@flung.dev/connect 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +125 -2
- package/dist/core/client.d.ts +46 -0
- package/dist/core/client.js +109 -0
- package/dist/core/flows.d.ts +36 -0
- package/dist/core/flows.js +59 -0
- package/dist/core/index.d.ts +10 -0
- package/dist/core/index.js +7 -0
- package/dist/core/platforms.d.ts +14 -0
- package/dist/core/platforms.js +49 -0
- package/dist/core/types.d.ts +40 -0
- package/dist/core/types.js +5 -0
- package/dist/element/copy.d.ts +33 -0
- package/dist/element/copy.js +29 -0
- package/dist/element/define.d.ts +7 -0
- package/dist/element/define.js +12 -0
- package/dist/element/dom.d.ts +19 -0
- package/dist/element/dom.js +29 -0
- package/dist/element/events.d.ts +25 -0
- package/dist/element/events.js +1 -0
- package/dist/element/flung-connect.d.ts +13 -0
- package/dist/element/flung-connect.js +251 -0
- package/dist/element/styles.d.ts +6 -0
- package/dist/element/styles.js +75 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/react/index.d.ts +30 -0
- package/dist/react/index.js +49 -0
- package/dist/register.d.ts +1 -0
- package/dist/register.js +7 -0
- package/package.json +73 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Flung
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @flung.dev/connect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`<flung-connect>`: a panel that lets the people using your app connect and disconnect their social accounts through Flung, on your own page. A web component, a React component, and the calls underneath for a page that draws its own. No runtime dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @flung.dev/connect
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
| Step | Where | What |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| 1 | Your server | Makes a connect token with your API key: `POST /v1/connect-tokens` with the page's origin. Keep the key there. |
|
|
14
|
+
| 2 | Your page | Hands the token to `<flung-connect>`. |
|
|
15
|
+
| 3 | The panel | Lists the platforms this Flung can connect and the accounts already connected. |
|
|
16
|
+
| 4 | The person | Presses **Connect**. The platform's consent page opens in a small window; when they approve, the window closes and the account appears. |
|
|
17
|
+
|
|
18
|
+
A connect token can only list, connect, reconnect and disconnect accounts, only from the origins it was made for, and stops working when it expires (one hour by default, at most one day). It cannot post, read the inbox or touch API keys.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
**Your server** (any language; this is the SDK):
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Flung } from '@flung.dev/sdk'
|
|
26
|
+
|
|
27
|
+
const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
|
|
28
|
+
|
|
29
|
+
// Behind your own sign-in: only make tokens for people who may manage this workspace's accounts.
|
|
30
|
+
app.post('/connect-token', async (_request, response) => {
|
|
31
|
+
const { token } = await flung.connectTokens.create({ origins: ['https://app.example.com'], expiresIn: 900 })
|
|
32
|
+
response.json({ token })
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Any page**:
|
|
37
|
+
|
|
38
|
+
```html
|
|
39
|
+
<script type="module">
|
|
40
|
+
import '@flung.dev/connect/register'
|
|
41
|
+
|
|
42
|
+
const panel = document.querySelector('flung-connect')
|
|
43
|
+
// A function: the panel asks again when a token expires.
|
|
44
|
+
panel.token = async () => (await (await fetch('/connect-token', { method: 'POST' })).json()).token
|
|
45
|
+
panel.addEventListener('flung-connect', (event) => console.log('Connected', event.detail.accounts))
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<flung-connect platforms="x,linkedin,bluesky,mastodon"></flung-connect>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**React**:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { FlungConnect } from '@flung.dev/connect/react'
|
|
55
|
+
|
|
56
|
+
export function Accounts() {
|
|
57
|
+
return <FlungConnect token={fetchConnectToken} platforms={['x', 'linkedin']} onConnect={({ accounts }) => console.log(accounts)} />
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
[`examples/vanilla`](../../examples/vanilla) runs the whole thing with a server of a few lines and nothing to install.
|
|
62
|
+
|
|
63
|
+
## Attributes and properties
|
|
64
|
+
|
|
65
|
+
| Attribute | Property | Meaning |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `token` | `token` | A connect token. The property also takes a function that returns a fresh one, which the panel calls again when the API says the token has expired. |
|
|
68
|
+
| `base-url` | | The API. Production (`https://api.flung.dev`) when left out; `https://api-preview.flung.dev` for preview. |
|
|
69
|
+
| `platforms` | | Offer only these, comma-separated. A token made with `platforms` is narrowed to those as well. |
|
|
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
|
+
| `theme` | | `light` or `dark`. Follows the page's scheme when left out. |
|
|
72
|
+
| | `labels` | Every word the panel shows, to put it in another language. See `DEFAULT_LABELS`. |
|
|
73
|
+
| | `refresh()` | Reads the accounts again. |
|
|
74
|
+
|
|
75
|
+
## Events
|
|
76
|
+
|
|
77
|
+
| Event | `detail` | When |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `flung-load` | `{ accounts }` | The accounts were read: on first show and after each change. |
|
|
80
|
+
| `flung-connect` | `{ session, accounts }` | A connect or reconnect finished. Facebook stores one account per page. `session` is null after a redirect. |
|
|
81
|
+
| `flung-disconnect` | `{ account }` | An account was disconnected. Its past posts keep their results. |
|
|
82
|
+
| `flung-error` | `{ code, message }` | A call or a connect failed: `access_denied`, `wrong_account`, `forbidden`, `network_error` and so on. |
|
|
83
|
+
|
|
84
|
+
Events bubble and cross the shadow root.
|
|
85
|
+
|
|
86
|
+
## Looks
|
|
87
|
+
|
|
88
|
+
The panel draws in a shadow root. Set any of `--flung-font`, `--flung-text`, `--flung-muted`, `--flung-surface`, `--flung-raised`, `--flung-border`, `--flung-accent`, `--flung-accent-text`, `--flung-danger`, `--flung-warning` and `--flung-radius` on the element, or style its parts with `::part()`: `panel`, `list`, `platform`, `badge`, `name`, `account`, `status`, `form`, `note`, `confirm`, `message`.
|
|
89
|
+
|
|
90
|
+
```css
|
|
91
|
+
flung-connect { --flung-accent: #0f766e; --flung-radius: 12px; }
|
|
92
|
+
flung-connect::part(panel) { border: 0; }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Behaviour worth knowing
|
|
96
|
+
|
|
97
|
+
- **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
|
+
- **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
|
+
- **Mastodon and Bluesky** ask first: the person's Mastodon server (required), or their Bluesky handle (optional; without it they sign in through bsky.social).
|
|
100
|
+
- **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
|
+
- **Disconnect** asks to confirm. The account then drops from the panel; it stays in the API as `disconnected`.
|
|
102
|
+
- **Safety.** Every name and message is set as text; nothing the API returns becomes markup. The panel sends the token to the API address only, and no cookie.
|
|
103
|
+
|
|
104
|
+
## Without the element
|
|
105
|
+
|
|
106
|
+
`@flung.dev/connect/core` has the calls (`ConnectClient`) and the flows (`connectInPopup`, `readRedirectResult`) for a page that draws its own panel.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { ConnectClient, connectInPopup } from '@flung.dev/connect/core'
|
|
110
|
+
|
|
111
|
+
const client = new ConnectClient({ token })
|
|
112
|
+
button.onclick = async () => {
|
|
113
|
+
const outcome = await connectInPopup(client, () => client.connect('linkedin'))
|
|
114
|
+
if (outcome.kind === 'finished' && outcome.session.status === 'connected') render(await client.accounts())
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Contents
|
|
119
|
+
|
|
120
|
+
| Path | Purpose |
|
|
121
|
+
|---|---|
|
|
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, words, events and node helpers. |
|
|
124
|
+
| `src/react/` | `<FlungConnect>`, the element for React. |
|
|
125
|
+
| `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. |
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Account, ConnectSession, Platform, PlatformInfo } from './types.js';
|
|
2
|
+
export declare const DEFAULT_BASE_URL = "https://api.flung.dev";
|
|
3
|
+
/** A connect token (`flung_ct_...`), or a function that fetches a fresh one from your server. */
|
|
4
|
+
export type TokenSource = string | (() => Promise<string>);
|
|
5
|
+
export interface ConnectClientOptions {
|
|
6
|
+
token: TokenSource;
|
|
7
|
+
/** Where the API lives. Defaults to production; `https://api-preview.flung.dev` for preview. */
|
|
8
|
+
baseUrl?: string | undefined;
|
|
9
|
+
/** A `fetch` to use instead of the global one. */
|
|
10
|
+
fetch?: typeof fetch | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** A refused call: the API's stable `code` (`forbidden`, `not_configured`, ...) and its message. */
|
|
13
|
+
export declare class FlungConnectError extends Error {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly code: string;
|
|
16
|
+
readonly details: unknown;
|
|
17
|
+
readonly name = "FlungConnectError";
|
|
18
|
+
constructor(status: number, code: string, message: string, details?: unknown);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The calls a connect token may make, over `fetch`. A token source that is a function is asked again when the API says
|
|
22
|
+
* the token has expired, once per call, so a long-open page keeps working.
|
|
23
|
+
*/
|
|
24
|
+
export declare class ConnectClient {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(options: ConnectClientOptions);
|
|
27
|
+
platforms(): Promise<PlatformInfo[]>;
|
|
28
|
+
accounts(): Promise<Account[]>;
|
|
29
|
+
/** Starts connecting a new account. `handle` is the Bluesky handle, or the Mastodon instance (required there). */
|
|
30
|
+
connect(platform: Platform, options?: {
|
|
31
|
+
handle?: string | undefined;
|
|
32
|
+
redirectUrl?: string | undefined;
|
|
33
|
+
}): Promise<ConnectSession>;
|
|
34
|
+
/** Starts reconnecting an account that needs it; the person must sign in as the same platform user. */
|
|
35
|
+
reconnect(accountId: string, options?: {
|
|
36
|
+
redirectUrl?: string | undefined;
|
|
37
|
+
}): Promise<ConnectSession>;
|
|
38
|
+
/** Deletes the account's stored tokens. It stays listed as `disconnected`. */
|
|
39
|
+
disconnect(accountId: string): Promise<Account>;
|
|
40
|
+
session(id: string): Promise<ConnectSession>;
|
|
41
|
+
/** Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. */
|
|
42
|
+
waitForSession(id: string, options?: {
|
|
43
|
+
signal?: AbortSignal | undefined;
|
|
44
|
+
intervalMs?: number | undefined;
|
|
45
|
+
}): Promise<ConnectSession>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const DEFAULT_BASE_URL = 'https://api.flung.dev';
|
|
2
|
+
const POLL_MS = 2_000;
|
|
3
|
+
/** A refused call: the API's stable `code` (`forbidden`, `not_configured`, ...) and its message. */
|
|
4
|
+
export class FlungConnectError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
code;
|
|
7
|
+
details;
|
|
8
|
+
name = 'FlungConnectError';
|
|
9
|
+
constructor(status, code, message, details = undefined) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The calls a connect token may make, over `fetch`. A token source that is a function is asked again when the API says
|
|
18
|
+
* the token has expired, once per call, so a long-open page keeps working.
|
|
19
|
+
*/
|
|
20
|
+
export class ConnectClient {
|
|
21
|
+
#baseUrl;
|
|
22
|
+
#fetch;
|
|
23
|
+
#source;
|
|
24
|
+
#token;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
27
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
28
|
+
this.#source = options.token;
|
|
29
|
+
this.#token = typeof options.token === 'string' ? options.token : null;
|
|
30
|
+
}
|
|
31
|
+
async platforms() {
|
|
32
|
+
return (await this.#call('GET', '/v1/platforms')).data;
|
|
33
|
+
}
|
|
34
|
+
async accounts() {
|
|
35
|
+
return (await this.#call('GET', '/v1/accounts')).data;
|
|
36
|
+
}
|
|
37
|
+
/** Starts connecting a new account. `handle` is the Bluesky handle, or the Mastodon instance (required there). */
|
|
38
|
+
async connect(platform, options = {}) {
|
|
39
|
+
return this.#call('POST', `/v1/connect/${encodeURIComponent(platform)}`, compact({ handle: options.handle, redirectUrl: options.redirectUrl }));
|
|
40
|
+
}
|
|
41
|
+
/** Starts reconnecting an account that needs it; the person must sign in as the same platform user. */
|
|
42
|
+
async reconnect(accountId, options = {}) {
|
|
43
|
+
return this.#call('POST', `/v1/accounts/${encodeURIComponent(accountId)}/reconnect`, compact({ redirectUrl: options.redirectUrl }));
|
|
44
|
+
}
|
|
45
|
+
/** Deletes the account's stored tokens. It stays listed as `disconnected`. */
|
|
46
|
+
async disconnect(accountId) {
|
|
47
|
+
return this.#call('DELETE', `/v1/accounts/${encodeURIComponent(accountId)}`);
|
|
48
|
+
}
|
|
49
|
+
async session(id) {
|
|
50
|
+
return this.#call('GET', `/v1/connect-sessions/${encodeURIComponent(id)}`);
|
|
51
|
+
}
|
|
52
|
+
/** Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. */
|
|
53
|
+
async waitForSession(id, options = {}) {
|
|
54
|
+
for (;;) {
|
|
55
|
+
options.signal?.throwIfAborted();
|
|
56
|
+
const session = await this.session(id);
|
|
57
|
+
if (session.status !== 'pending')
|
|
58
|
+
return session;
|
|
59
|
+
await delay(options.intervalMs ?? POLL_MS, options.signal);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async #call(method, path, body) {
|
|
63
|
+
let response = await this.#send(method, path, body, await this.#currentToken(false));
|
|
64
|
+
if (response.status === 401 && typeof this.#source === 'function')
|
|
65
|
+
response = await this.#send(method, path, body, await this.#currentToken(true));
|
|
66
|
+
const text = await response.text();
|
|
67
|
+
const parsed = parse(text);
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const error = parsed?.error;
|
|
70
|
+
throw new FlungConnectError(response.status, typeof error?.code === 'string' ? error.code : 'http_error', typeof error?.message === 'string' ? error.message : `Flung answered ${response.status}.`, error?.details);
|
|
71
|
+
}
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
#send(method, path, body, token) {
|
|
75
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
76
|
+
if (body !== undefined)
|
|
77
|
+
headers['content-type'] = 'application/json';
|
|
78
|
+
return this.#fetch(`${this.#baseUrl}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }).catch((error) => {
|
|
79
|
+
throw new FlungConnectError(0, 'network_error', `Could not reach Flung: ${String(error)}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async #currentToken(fresh) {
|
|
83
|
+
if (typeof this.#source === 'string')
|
|
84
|
+
return this.#source;
|
|
85
|
+
if (this.#token === null || fresh)
|
|
86
|
+
this.#token = await this.#source();
|
|
87
|
+
return this.#token;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function compact(values) {
|
|
91
|
+
return Object.fromEntries(Object.entries(values).filter((entry) => entry[1] !== undefined));
|
|
92
|
+
}
|
|
93
|
+
function parse(text) {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(text);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function delay(ms, signal) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const timer = setTimeout(resolve, ms);
|
|
104
|
+
signal?.addEventListener('abort', () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
reject(signal.reason);
|
|
107
|
+
}, { once: true });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ConnectClient } from './client.js';
|
|
2
|
+
import type { ConnectSession } from './types.js';
|
|
3
|
+
/** How a connect in a window ended: the session, or `blocked` when the browser refused to open the window. */
|
|
4
|
+
export type PopupOutcome = {
|
|
5
|
+
kind: 'finished';
|
|
6
|
+
session: ConnectSession;
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'blocked';
|
|
9
|
+
session: ConnectSession;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Opens the platform's consent page in a small window and resolves when the person finishes. Call it straight from a
|
|
13
|
+
* click: browsers open a window only during one, so it is opened before the session is asked for, then sent there.
|
|
14
|
+
*
|
|
15
|
+
* It waits on the session, not the window. A platform's sign-in page may cut the link back to this page, after which
|
|
16
|
+
* the window looks closed while the person is still in it, so only the session can say they are done. Abort `signal`
|
|
17
|
+
* to stop waiting (a Cancel button).
|
|
18
|
+
*/
|
|
19
|
+
export declare function connectInPopup(client: ConnectClient, start: () => Promise<ConnectSession>, options?: {
|
|
20
|
+
signal?: AbortSignal | undefined;
|
|
21
|
+
onStarted?: ((session: ConnectSession) => void) | undefined;
|
|
22
|
+
}): Promise<PopupOutcome>;
|
|
23
|
+
/** What Flung put on `redirectUrl` when a connect that left the page came back to it. */
|
|
24
|
+
export interface RedirectResult {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
status: 'connected' | 'error';
|
|
27
|
+
accountIds: string[];
|
|
28
|
+
error: {
|
|
29
|
+
code: string;
|
|
30
|
+
message: string;
|
|
31
|
+
} | null;
|
|
32
|
+
}
|
|
33
|
+
/** Reads the result from an address Flung sent the person back to, or null when it carries none. */
|
|
34
|
+
export declare function readRedirectResult(address: string): RedirectResult | null;
|
|
35
|
+
/** The address without Flung's result, to put back in the address bar once it is read. */
|
|
36
|
+
export declare function withoutRedirectResult(address: string): string;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const POPUP_NAME = 'flung-connect';
|
|
2
|
+
const POPUP_FEATURES = 'popup,width=520,height=720';
|
|
3
|
+
/**
|
|
4
|
+
* Opens the platform's consent page in a small window and resolves when the person finishes. Call it straight from a
|
|
5
|
+
* click: browsers open a window only during one, so it is opened before the session is asked for, then sent there.
|
|
6
|
+
*
|
|
7
|
+
* It waits on the session, not the window. A platform's sign-in page may cut the link back to this page, after which
|
|
8
|
+
* the window looks closed while the person is still in it, so only the session can say they are done. Abort `signal`
|
|
9
|
+
* to stop waiting (a Cancel button).
|
|
10
|
+
*/
|
|
11
|
+
export async function connectInPopup(client, start, options = {}) {
|
|
12
|
+
const popup = typeof window === 'undefined' ? null : window.open('', POPUP_NAME, POPUP_FEATURES);
|
|
13
|
+
let session;
|
|
14
|
+
try {
|
|
15
|
+
session = await start();
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
popup?.close();
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
options.onStarted?.(session);
|
|
22
|
+
if (session.authorizationUrl === null) {
|
|
23
|
+
popup?.close();
|
|
24
|
+
return { kind: 'finished', session };
|
|
25
|
+
}
|
|
26
|
+
if (popup === null)
|
|
27
|
+
return { kind: 'blocked', session };
|
|
28
|
+
popup.location.href = session.authorizationUrl;
|
|
29
|
+
try {
|
|
30
|
+
const finished = await client.waitForSession(session.id, { signal: options.signal });
|
|
31
|
+
return { kind: 'finished', session: finished };
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
// May do nothing once the platform cut the link; Flung's last page then says the window can be closed.
|
|
35
|
+
popup.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const PARAMS = ['flung_session_id', 'flung_status', 'flung_account_ids', 'flung_error', 'flung_message'];
|
|
39
|
+
/** Reads the result from an address Flung sent the person back to, or null when it carries none. */
|
|
40
|
+
export function readRedirectResult(address) {
|
|
41
|
+
const query = new URL(address).searchParams;
|
|
42
|
+
const sessionId = query.get('flung_session_id');
|
|
43
|
+
const status = query.get('flung_status');
|
|
44
|
+
if (sessionId === null || (status !== 'connected' && status !== 'error'))
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
sessionId,
|
|
48
|
+
status,
|
|
49
|
+
accountIds: (query.get('flung_account_ids') ?? '').split(',').filter((id) => id !== ''),
|
|
50
|
+
error: status === 'error' ? { code: query.get('flung_error') ?? 'platform_error', message: query.get('flung_message') ?? '' } : null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The address without Flung's result, to put back in the address bar once it is read. */
|
|
54
|
+
export function withoutRedirectResult(address) {
|
|
55
|
+
const url = new URL(address);
|
|
56
|
+
for (const name of PARAMS)
|
|
57
|
+
url.searchParams.delete(name);
|
|
58
|
+
return url.toString();
|
|
59
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@flung.dev/connect/core`: the connect calls and flows with no element, for a page that draws its own panel.
|
|
3
|
+
*/
|
|
4
|
+
export { ConnectClient, DEFAULT_BASE_URL, FlungConnectError } from './client.js';
|
|
5
|
+
export type { ConnectClientOptions, TokenSource } from './client.js';
|
|
6
|
+
export { connectInPopup, readRedirectResult, withoutRedirectResult } from './flows.js';
|
|
7
|
+
export type { PopupOutcome, RedirectResult } from './flows.js';
|
|
8
|
+
export { HANDLE_PROMPT, isPlatform, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
|
|
9
|
+
export { PLATFORMS } from './types.js';
|
|
10
|
+
export type { Account, AccountStatus, ConnectSession, ConnectSessionStatus, Platform, PlatformInfo } from './types.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@flung.dev/connect/core`: the connect calls and flows with no element, for a page that draws its own panel.
|
|
3
|
+
*/
|
|
4
|
+
export { ConnectClient, DEFAULT_BASE_URL, FlungConnectError } from './client.js';
|
|
5
|
+
export { connectInPopup, readRedirectResult, withoutRedirectResult } from './flows.js';
|
|
6
|
+
export { HANDLE_PROMPT, isPlatform, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
|
|
7
|
+
export { PLATFORMS } from './types.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Platform } from './types.js';
|
|
2
|
+
export declare const PLATFORM_NAMES: Readonly<Record<Platform, string>>;
|
|
3
|
+
/** What a connect asks the person for first: Mastodon needs their instance; Bluesky takes a handle when they have one. */
|
|
4
|
+
export declare const HANDLE_PROMPT: Readonly<Partial<Record<Platform, {
|
|
5
|
+
required: boolean;
|
|
6
|
+
}>>>;
|
|
7
|
+
export declare function isPlatform(value: string): value is Platform;
|
|
8
|
+
/** `x, linkedin` as platforms, dropping anything that is not one. Null for an empty or missing list. */
|
|
9
|
+
export declare function parsePlatformList(value: string | null | undefined): Platform[] | null;
|
|
10
|
+
/**
|
|
11
|
+
* The platforms a connect token was made for, read from its payload so the panel offers only those. Only a hint: the
|
|
12
|
+
* API checks the signature and refuses the rest. Null when the token names none or cannot be read.
|
|
13
|
+
*/
|
|
14
|
+
export declare function tokenPlatforms(token: string): Platform[] | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PLATFORMS } from './types.js';
|
|
2
|
+
export const PLATFORM_NAMES = {
|
|
3
|
+
x: 'X',
|
|
4
|
+
instagram: 'Instagram',
|
|
5
|
+
facebook: 'Facebook',
|
|
6
|
+
threads: 'Threads',
|
|
7
|
+
linkedin: 'LinkedIn',
|
|
8
|
+
tiktok: 'TikTok',
|
|
9
|
+
youtube: 'YouTube',
|
|
10
|
+
pinterest: 'Pinterest',
|
|
11
|
+
bluesky: 'Bluesky',
|
|
12
|
+
mastodon: 'Mastodon',
|
|
13
|
+
discord: 'Discord',
|
|
14
|
+
farcaster: 'Farcaster',
|
|
15
|
+
};
|
|
16
|
+
/** What a connect asks the person for first: Mastodon needs their instance; Bluesky takes a handle when they have one. */
|
|
17
|
+
export const HANDLE_PROMPT = {
|
|
18
|
+
mastodon: { required: true },
|
|
19
|
+
bluesky: { required: false },
|
|
20
|
+
};
|
|
21
|
+
export function isPlatform(value) {
|
|
22
|
+
return PLATFORMS.includes(value);
|
|
23
|
+
}
|
|
24
|
+
/** `x, linkedin` as platforms, dropping anything that is not one. Null for an empty or missing list. */
|
|
25
|
+
export function parsePlatformList(value) {
|
|
26
|
+
if (value === null || value === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
const platforms = value
|
|
29
|
+
.split(',')
|
|
30
|
+
.map((part) => part.trim().toLowerCase())
|
|
31
|
+
.filter(isPlatform);
|
|
32
|
+
return platforms.length === 0 ? null : [...new Set(platforms)];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The platforms a connect token was made for, read from its payload so the panel offers only those. Only a hint: the
|
|
36
|
+
* API checks the signature and refuses the rest. Null when the token names none or cannot be read.
|
|
37
|
+
*/
|
|
38
|
+
export function tokenPlatforms(token) {
|
|
39
|
+
const body = /^flung_ct_([\w-]+)\./.exec(token)?.[1];
|
|
40
|
+
if (body === undefined)
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const payload = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/')));
|
|
44
|
+
return Array.isArray(payload.pl) ? payload.pl.filter((value) => typeof value === 'string' && isPlatform(value)) : null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few shapes of the Flung API a connect panel reads, written out here so the package needs nothing else. They are
|
|
3
|
+
* the API's own (`spec/openapi.json`): `PlatformInfo`, `Account` and `ConnectSession`, trimmed to what is used.
|
|
4
|
+
*/
|
|
5
|
+
export declare const PLATFORMS: readonly ['x', 'instagram', 'facebook', 'threads', 'linkedin', 'tiktok', 'youtube', 'pinterest', 'bluesky', 'mastodon', 'discord', 'farcaster'];
|
|
6
|
+
export type Platform = (typeof PLATFORMS)[number];
|
|
7
|
+
export interface PlatformInfo {
|
|
8
|
+
platform: Platform;
|
|
9
|
+
/** Whether this Flung has the platform's app credentials, so accounts can be connected. */
|
|
10
|
+
connectable: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type AccountStatus = 'active' | 'needs_reconnect' | 'disconnected';
|
|
13
|
+
export interface Account {
|
|
14
|
+
id: string;
|
|
15
|
+
platform: Platform;
|
|
16
|
+
platformUserId: string;
|
|
17
|
+
username: string | null;
|
|
18
|
+
profileId: string | null;
|
|
19
|
+
status: AccountStatus;
|
|
20
|
+
/** When the person must reconnect by, for platforms whose connections cannot be renewed forever. */
|
|
21
|
+
reconnectBy: string | null;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
export type ConnectSessionStatus = 'pending' | 'connected' | 'failed' | 'expired';
|
|
25
|
+
export interface ConnectSession {
|
|
26
|
+
id: string;
|
|
27
|
+
platform: Platform;
|
|
28
|
+
/** The account being reconnected; null for a new connection. */
|
|
29
|
+
accountId: string | null;
|
|
30
|
+
status: ConnectSessionStatus;
|
|
31
|
+
/** The platform's consent page, while the session is pending. */
|
|
32
|
+
authorizationUrl: string | null;
|
|
33
|
+
expiresAt: string;
|
|
34
|
+
/** The accounts stored, once connected. Facebook connects one per page. */
|
|
35
|
+
accountIds: string[];
|
|
36
|
+
error: {
|
|
37
|
+
code: string;
|
|
38
|
+
message: string;
|
|
39
|
+
} | null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few shapes of the Flung API a connect panel reads, written out here so the package needs nothing else. They are
|
|
3
|
+
* the API's own (`spec/openapi.json`): `PlatformInfo`, `Account` and `ConnectSession`, trimmed to what is used.
|
|
4
|
+
*/
|
|
5
|
+
export const PLATFORMS = ['x', 'instagram', 'facebook', 'threads', 'linkedin', 'tiktok', 'youtube', 'pinterest', 'bluesky', 'mastodon', 'discord', 'farcaster'];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every word the panel shows, in English. Pass `labels` to put it in another language; `{platform}`, `{account}`,
|
|
3
|
+
* `{message}` and `{date}` are filled in where a label has them.
|
|
4
|
+
*/
|
|
5
|
+
export interface ConnectLabels {
|
|
6
|
+
/** The panel's name for a screen reader. */
|
|
7
|
+
panel: string;
|
|
8
|
+
connect: string;
|
|
9
|
+
connectAnother: string;
|
|
10
|
+
reconnect: string;
|
|
11
|
+
disconnect: string;
|
|
12
|
+
confirmDisconnect: string;
|
|
13
|
+
keep: string;
|
|
14
|
+
cancel: string;
|
|
15
|
+
continue: string;
|
|
16
|
+
handleBluesky: string;
|
|
17
|
+
handleMastodon: string;
|
|
18
|
+
waiting: string;
|
|
19
|
+
blocked: string;
|
|
20
|
+
openSignIn: string;
|
|
21
|
+
connected: string;
|
|
22
|
+
failed: string;
|
|
23
|
+
expired: string;
|
|
24
|
+
disconnected: string;
|
|
25
|
+
needsReconnect: string;
|
|
26
|
+
reconnectBy: string;
|
|
27
|
+
loading: string;
|
|
28
|
+
loadFailed: string;
|
|
29
|
+
empty: string;
|
|
30
|
+
noToken: string;
|
|
31
|
+
}
|
|
32
|
+
export declare const DEFAULT_LABELS: ConnectLabels;
|
|
33
|
+
export declare function format(template: string, values: Readonly<Record<string, string>>): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const DEFAULT_LABELS = {
|
|
2
|
+
panel: 'Connected accounts',
|
|
3
|
+
connect: 'Connect',
|
|
4
|
+
connectAnother: 'Add another',
|
|
5
|
+
reconnect: 'Reconnect',
|
|
6
|
+
disconnect: 'Disconnect',
|
|
7
|
+
confirmDisconnect: 'Disconnect {account}? Posts to it will stop.',
|
|
8
|
+
keep: 'Keep it',
|
|
9
|
+
cancel: 'Cancel',
|
|
10
|
+
continue: 'Continue',
|
|
11
|
+
handleBluesky: 'Your Bluesky handle, such as alice.bsky.social (optional)',
|
|
12
|
+
handleMastodon: 'Your Mastodon server, such as mastodon.social',
|
|
13
|
+
waiting: 'Finish signing in to {platform} in the window that opened.',
|
|
14
|
+
blocked: 'Your browser did not open the {platform} window.',
|
|
15
|
+
openSignIn: 'Open {platform} sign-in',
|
|
16
|
+
connected: '{platform} is connected.',
|
|
17
|
+
failed: '{platform} was not connected: {message}',
|
|
18
|
+
expired: 'Signing in to {platform} took too long. Try again.',
|
|
19
|
+
disconnected: '{account} is disconnected.',
|
|
20
|
+
needsReconnect: 'Needs reconnecting',
|
|
21
|
+
reconnectBy: 'Reconnect by {date}',
|
|
22
|
+
loading: 'Loading accounts…',
|
|
23
|
+
loadFailed: 'Could not load accounts: {message}',
|
|
24
|
+
empty: 'There is nothing to connect here.',
|
|
25
|
+
noToken: 'This panel has no connect token yet.',
|
|
26
|
+
};
|
|
27
|
+
export function format(template, values) {
|
|
28
|
+
return template.replace(/\{(\w+)\}/g, (whole, name) => values[name] ?? whole);
|
|
29
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The element's tag. */
|
|
2
|
+
export declare const TAG_NAME = "flung-connect";
|
|
3
|
+
/**
|
|
4
|
+
* Registers `<flung-connect>` once. Safe to call many times, from several copies of the package on one page, and where
|
|
5
|
+
* there is no `customElements` (a server render), where it does nothing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function defineFlungConnect(tag?: string): void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createFlungConnectClass } from './flung-connect.js';
|
|
2
|
+
/** The element's tag. */
|
|
3
|
+
export const TAG_NAME = 'flung-connect';
|
|
4
|
+
/**
|
|
5
|
+
* Registers `<flung-connect>` once. Safe to call many times, from several copies of the package on one page, and where
|
|
6
|
+
* there is no `customElements` (a server render), where it does nothing.
|
|
7
|
+
*/
|
|
8
|
+
export function defineFlungConnect(tag = TAG_NAME) {
|
|
9
|
+
if (typeof customElements === 'undefined' || customElements.get(tag) !== undefined)
|
|
10
|
+
return;
|
|
11
|
+
customElements.define(tag, createFlungConnectClass());
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building the element's own nodes with `createElement`, never from a string of HTML, so nothing the API or a page
|
|
3
|
+
* supplies (an account name, an error message) can become markup. Attributes go through `setAttribute`, text through
|
|
4
|
+
* `textContent`.
|
|
5
|
+
*/
|
|
6
|
+
import type { Account } from '../core/types.js';
|
|
7
|
+
type Attributes = Readonly<Record<string, string | number | boolean | undefined>>;
|
|
8
|
+
/** An HTML element of the element's own. `true` sets an empty attribute; `false` and `undefined` set none. */
|
|
9
|
+
export declare function html<K extends keyof HTMLElementTagNameMap>(tag: K, attributes?: Attributes, ...children: (Node | string | null)[]): HTMLElementTagNameMap[K];
|
|
10
|
+
/** How an account is named in the panel: its username, or its platform id when it has none. */
|
|
11
|
+
export declare function displayName(account: Account): string;
|
|
12
|
+
/** A button that calls `onClick`, with a `data-key` so focus can be put back on it after the panel is drawn again. */
|
|
13
|
+
export declare function button(text: string, key: string, onClick: () => void, options?: {
|
|
14
|
+
primary?: boolean;
|
|
15
|
+
danger?: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
label?: string;
|
|
18
|
+
}): HTMLButtonElement;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building the element's own nodes with `createElement`, never from a string of HTML, so nothing the API or a page
|
|
3
|
+
* supplies (an account name, an error message) can become markup. Attributes go through `setAttribute`, text through
|
|
4
|
+
* `textContent`.
|
|
5
|
+
*/
|
|
6
|
+
/** An HTML element of the element's own. `true` sets an empty attribute; `false` and `undefined` set none. */
|
|
7
|
+
export function html(tag, attributes = {}, ...children) {
|
|
8
|
+
const element = document.createElement(tag);
|
|
9
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
10
|
+
if (value === undefined || value === false)
|
|
11
|
+
continue;
|
|
12
|
+
element.setAttribute(name, value === true ? '' : String(value));
|
|
13
|
+
}
|
|
14
|
+
for (const child of children)
|
|
15
|
+
if (child !== null)
|
|
16
|
+
element.append(child);
|
|
17
|
+
return element;
|
|
18
|
+
}
|
|
19
|
+
/** How an account is named in the panel: its username, or its platform id when it has none. */
|
|
20
|
+
export function displayName(account) {
|
|
21
|
+
return account.username ?? account.platformUserId;
|
|
22
|
+
}
|
|
23
|
+
/** A button that calls `onClick`, with a `data-key` so focus can be put back on it after the panel is drawn again. */
|
|
24
|
+
export function button(text, key, onClick, options = {}) {
|
|
25
|
+
const className = options.primary === true ? 'primary' : options.danger === true ? 'danger' : undefined;
|
|
26
|
+
const element = html('button', { type: 'button', class: className, 'data-key': key, disabled: options.disabled === true, 'aria-label': options.label }, text);
|
|
27
|
+
element.addEventListener('click', onClick);
|
|
28
|
+
return element;
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Account, ConnectSession } from '../core/types.js';
|
|
2
|
+
/** `flung-load`: the accounts were read, on first show and after every change. */
|
|
3
|
+
export interface LoadDetail {
|
|
4
|
+
accounts: Account[];
|
|
5
|
+
}
|
|
6
|
+
/** `flung-connect`: a connect or reconnect finished. `accounts` are the ones stored; Facebook stores one per page. */
|
|
7
|
+
export interface ConnectDetail {
|
|
8
|
+
session: ConnectSession | null;
|
|
9
|
+
accounts: Account[];
|
|
10
|
+
}
|
|
11
|
+
/** `flung-disconnect`: an account was disconnected. */
|
|
12
|
+
export interface DisconnectDetail {
|
|
13
|
+
account: Account;
|
|
14
|
+
}
|
|
15
|
+
/** `flung-error`: a call or a connect failed. `code` is the API's stable code, such as `forbidden` or `access_denied`. */
|
|
16
|
+
export interface ErrorDetail {
|
|
17
|
+
code: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
export interface FlungConnectEventMap {
|
|
21
|
+
'flung-load': CustomEvent<LoadDetail>;
|
|
22
|
+
'flung-connect': CustomEvent<ConnectDetail>;
|
|
23
|
+
'flung-disconnect': CustomEvent<DisconnectDetail>;
|
|
24
|
+
'flung-error': CustomEvent<ErrorDetail>;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TokenSource } from '../core/client.js';
|
|
2
|
+
import type { ConnectLabels } from './copy.js';
|
|
3
|
+
/** `<flung-connect>` as a page sees it. */
|
|
4
|
+
export interface FlungConnectElement extends HTMLElement {
|
|
5
|
+
/** A connect token, or a function that fetches a fresh one from your server. The `token` attribute sets a string. */
|
|
6
|
+
token: TokenSource | null;
|
|
7
|
+
get labels(): ConnectLabels;
|
|
8
|
+
set labels(value: Partial<ConnectLabels> | undefined);
|
|
9
|
+
/** Reads the accounts again, as after a connect. */
|
|
10
|
+
refresh(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/** Builds the class on first use, so importing the package on a server, where there is no `HTMLElement`, is safe. */
|
|
13
|
+
export declare function createFlungConnectClass(): new () => FlungConnectElement;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { ConnectClient, FlungConnectError } from '../core/client.js';
|
|
2
|
+
import { connectInPopup, readRedirectResult, withoutRedirectResult } from '../core/flows.js';
|
|
3
|
+
import { HANDLE_PROMPT, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from '../core/platforms.js';
|
|
4
|
+
import { DEFAULT_LABELS, format } from './copy.js';
|
|
5
|
+
import { button, displayName, html } from './dom.js';
|
|
6
|
+
import { STYLES } from './styles.js';
|
|
7
|
+
/** How soon a reconnect deadline is worth showing. */
|
|
8
|
+
const SOON_MS = 14 * 86_400_000;
|
|
9
|
+
/** Builds the class on first use, so importing the package on a server, where there is no `HTMLElement`, is safe. */
|
|
10
|
+
export function createFlungConnectClass() {
|
|
11
|
+
return class FlungConnect extends HTMLElement {
|
|
12
|
+
static observedAttributes = ['token', 'base-url', 'platforms'];
|
|
13
|
+
#root = this.attachShadow({ mode: 'open' });
|
|
14
|
+
#source = null;
|
|
15
|
+
#client = null;
|
|
16
|
+
#labels = DEFAULT_LABELS;
|
|
17
|
+
#platforms = [];
|
|
18
|
+
#accounts = [];
|
|
19
|
+
#rows = new Map();
|
|
20
|
+
#message = { text: '', error: false };
|
|
21
|
+
#focus = null;
|
|
22
|
+
#started = false;
|
|
23
|
+
get token() {
|
|
24
|
+
return this.#source;
|
|
25
|
+
}
|
|
26
|
+
set token(value) {
|
|
27
|
+
this.#source = value;
|
|
28
|
+
this.#reset();
|
|
29
|
+
}
|
|
30
|
+
get labels() {
|
|
31
|
+
return this.#labels;
|
|
32
|
+
}
|
|
33
|
+
set labels(value) {
|
|
34
|
+
this.#labels = { ...DEFAULT_LABELS, ...value };
|
|
35
|
+
this.#render();
|
|
36
|
+
}
|
|
37
|
+
refresh() {
|
|
38
|
+
return this.#load();
|
|
39
|
+
}
|
|
40
|
+
connectedCallback() {
|
|
41
|
+
if (this.#started)
|
|
42
|
+
return;
|
|
43
|
+
this.#started = true;
|
|
44
|
+
if (this.#source === null && this.hasAttribute('token'))
|
|
45
|
+
this.#source = this.getAttribute('token');
|
|
46
|
+
this.#reset();
|
|
47
|
+
}
|
|
48
|
+
attributeChangedCallback(name, previous, next) {
|
|
49
|
+
if (previous === next || !this.#started)
|
|
50
|
+
return;
|
|
51
|
+
if (name === 'token')
|
|
52
|
+
this.#source = next;
|
|
53
|
+
if (name === 'platforms')
|
|
54
|
+
void this.#load();
|
|
55
|
+
else
|
|
56
|
+
this.#reset();
|
|
57
|
+
}
|
|
58
|
+
#reset() {
|
|
59
|
+
if (!this.#started)
|
|
60
|
+
return;
|
|
61
|
+
for (const row of this.#rows.values())
|
|
62
|
+
if (row.kind === 'waiting')
|
|
63
|
+
row.abort.abort();
|
|
64
|
+
this.#rows.clear();
|
|
65
|
+
this.#client = this.#source === null ? null : new ConnectClient({ token: this.#source, baseUrl: this.getAttribute('base-url') ?? undefined });
|
|
66
|
+
void this.#load().then(() => this.#readReturn());
|
|
67
|
+
}
|
|
68
|
+
async #load() {
|
|
69
|
+
const client = this.#client;
|
|
70
|
+
if (client === null)
|
|
71
|
+
return this.#say(this.#labels.noToken);
|
|
72
|
+
if (this.#platforms.length === 0)
|
|
73
|
+
this.#say(this.#labels.loading);
|
|
74
|
+
try {
|
|
75
|
+
const [platforms, accounts] = await Promise.all([client.platforms(), client.accounts()]);
|
|
76
|
+
const asked = parsePlatformList(this.getAttribute('platforms'));
|
|
77
|
+
const granted = typeof this.#source === 'string' ? tokenPlatforms(this.#source) : null;
|
|
78
|
+
this.#platforms = platforms.map((info) => info.platform).filter((platform) => platforms.find((info) => info.platform === platform)?.connectable === true && (asked ?? [platform]).includes(platform) && (granted ?? [platform]).includes(platform));
|
|
79
|
+
this.#accounts = accounts.filter((account) => account.status !== 'disconnected' && this.#platforms.includes(account.platform));
|
|
80
|
+
if (this.#message.text === this.#labels.loading || this.#message.text === this.#labels.noToken)
|
|
81
|
+
this.#message = { text: this.#platforms.length === 0 ? this.#labels.empty : '', error: false };
|
|
82
|
+
this.#render();
|
|
83
|
+
this.#emit('flung-load', { accounts: this.#accounts });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
this.#fail(error, (message) => format(this.#labels.loadFailed, { message }));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** A connect that left the page (`mode="redirect"`) and came back: report it, and take Flung's result off the address. */
|
|
90
|
+
#readReturn() {
|
|
91
|
+
if (typeof location === 'undefined')
|
|
92
|
+
return;
|
|
93
|
+
const result = readRedirectResult(location.href);
|
|
94
|
+
if (result === null)
|
|
95
|
+
return;
|
|
96
|
+
history.replaceState(history.state, '', withoutRedirectResult(location.href));
|
|
97
|
+
if (result.status === 'connected') {
|
|
98
|
+
const accounts = this.#accounts.filter((account) => result.accountIds.includes(account.id));
|
|
99
|
+
const platform = accounts[0]?.platform;
|
|
100
|
+
this.#say(format(this.#labels.connected, { platform: platform === undefined ? 'The account' : PLATFORM_NAMES[platform] }));
|
|
101
|
+
this.#emit('flung-connect', { session: null, accounts });
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
this.#say(result.error?.message ?? '', true);
|
|
105
|
+
this.#emit('flung-error', { code: result.error?.code ?? 'platform_error', message: result.error?.message ?? '' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Called straight from a click or a submit: a window can only be opened during one. */
|
|
109
|
+
#start(platform, account, handle) {
|
|
110
|
+
const client = this.#client;
|
|
111
|
+
if (client === null)
|
|
112
|
+
return;
|
|
113
|
+
const redirect = this.getAttribute('mode') === 'redirect';
|
|
114
|
+
const redirectUrl = redirect ? withoutRedirectResult(location.href) : undefined;
|
|
115
|
+
const begin = () => (account === null ? client.connect(platform, { handle, redirectUrl }) : client.reconnect(account.id, { redirectUrl }));
|
|
116
|
+
const abort = new AbortController();
|
|
117
|
+
const row = { kind: 'waiting', abort, blockedUrl: null };
|
|
118
|
+
this.#rows.set(platform, row);
|
|
119
|
+
this.#say('');
|
|
120
|
+
if (redirect) {
|
|
121
|
+
void begin().then((session) => session.authorizationUrl !== null && location.assign(session.authorizationUrl), (error) => this.#settle(platform, error));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
void connectInPopup(client, begin, { signal: abort.signal })
|
|
125
|
+
.then(async (outcome) => {
|
|
126
|
+
if (outcome.kind === 'finished')
|
|
127
|
+
return outcome.session;
|
|
128
|
+
row.blockedUrl = outcome.session.authorizationUrl;
|
|
129
|
+
this.#render();
|
|
130
|
+
return client.waitForSession(outcome.session.id, { signal: abort.signal });
|
|
131
|
+
})
|
|
132
|
+
.then((session) => this.#finish(platform, session), (error) => this.#settle(platform, error));
|
|
133
|
+
}
|
|
134
|
+
async #finish(platform, session) {
|
|
135
|
+
this.#rows.set(platform, { kind: 'idle' });
|
|
136
|
+
const name = PLATFORM_NAMES[platform];
|
|
137
|
+
if (session.status === 'connected') {
|
|
138
|
+
await this.#load();
|
|
139
|
+
this.#say(format(this.#labels.connected, { platform: name }));
|
|
140
|
+
this.#emit('flung-connect', { session, accounts: this.#accounts.filter((account) => session.accountIds.includes(account.id)) });
|
|
141
|
+
}
|
|
142
|
+
else if (session.status === 'expired') {
|
|
143
|
+
this.#say(format(this.#labels.expired, { platform: name }), true);
|
|
144
|
+
this.#emit('flung-error', { code: 'expired', message: this.#message.text });
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const message = session.error?.message ?? '';
|
|
148
|
+
this.#say(format(this.#labels.failed, { platform: name, message }), true);
|
|
149
|
+
this.#emit('flung-error', { code: session.error?.code ?? 'platform_error', message });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** A connect that stopped early: cancelled by the person, or refused by the API. */
|
|
153
|
+
#settle(platform, error) {
|
|
154
|
+
this.#rows.set(platform, { kind: 'idle' });
|
|
155
|
+
if (error instanceof DOMException && error.name === 'AbortError')
|
|
156
|
+
return this.#render();
|
|
157
|
+
this.#fail(error, (message) => format(this.#labels.failed, { platform: PLATFORM_NAMES[platform], message }));
|
|
158
|
+
}
|
|
159
|
+
async #disconnect(account) {
|
|
160
|
+
this.#rows.set(account.platform, { kind: 'idle' });
|
|
161
|
+
try {
|
|
162
|
+
const disconnected = await this.#client.disconnect(account.id);
|
|
163
|
+
await this.#load();
|
|
164
|
+
this.#say(format(this.#labels.disconnected, { account: displayName(account) }));
|
|
165
|
+
this.#emit('flung-disconnect', { account: disconnected });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
this.#fail(error, (message) => message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
#fail(error, say) {
|
|
172
|
+
const detail = error instanceof FlungConnectError ? { code: error.code, message: error.message } : { code: 'unknown', message: String(error) };
|
|
173
|
+
this.#say(say(detail.message), true);
|
|
174
|
+
this.#emit('flung-error', detail);
|
|
175
|
+
}
|
|
176
|
+
#say(text, error = false) {
|
|
177
|
+
this.#message = { text, error };
|
|
178
|
+
this.#render();
|
|
179
|
+
}
|
|
180
|
+
#emit(type, detail) {
|
|
181
|
+
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
182
|
+
}
|
|
183
|
+
#render() {
|
|
184
|
+
const active = this.#root.activeElement instanceof HTMLElement ? (this.#root.activeElement.dataset['key'] ?? null) : null;
|
|
185
|
+
const list = html('ul', { class: 'list', part: 'list' }, ...this.#platforms.map((platform) => this.#platformRow(platform)));
|
|
186
|
+
const message = html('p', { class: this.#message.error ? 'message error' : 'message', part: 'message', role: 'status', 'aria-live': 'polite' }, this.#message.text);
|
|
187
|
+
const panel = html('section', { class: 'panel', part: 'panel', 'aria-label': this.#labels.panel }, list, message);
|
|
188
|
+
this.#root.replaceChildren(html('style', {}, STYLES), panel);
|
|
189
|
+
const key = this.#focus ?? active;
|
|
190
|
+
this.#focus = null;
|
|
191
|
+
if (key !== null)
|
|
192
|
+
this.#root.querySelector(`[data-key="${key}"]`)?.focus();
|
|
193
|
+
}
|
|
194
|
+
#platformRow(platform) {
|
|
195
|
+
const name = PLATFORM_NAMES[platform];
|
|
196
|
+
const state = this.#rows.get(platform) ?? { kind: 'idle' };
|
|
197
|
+
const busy = state.kind === 'waiting';
|
|
198
|
+
const accounts = this.#accounts.filter((account) => account.platform === platform);
|
|
199
|
+
const connect = button(accounts.length === 0 ? this.#labels.connect : this.#labels.connectAnother, `connect-${platform}`, () => this.#askOrStart(platform), { primary: accounts.length === 0, disabled: busy, label: `${accounts.length === 0 ? this.#labels.connect : this.#labels.connectAnother} ${name}` });
|
|
200
|
+
const row = html('div', { class: 'row' }, html('span', { class: 'badge', part: 'badge', 'aria-hidden': 'true' }, name.slice(0, 2)), html('span', { class: 'name', part: 'name' }, name), connect);
|
|
201
|
+
const items = accounts.map((account) => this.#accountRow(account, busy));
|
|
202
|
+
return html('li', { class: 'platform', part: 'platform', 'data-platform': platform }, row, items.length === 0 ? null : html('ul', { class: 'accounts' }, ...items), this.#rowExtra(platform, state));
|
|
203
|
+
}
|
|
204
|
+
#accountRow(account, busy) {
|
|
205
|
+
const due = account.reconnectBy === null ? null : new Date(account.reconnectBy);
|
|
206
|
+
const status = account.status === 'needs_reconnect' ? this.#labels.needsReconnect : due !== null && due.getTime() - Date.now() < SOON_MS ? format(this.#labels.reconnectBy, { date: due.toLocaleDateString() }) : null;
|
|
207
|
+
return html('li', { class: 'account', part: 'account', 'data-account': account.id }, html('span', { class: 'who' }, displayName(account)), status === null ? null : html('span', { class: 'status', part: 'status' }, status), status === null ? null : button(this.#labels.reconnect, `reconnect-${account.id}`, () => this.#start(account.platform, account, undefined), { disabled: busy }), button(this.#labels.disconnect, `disconnect-${account.id}`, () => this.#confirm(account), { danger: true, disabled: busy, label: `${this.#labels.disconnect} ${displayName(account)}` }));
|
|
208
|
+
}
|
|
209
|
+
#rowExtra(platform, state) {
|
|
210
|
+
const name = PLATFORM_NAMES[platform];
|
|
211
|
+
if (state.kind === 'handle') {
|
|
212
|
+
const id = `handle-${platform}`;
|
|
213
|
+
const required = HANDLE_PROMPT[platform]?.required === true;
|
|
214
|
+
const input = html('input', { id, name: 'handle', 'data-key': id, autocomplete: 'username', spellcheck: 'false', required });
|
|
215
|
+
const form = html('form', { class: 'form', part: 'form' }, html('label', { for: id }, platform === 'mastodon' ? this.#labels.handleMastodon : this.#labels.handleBluesky), input, html('button', { type: 'submit', class: 'primary', 'data-key': `continue-${platform}` }, this.#labels.continue), button(this.#labels.cancel, `cancel-${platform}`, () => this.#idle(platform)));
|
|
216
|
+
form.addEventListener('submit', (event) => {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
const handle = input.value.trim();
|
|
219
|
+
this.#start(platform, null, handle === '' ? undefined : handle);
|
|
220
|
+
});
|
|
221
|
+
return form;
|
|
222
|
+
}
|
|
223
|
+
if (state.kind === 'waiting') {
|
|
224
|
+
const link = state.blockedUrl === null ? null : html('a', { href: state.blockedUrl, target: '_blank', rel: 'noopener' }, format(this.#labels.openSignIn, { platform: name }));
|
|
225
|
+
const text = state.blockedUrl === null ? format(this.#labels.waiting, { platform: name }) : `${format(this.#labels.blocked, { platform: name })} `;
|
|
226
|
+
return html('p', { class: 'note', part: 'note' }, text, link, ' ', button(this.#labels.cancel, `cancel-${platform}`, () => (state.abort.abort(), this.#idle(platform))));
|
|
227
|
+
}
|
|
228
|
+
if (state.kind === 'confirm') {
|
|
229
|
+
return html('div', { class: 'confirm', part: 'confirm' }, html('p', {}, format(this.#labels.confirmDisconnect, { account: displayName(state.account) })), button(this.#labels.disconnect, `confirm-${platform}`, () => void this.#disconnect(state.account), { danger: true }), button(this.#labels.keep, `keep-${platform}`, () => this.#idle(platform)));
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
#askOrStart(platform) {
|
|
234
|
+
if (HANDLE_PROMPT[platform] === undefined)
|
|
235
|
+
return this.#start(platform, null, undefined);
|
|
236
|
+
this.#rows.set(platform, { kind: 'handle' });
|
|
237
|
+
this.#focus = `handle-${platform}`;
|
|
238
|
+
this.#render();
|
|
239
|
+
}
|
|
240
|
+
#confirm(account) {
|
|
241
|
+
this.#rows.set(account.platform, { kind: 'confirm', account });
|
|
242
|
+
this.#focus = `keep-${account.platform}`;
|
|
243
|
+
this.#render();
|
|
244
|
+
}
|
|
245
|
+
#idle(platform) {
|
|
246
|
+
this.#rows.set(platform, { kind: 'idle' });
|
|
247
|
+
this.#focus = `connect-${platform}`;
|
|
248
|
+
this.#render();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel's look, inside its shadow root. Every colour, the font and the corner radius are `--flung-*` properties a
|
|
3
|
+
* page can set on the element; each part has a `part` name for `::part()`. Follows the page's light or dark scheme
|
|
4
|
+
* unless `theme` says which.
|
|
5
|
+
*/
|
|
6
|
+
export declare const STYLES = "\n:host {\n --flung-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --flung-text: #18181b;\n --flung-muted: #52525b;\n --flung-surface: #ffffff;\n --flung-raised: #f4f4f5;\n --flung-border: #d4d4d8;\n --flung-accent: #18181b;\n --flung-accent-text: #ffffff;\n --flung-danger: #b91c1c;\n --flung-warning: #a16207;\n --flung-radius: 8px;\n display: block;\n font: 15px/1.45 var(--flung-font);\n color: var(--flung-text);\n}\n:host([hidden]) { display: none; }\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"])) {\n --flung-text: #f4f4f5;\n --flung-muted: #a1a1aa;\n --flung-surface: #18181b;\n --flung-raised: #27272a;\n --flung-border: #3f3f46;\n --flung-accent: #f4f4f5;\n --flung-accent-text: #18181b;\n --flung-danger: #f87171;\n --flung-warning: #facc15;\n }\n}\n:host([theme=\"dark\"]) {\n --flung-text: #f4f4f5;\n --flung-muted: #a1a1aa;\n --flung-surface: #18181b;\n --flung-raised: #27272a;\n --flung-border: #3f3f46;\n --flung-accent: #f4f4f5;\n --flung-accent-text: #18181b;\n --flung-danger: #f87171;\n --flung-warning: #facc15;\n}\n* { box-sizing: border-box; }\n.panel { background: var(--flung-surface); border: 1px solid var(--flung-border); border-radius: var(--flung-radius); overflow: hidden; }\nul { list-style: none; margin: 0; padding: 0; }\n.platform { padding: 12px 14px; border-top: 1px solid var(--flung-border); }\n.platform:first-child { border-top: 0; }\n.row { display: flex; align-items: center; gap: 10px; min-height: 36px; }\n.badge { flex: none; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--flung-raised); font-weight: 600; font-size: 13px; }\n.name { flex: 1; min-width: 0; font-weight: 600; }\n.accounts { margin: 6px 0 0 42px; }\n.account { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 4px 0; }\n.who { flex: 1; min-width: 8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.status { font-size: 13px; color: var(--flung-warning); }\n.note { margin: 8px 0 0 42px; color: var(--flung-muted); font-size: 14px; }\n.note a { color: inherit; }\n.confirm, .form { margin: 8px 0 0 42px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.confirm p { flex-basis: 100%; margin: 0; }\n.form label { flex-basis: 100%; font-size: 14px; color: var(--flung-muted); }\n.form input { flex: 1; min-width: 10rem; font: inherit; color: inherit; background: var(--flung-surface); border: 1px solid var(--flung-border); border-radius: calc(var(--flung-radius) - 2px); padding: 6px 10px; }\nbutton { font: inherit; font-size: 14px; cursor: pointer; border-radius: calc(var(--flung-radius) - 2px); padding: 6px 12px; border: 1px solid var(--flung-border); background: transparent; color: inherit; }\nbutton.primary { background: var(--flung-accent); border-color: var(--flung-accent); color: var(--flung-accent-text); }\nbutton.danger { color: var(--flung-danger); border-color: currentColor; }\nbutton:disabled { opacity: .55; cursor: default; }\nbutton:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--flung-accent); outline-offset: 2px; }\n.message { margin: 0; padding: 10px 14px; border-top: 1px solid var(--flung-border); color: var(--flung-muted); font-size: 14px; }\n.message:empty { display: none; }\n.message.error { color: var(--flung-danger); }\n@media (prefers-reduced-motion: no-preference) { button { transition: opacity .15s; } }\n";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel's look, inside its shadow root. Every colour, the font and the corner radius are `--flung-*` properties a
|
|
3
|
+
* page can set on the element; each part has a `part` name for `::part()`. Follows the page's light or dark scheme
|
|
4
|
+
* unless `theme` says which.
|
|
5
|
+
*/
|
|
6
|
+
export const STYLES = `
|
|
7
|
+
:host {
|
|
8
|
+
--flung-font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
9
|
+
--flung-text: #18181b;
|
|
10
|
+
--flung-muted: #52525b;
|
|
11
|
+
--flung-surface: #ffffff;
|
|
12
|
+
--flung-raised: #f4f4f5;
|
|
13
|
+
--flung-border: #d4d4d8;
|
|
14
|
+
--flung-accent: #18181b;
|
|
15
|
+
--flung-accent-text: #ffffff;
|
|
16
|
+
--flung-danger: #b91c1c;
|
|
17
|
+
--flung-warning: #a16207;
|
|
18
|
+
--flung-radius: 8px;
|
|
19
|
+
display: block;
|
|
20
|
+
font: 15px/1.45 var(--flung-font);
|
|
21
|
+
color: var(--flung-text);
|
|
22
|
+
}
|
|
23
|
+
:host([hidden]) { display: none; }
|
|
24
|
+
@media (prefers-color-scheme: dark) {
|
|
25
|
+
:host(:not([theme="light"])) {
|
|
26
|
+
--flung-text: #f4f4f5;
|
|
27
|
+
--flung-muted: #a1a1aa;
|
|
28
|
+
--flung-surface: #18181b;
|
|
29
|
+
--flung-raised: #27272a;
|
|
30
|
+
--flung-border: #3f3f46;
|
|
31
|
+
--flung-accent: #f4f4f5;
|
|
32
|
+
--flung-accent-text: #18181b;
|
|
33
|
+
--flung-danger: #f87171;
|
|
34
|
+
--flung-warning: #facc15;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
:host([theme="dark"]) {
|
|
38
|
+
--flung-text: #f4f4f5;
|
|
39
|
+
--flung-muted: #a1a1aa;
|
|
40
|
+
--flung-surface: #18181b;
|
|
41
|
+
--flung-raised: #27272a;
|
|
42
|
+
--flung-border: #3f3f46;
|
|
43
|
+
--flung-accent: #f4f4f5;
|
|
44
|
+
--flung-accent-text: #18181b;
|
|
45
|
+
--flung-danger: #f87171;
|
|
46
|
+
--flung-warning: #facc15;
|
|
47
|
+
}
|
|
48
|
+
* { box-sizing: border-box; }
|
|
49
|
+
.panel { background: var(--flung-surface); border: 1px solid var(--flung-border); border-radius: var(--flung-radius); overflow: hidden; }
|
|
50
|
+
ul { list-style: none; margin: 0; padding: 0; }
|
|
51
|
+
.platform { padding: 12px 14px; border-top: 1px solid var(--flung-border); }
|
|
52
|
+
.platform:first-child { border-top: 0; }
|
|
53
|
+
.row { display: flex; align-items: center; gap: 10px; min-height: 36px; }
|
|
54
|
+
.badge { flex: none; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--flung-raised); font-weight: 600; font-size: 13px; }
|
|
55
|
+
.name { flex: 1; min-width: 0; font-weight: 600; }
|
|
56
|
+
.accounts { margin: 6px 0 0 42px; }
|
|
57
|
+
.account { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 4px 0; }
|
|
58
|
+
.who { flex: 1; min-width: 8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
59
|
+
.status { font-size: 13px; color: var(--flung-warning); }
|
|
60
|
+
.note { margin: 8px 0 0 42px; color: var(--flung-muted); font-size: 14px; }
|
|
61
|
+
.note a { color: inherit; }
|
|
62
|
+
.confirm, .form { margin: 8px 0 0 42px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
63
|
+
.confirm p { flex-basis: 100%; margin: 0; }
|
|
64
|
+
.form label { flex-basis: 100%; font-size: 14px; color: var(--flung-muted); }
|
|
65
|
+
.form input { flex: 1; min-width: 10rem; font: inherit; color: inherit; background: var(--flung-surface); border: 1px solid var(--flung-border); border-radius: calc(var(--flung-radius) - 2px); padding: 6px 10px; }
|
|
66
|
+
button { font: inherit; font-size: 14px; cursor: pointer; border-radius: calc(var(--flung-radius) - 2px); padding: 6px 12px; border: 1px solid var(--flung-border); background: transparent; color: inherit; }
|
|
67
|
+
button.primary { background: var(--flung-accent); border-color: var(--flung-accent); color: var(--flung-accent-text); }
|
|
68
|
+
button.danger { color: var(--flung-danger); border-color: currentColor; }
|
|
69
|
+
button:disabled { opacity: .55; cursor: default; }
|
|
70
|
+
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--flung-accent); outline-offset: 2px; }
|
|
71
|
+
.message { margin: 0; padding: 10px 14px; border-top: 1px solid var(--flung-border); color: var(--flung-muted); font-size: 14px; }
|
|
72
|
+
.message:empty { display: none; }
|
|
73
|
+
.message.error { color: var(--flung-danger); }
|
|
74
|
+
@media (prefers-reduced-motion: no-preference) { button { transition: opacity .15s; } }
|
|
75
|
+
`;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@flung.dev/connect`: `<flung-connect>`, a panel that lets people connect and disconnect their social accounts
|
|
3
|
+
* from your page, with a connect token your server makes. `import '@flung.dev/connect/register'` to register it,
|
|
4
|
+
* or call `defineFlungConnect()`.
|
|
5
|
+
*/
|
|
6
|
+
export { defineFlungConnect, TAG_NAME } from './element/define.js';
|
|
7
|
+
export type { FlungConnectElement } from './element/flung-connect.js';
|
|
8
|
+
export { DEFAULT_LABELS } from './element/copy.js';
|
|
9
|
+
export type { ConnectLabels } from './element/copy.js';
|
|
10
|
+
export type { ConnectDetail, DisconnectDetail, ErrorDetail, FlungConnectEventMap, LoadDetail } from './element/events.js';
|
|
11
|
+
export * from './core/index.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@flung.dev/connect`: `<flung-connect>`, a panel that lets people connect and disconnect their social accounts
|
|
3
|
+
* from your page, with a connect token your server makes. `import '@flung.dev/connect/register'` to register it,
|
|
4
|
+
* or call `defineFlungConnect()`.
|
|
5
|
+
*/
|
|
6
|
+
export { defineFlungConnect, TAG_NAME } from './element/define.js';
|
|
7
|
+
export { DEFAULT_LABELS } from './element/copy.js';
|
|
8
|
+
export * from './core/index.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import type { TokenSource } from '../core/client.js';
|
|
3
|
+
import type { Platform } from '../core/types.js';
|
|
4
|
+
import type { ConnectLabels } from '../element/copy.js';
|
|
5
|
+
import type { ConnectDetail, DisconnectDetail, ErrorDetail, LoadDetail } from '../element/events.js';
|
|
6
|
+
import type { FlungConnectElement } from '../element/flung-connect.js';
|
|
7
|
+
/**
|
|
8
|
+
* `<FlungConnect>`: the `<flung-connect>` element for React. It registers the element on first render, passes plain
|
|
9
|
+
* values as attributes, sets `token` and `labels` as properties, and turns the element's events into callbacks. React
|
|
10
|
+
* is a peer dependency; nothing else is imported. The ref is the element itself, with `refresh()`.
|
|
11
|
+
*/
|
|
12
|
+
export interface FlungConnectProps {
|
|
13
|
+
/** A connect token from your server, or a function that fetches a fresh one. */
|
|
14
|
+
token: TokenSource | null;
|
|
15
|
+
/** Where the API lives; production when left out. */
|
|
16
|
+
baseUrl?: string | undefined;
|
|
17
|
+
/** Offer only these platforms. */
|
|
18
|
+
platforms?: readonly Platform[] | undefined;
|
|
19
|
+
/** `redirect` sends the whole page to the platform and back, for pages that cannot open windows. */
|
|
20
|
+
mode?: 'popup' | 'redirect' | undefined;
|
|
21
|
+
theme?: 'light' | 'dark' | undefined;
|
|
22
|
+
labels?: Partial<ConnectLabels> | undefined;
|
|
23
|
+
className?: string | undefined;
|
|
24
|
+
style?: CSSProperties | undefined;
|
|
25
|
+
onLoad?: ((detail: LoadDetail) => void) | undefined;
|
|
26
|
+
onConnect?: ((detail: ConnectDetail) => void) | undefined;
|
|
27
|
+
onDisconnect?: ((detail: DisconnectDetail) => void) | undefined;
|
|
28
|
+
onError?: ((detail: ErrorDetail) => void) | undefined;
|
|
29
|
+
}
|
|
30
|
+
export declare const FlungConnect: import("react").ForwardRefExoticComponent<FlungConnectProps & import("react").RefAttributes<FlungConnectElement>>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createElement, forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import { defineFlungConnect, TAG_NAME } from '../element/define.js';
|
|
3
|
+
const EVENTS = { 'flung-load': 'onLoad', 'flung-connect': 'onConnect', 'flung-disconnect': 'onDisconnect', 'flung-error': 'onError' };
|
|
4
|
+
const useClientLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
5
|
+
function FlungConnectInner(props, ref) {
|
|
6
|
+
const element = useRef(null);
|
|
7
|
+
const latest = useRef(props);
|
|
8
|
+
latest.current = props;
|
|
9
|
+
useImperativeHandle(ref, () => element.current, []);
|
|
10
|
+
useClientLayoutEffect(() => {
|
|
11
|
+
defineFlungConnect();
|
|
12
|
+
}, []);
|
|
13
|
+
// Listens before the token is handed over below, so the first `flung-load` is not missed.
|
|
14
|
+
useClientLayoutEffect(() => {
|
|
15
|
+
const node = element.current;
|
|
16
|
+
if (node === null)
|
|
17
|
+
return undefined;
|
|
18
|
+
const listeners = Object.entries(EVENTS).map(([type, prop]) => {
|
|
19
|
+
const listener = (event) => {
|
|
20
|
+
const handler = latest.current[prop];
|
|
21
|
+
handler?.(event.detail);
|
|
22
|
+
};
|
|
23
|
+
node.addEventListener(type, listener);
|
|
24
|
+
return () => node.removeEventListener(type, listener);
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
for (const remove of listeners)
|
|
28
|
+
remove();
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
useClientLayoutEffect(() => {
|
|
32
|
+
const node = element.current;
|
|
33
|
+
if (node === null)
|
|
34
|
+
return;
|
|
35
|
+
node.labels = props.labels;
|
|
36
|
+
if (node.token !== props.token)
|
|
37
|
+
node.token = props.token;
|
|
38
|
+
}, [props.token, props.labels]);
|
|
39
|
+
return createElement(TAG_NAME, {
|
|
40
|
+
ref: element,
|
|
41
|
+
'base-url': props.baseUrl,
|
|
42
|
+
platforms: props.platforms?.join(','),
|
|
43
|
+
mode: props.mode,
|
|
44
|
+
theme: props.theme,
|
|
45
|
+
class: props.className,
|
|
46
|
+
style: props.style,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export const FlungConnect = forwardRef(FlungConnectInner);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/register.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side-effect entry: `import '@flung.dev/connect/register'` registers `<flung-connect>`. It is the only module
|
|
3
|
+
* of the package with a side effect, and `sideEffects` in package.json names it, so a bundler keeps it and tree-shakes
|
|
4
|
+
* the rest.
|
|
5
|
+
*/
|
|
6
|
+
import { defineFlungConnect } from './element/define.js';
|
|
7
|
+
defineFlungConnect();
|
package/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flung.dev/connect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Let people connect and disconnect their social accounts from your page: a <flung-connect> web component and a React component for Flung, with no runtime dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://flung.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/flung-official/flung-sdk.git",
|
|
11
|
+
"directory": "packages/connect"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"flung",
|
|
15
|
+
"social-media",
|
|
16
|
+
"oauth",
|
|
17
|
+
"connect",
|
|
18
|
+
"web-component",
|
|
19
|
+
"react"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./register": {
|
|
27
|
+
"types": "./dist/register.d.ts",
|
|
28
|
+
"default": "./dist/register.js"
|
|
29
|
+
},
|
|
30
|
+
"./react": {
|
|
31
|
+
"types": "./dist/react/index.d.ts",
|
|
32
|
+
"default": "./dist/react/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./core": {
|
|
35
|
+
"types": "./dist/core/index.d.ts",
|
|
36
|
+
"default": "./dist/core/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"!dist/**/__tests__",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"sideEffects": [
|
|
47
|
+
"./dist/register.js"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"react": ">=18"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"react": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/react": "19.3.0",
|
|
62
|
+
"@types/react-dom": "19.2.3",
|
|
63
|
+
"jsdom": "30.1.1",
|
|
64
|
+
"react": "19.3.0",
|
|
65
|
+
"react-dom": "19.3.0"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public",
|
|
69
|
+
"provenance": true
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
73
|
+
"typecheck": "tsc --noEmit",
|
|
74
|
+
"test": "vitest run"
|
|
75
|
+
}
|
|
6
76
|
}
|