@flung.dev/connect 0.1.2 → 0.4.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 +224 -12
- package/dist/core/client.d.ts +11 -3
- package/dist/core/client.js +34 -12
- package/dist/core/controller.d.ts +232 -0
- package/dist/core/controller.js +447 -0
- package/dist/core/events.d.ts +25 -0
- package/dist/core/events.js +1 -0
- package/dist/core/index.d.ts +12 -4
- package/dist/core/index.js +7 -3
- package/dist/core/labels.d.ts +96 -0
- package/dist/core/labels.js +50 -0
- package/dist/core/platforms.d.ts +7 -1
- package/dist/core/platforms.js +11 -1
- package/dist/core/qr.d.ts +13 -0
- package/dist/core/qr.js +319 -0
- package/dist/core/types.d.ts +28 -0
- package/dist/core/types.js +1 -0
- package/dist/element/copy.d.ts +3 -39
- package/dist/element/copy.js +2 -32
- package/dist/element/dom.d.ts +29 -8
- package/dist/element/dom.js +77 -8
- package/dist/element/events.d.ts +14 -24
- package/dist/element/flung-connect.d.ts +21 -1
- package/dist/element/flung-connect.js +248 -198
- package/dist/element/marks.d.ts +7 -2
- package/dist/element/marks.js +8 -1
- package/dist/element/styles.d.ts +5 -3
- package/dist/element/styles.js +168 -42
- 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 +43 -3
- package/dist/react/index.js +39 -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/dist/element/events.d.ts
CHANGED
|
@@ -1,31 +1,21 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export interface ConnectDetail {
|
|
9
|
-
/** The connect session, or null when the person came back from a redirect. */
|
|
10
|
-
session: ConnectSession | null;
|
|
11
|
-
/** The accounts stored. */
|
|
12
|
-
accounts: Account[];
|
|
13
|
-
}
|
|
14
|
-
/** `flung-disconnect`: an account was disconnected. */
|
|
15
|
-
export interface DisconnectDetail {
|
|
16
|
-
/** The account, now `disconnected`. */
|
|
17
|
-
account: Account;
|
|
18
|
-
}
|
|
19
|
-
/** `flung-error`: a call or a connect failed. `code` is the API's stable code, such as `forbidden` or `access_denied`. */
|
|
20
|
-
export interface ErrorDetail {
|
|
21
|
-
/** The API's stable code, or the platform's, such as `access_denied`. */
|
|
22
|
-
code: string;
|
|
23
|
-
/** What went wrong, for a person. */
|
|
24
|
-
message: string;
|
|
1
|
+
import type { ConnectState } from '../core/controller.js';
|
|
2
|
+
import type { ConnectDetail, DisconnectDetail, ErrorDetail, LoadDetail } from '../core/events.js';
|
|
3
|
+
export type { ConnectDetail, DisconnectDetail, ErrorDetail, LoadDetail } from '../core/events.js';
|
|
4
|
+
/** `flung-change`: the panel's state changed. Does not bubble; for a page that draws parts of the panel itself. */
|
|
5
|
+
export interface ChangeDetail {
|
|
6
|
+
/** The new snapshot. */
|
|
7
|
+
state: ConnectState;
|
|
25
8
|
}
|
|
9
|
+
/** The element's events, for `addEventListener` types. */
|
|
26
10
|
export interface FlungConnectEventMap {
|
|
11
|
+
/** The accounts were read. */
|
|
27
12
|
'flung-load': CustomEvent<LoadDetail>;
|
|
13
|
+
/** A connect or reconnect finished. */
|
|
28
14
|
'flung-connect': CustomEvent<ConnectDetail>;
|
|
15
|
+
/** An account was disconnected. */
|
|
29
16
|
'flung-disconnect': CustomEvent<DisconnectDetail>;
|
|
17
|
+
/** A call or a connect failed. */
|
|
30
18
|
'flung-error': CustomEvent<ErrorDetail>;
|
|
19
|
+
/** The state changed. */
|
|
20
|
+
'flung-change': CustomEvent<ChangeDetail>;
|
|
31
21
|
}
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import type { TokenSource } from '../core/client.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { ConnectController, ConnectOptions, ConnectState } from '../core/controller.js';
|
|
3
|
+
import type { ConnectLabels } from '../core/labels.js';
|
|
4
|
+
import type { Platform } from '../core/types.js';
|
|
5
|
+
/** How the platforms are laid out: `list` (the default) one under another, `grid` as cards side by side, `compact` tighter. */
|
|
6
|
+
export type FlungConnectLayout = 'list' | 'grid' | 'compact';
|
|
7
|
+
/**
|
|
8
|
+
* How a platform with a connect variant (LinkedIn: a personal profile or company pages) is offered: `choose` (the
|
|
9
|
+
* default) asks which after Connect, inside the panel; `tiles` shows each as its own platform, such as LinkedIn and
|
|
10
|
+
* LinkedIn Page.
|
|
11
|
+
*/
|
|
12
|
+
export type FlungConnectVariants = 'choose' | 'tiles';
|
|
3
13
|
/** `<flung-connect>` as a page sees it. */
|
|
4
14
|
export interface FlungConnectElement extends HTMLElement {
|
|
5
15
|
/** A connect token, or a function that fetches a fresh one from your server. The `token` attribute sets a string. */
|
|
6
16
|
token: TokenSource | null;
|
|
17
|
+
/** Every word the panel shows. Setting it fills in the defaults for any left out. */
|
|
7
18
|
get labels(): ConnectLabels;
|
|
8
19
|
set labels(value: Partial<ConnectLabels> | undefined);
|
|
20
|
+
/** The panel's current state, the same snapshot `flung-change` carries. */
|
|
21
|
+
readonly state: ConnectState;
|
|
22
|
+
/** The headless controller behind the panel, for a page that draws parts of it itself; null until the element is on a page. A new token makes a new one. */
|
|
23
|
+
readonly controller: ConnectController | null;
|
|
24
|
+
/**
|
|
25
|
+
* Starts a connect, as the platform's Connect button does. Call it straight from a click: a browser opens a window only
|
|
26
|
+
* during one. `{ variant: 'pages' }` connects LinkedIn company pages without asking, `{ variant: null }` the profile.
|
|
27
|
+
*/
|
|
28
|
+
connect(platform: Platform, options?: ConnectOptions): void;
|
|
9
29
|
/** Reads the accounts again, as after a connect. */
|
|
10
30
|
refresh(): Promise<void>;
|
|
11
31
|
}
|
|
@@ -1,25 +1,47 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { createConnectController, initialConnectState } from '../core/controller.js';
|
|
2
|
+
import { DEFAULT_LABELS, formatLabel as format } from '../core/labels.js';
|
|
3
|
+
import { parsePlatformList } from '../core/platforms.js';
|
|
4
|
+
import { avatar, button, html, mark, qrCode } from './dom.js';
|
|
5
|
+
import { CHOICE_MARKS, FLUNG_MARK, PLATFORM_MARKS } from './marks.js';
|
|
6
|
+
import { BASE_STYLES, STYLES } from './styles.js';
|
|
7
|
+
import { panelTooltip, TOOLTIP_BASE_STYLES, TOOLTIP_STYLES } from './tooltip.js';
|
|
8
|
+
const LAYOUTS = ['list', 'grid', 'compact'];
|
|
9
|
+
/**
|
|
10
|
+
* Which of a platform's tiles its step shows under: a reconnect or a disconnect under the account's tile, a connect under
|
|
11
|
+
* the tile of the variant it is for, anything else under the first. -1 when none matches.
|
|
12
|
+
*/
|
|
13
|
+
function stepTile(item, tiles) {
|
|
14
|
+
const { step } = item;
|
|
15
|
+
const accountId = step.kind === 'confirm' ? step.account.account.id : step.kind === 'waiting' || step.kind === 'approve' ? step.accountId : null;
|
|
16
|
+
if (accountId !== null)
|
|
17
|
+
return tiles.findIndex((tile) => tile.accounts.some((account) => account.account.id === accountId));
|
|
18
|
+
if (step.kind === 'waiting' || step.kind === 'approve')
|
|
19
|
+
return tiles.findIndex((tile) => tile.variant === step.variant);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A step's kind for moving focus, with an approval split by where it is: making its link, showing it, or ended. Each
|
|
24
|
+
* change is a new place for focus.
|
|
25
|
+
*/
|
|
26
|
+
function focusKind(step) {
|
|
27
|
+
if (step.kind !== 'approve')
|
|
28
|
+
return step.kind;
|
|
29
|
+
return step.ended !== null ? 'approve-ended' : step.approvalUrl !== null ? 'approve-link' : 'approve';
|
|
30
|
+
}
|
|
31
|
+
/** The key of a tile's Connect button, so focus can come back to it. */
|
|
32
|
+
const connectKey = (platform, variant) => (variant === null ? `connect-${platform}` : `connect-${platform}-${variant}`);
|
|
10
33
|
/** Builds the class on first use, so importing the package on a server, where there is no `HTMLElement`, is safe. */
|
|
11
34
|
export function createFlungConnectClass() {
|
|
12
35
|
return class FlungConnect extends HTMLElement {
|
|
13
|
-
static observedAttributes = ['token', 'base-url', 'platforms', 'branding'];
|
|
36
|
+
static observedAttributes = ['token', 'base-url', 'platforms', 'branding', 'mode', 'layout', 'unstyled', 'variants'];
|
|
14
37
|
#root = this.attachShadow({ mode: 'open' });
|
|
38
|
+
#tooltip = panelTooltip(this.#root);
|
|
15
39
|
#source = null;
|
|
16
|
-
#client = null;
|
|
17
40
|
#labels = DEFAULT_LABELS;
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
#
|
|
22
|
-
#focus = null;
|
|
41
|
+
#controller = null;
|
|
42
|
+
#unsubscribe = null;
|
|
43
|
+
/** Each platform's step when the panel was last drawn, and the variant it was for, to move focus when one changes. */
|
|
44
|
+
#steps = new Map();
|
|
23
45
|
#started = false;
|
|
24
46
|
get token() {
|
|
25
47
|
return this.#source;
|
|
@@ -33,10 +55,22 @@ export function createFlungConnectClass() {
|
|
|
33
55
|
}
|
|
34
56
|
set labels(value) {
|
|
35
57
|
this.#labels = { ...DEFAULT_LABELS, ...value };
|
|
36
|
-
this.#
|
|
58
|
+
if (this.#controller === null)
|
|
59
|
+
this.#render();
|
|
60
|
+
else
|
|
61
|
+
this.#controller.configure({ labels: this.#labels });
|
|
62
|
+
}
|
|
63
|
+
get state() {
|
|
64
|
+
return this.#controller?.getState() ?? initialConnectState(this.#source !== null);
|
|
65
|
+
}
|
|
66
|
+
get controller() {
|
|
67
|
+
return this.#controller;
|
|
68
|
+
}
|
|
69
|
+
connect(platform, options) {
|
|
70
|
+
this.#controller?.connect(platform, options);
|
|
37
71
|
}
|
|
38
72
|
refresh() {
|
|
39
|
-
return this.#
|
|
73
|
+
return this.#controller?.refresh() ?? Promise.resolve();
|
|
40
74
|
}
|
|
41
75
|
connectedCallback() {
|
|
42
76
|
if (this.#started)
|
|
@@ -49,218 +83,234 @@ export function createFlungConnectClass() {
|
|
|
49
83
|
attributeChangedCallback(name, previous, next) {
|
|
50
84
|
if (previous === next || !this.#started)
|
|
51
85
|
return;
|
|
52
|
-
if (name === 'token')
|
|
86
|
+
if (name === 'token') {
|
|
53
87
|
this.#source = next;
|
|
54
|
-
|
|
55
|
-
|
|
88
|
+
this.#reset();
|
|
89
|
+
}
|
|
90
|
+
else if (name === 'base-url')
|
|
91
|
+
this.#reset();
|
|
56
92
|
else if (name === 'platforms')
|
|
57
|
-
|
|
93
|
+
this.#controller?.configure({ platforms: parsePlatformList(next) });
|
|
94
|
+
else if (name === 'mode')
|
|
95
|
+
this.#controller?.configure({ mode: next === 'redirect' ? 'redirect' : 'popup' });
|
|
58
96
|
else
|
|
59
|
-
this.#
|
|
97
|
+
this.#render();
|
|
60
98
|
}
|
|
99
|
+
/** A new token or API: a new controller, which reads everything again and reports a redirect that came back. */
|
|
61
100
|
#reset() {
|
|
62
101
|
if (!this.#started)
|
|
63
102
|
return;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const granted = typeof this.#source === 'string' ? tokenPlatforms(this.#source) : null;
|
|
81
|
-
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));
|
|
82
|
-
this.#accounts = accounts.filter((account) => account.status !== 'disconnected' && this.#platforms.includes(account.platform));
|
|
83
|
-
if (this.#message.text === this.#labels.loading || this.#message.text === this.#labels.noToken)
|
|
84
|
-
this.#message = { text: this.#platforms.length === 0 ? this.#labels.empty : '', error: false };
|
|
85
|
-
this.#render();
|
|
86
|
-
this.#emit('flung-load', { accounts: this.#accounts });
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
this.#fail(error, (message) => format(this.#labels.loadFailed, { message }));
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
/** A connect that left the page (`mode="redirect"`) and came back: report it, and take Flung's result off the address. */
|
|
93
|
-
#readReturn() {
|
|
94
|
-
if (typeof location === 'undefined')
|
|
95
|
-
return;
|
|
96
|
-
const result = readRedirectResult(location.href);
|
|
97
|
-
if (result === null)
|
|
98
|
-
return;
|
|
99
|
-
history.replaceState(history.state, '', withoutRedirectResult(location.href));
|
|
100
|
-
if (result.status === 'connected') {
|
|
101
|
-
const accounts = this.#accounts.filter((account) => result.accountIds.includes(account.id));
|
|
102
|
-
const platform = accounts[0]?.platform;
|
|
103
|
-
this.#say(format(this.#labels.connected, { platform: platform === undefined ? 'The account' : PLATFORM_NAMES[platform] }));
|
|
104
|
-
this.#emit('flung-connect', { session: null, accounts });
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
this.#say(result.error?.message ?? '', true);
|
|
108
|
-
this.#emit('flung-error', { code: result.error?.code ?? 'platform_error', message: result.error?.message ?? '' });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
/** Called straight from a click or a submit: a window can only be opened during one. */
|
|
112
|
-
#start(platform, account, handle) {
|
|
113
|
-
const client = this.#client;
|
|
114
|
-
if (client === null)
|
|
115
|
-
return;
|
|
116
|
-
const redirect = this.getAttribute('mode') === 'redirect';
|
|
117
|
-
const redirectUrl = redirect ? withoutRedirectResult(location.href) : undefined;
|
|
118
|
-
const begin = () => (account === null ? client.connect(platform, { handle, redirectUrl }) : client.reconnect(account.id, { redirectUrl }));
|
|
119
|
-
const abort = new AbortController();
|
|
120
|
-
const row = { kind: 'waiting', abort, blockedUrl: null };
|
|
121
|
-
this.#rows.set(platform, row);
|
|
122
|
-
this.#say('');
|
|
123
|
-
if (redirect) {
|
|
124
|
-
void begin().then((session) => session.authorizationUrl !== null && location.assign(session.authorizationUrl), (error) => this.#settle(platform, error));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
void connectInPopup(client, begin, { signal: abort.signal })
|
|
128
|
-
.then(async (outcome) => {
|
|
129
|
-
if (outcome.kind === 'finished')
|
|
130
|
-
return outcome.session;
|
|
131
|
-
row.blockedUrl = outcome.session.authorizationUrl;
|
|
132
|
-
this.#render();
|
|
133
|
-
return client.waitForSession(outcome.session.id, { signal: abort.signal });
|
|
134
|
-
})
|
|
135
|
-
.then((session) => this.#finish(platform, session), (error) => this.#settle(platform, error));
|
|
136
|
-
}
|
|
137
|
-
async #finish(platform, session) {
|
|
138
|
-
this.#rows.set(platform, { kind: 'idle' });
|
|
139
|
-
const name = PLATFORM_NAMES[platform];
|
|
140
|
-
if (session.status === 'connected') {
|
|
141
|
-
await this.#load();
|
|
142
|
-
this.#say(format(this.#labels.connected, { platform: name }));
|
|
143
|
-
this.#emit('flung-connect', { session, accounts: this.#accounts.filter((account) => session.accountIds.includes(account.id)) });
|
|
144
|
-
}
|
|
145
|
-
else if (session.status === 'expired') {
|
|
146
|
-
this.#say(format(this.#labels.expired, { platform: name }), true);
|
|
147
|
-
this.#emit('flung-error', { code: 'expired', message: this.#message.text });
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
const message = session.error?.message ?? '';
|
|
151
|
-
this.#say(format(this.#labels.failed, { platform: name, message }), true);
|
|
152
|
-
this.#emit('flung-error', { code: session.error?.code ?? 'platform_error', message });
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/** A connect that stopped early: cancelled by the person, or refused by the API. */
|
|
156
|
-
#settle(platform, error) {
|
|
157
|
-
this.#rows.set(platform, { kind: 'idle' });
|
|
158
|
-
if (error instanceof DOMException && error.name === 'AbortError')
|
|
159
|
-
return this.#render();
|
|
160
|
-
this.#fail(error, (message) => format(this.#labels.failed, { platform: PLATFORM_NAMES[platform], message }));
|
|
161
|
-
}
|
|
162
|
-
async #disconnect(account) {
|
|
163
|
-
this.#rows.set(account.platform, { kind: 'idle' });
|
|
164
|
-
try {
|
|
165
|
-
const disconnected = await this.#client.disconnect(account.id);
|
|
166
|
-
await this.#load();
|
|
167
|
-
this.#say(format(this.#labels.disconnected, { account: displayName(account) }));
|
|
168
|
-
this.#emit('flung-disconnect', { account: disconnected });
|
|
169
|
-
}
|
|
170
|
-
catch (error) {
|
|
171
|
-
this.#fail(error, (message) => message);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
#fail(error, say) {
|
|
175
|
-
const detail = error instanceof FlungConnectError ? { code: error.code, message: error.message } : { code: 'unknown', message: String(error) };
|
|
176
|
-
this.#say(say(detail.message), true);
|
|
177
|
-
this.#emit('flung-error', detail);
|
|
178
|
-
}
|
|
179
|
-
#say(text, error = false) {
|
|
180
|
-
this.#message = { text, error };
|
|
103
|
+
this.#unsubscribe?.();
|
|
104
|
+
this.#controller?.destroy();
|
|
105
|
+
this.#steps.clear();
|
|
106
|
+
const controller = createConnectController({
|
|
107
|
+
token: this.#source,
|
|
108
|
+
baseUrl: this.getAttribute('base-url') ?? undefined,
|
|
109
|
+
platforms: parsePlatformList(this.getAttribute('platforms')),
|
|
110
|
+
mode: this.getAttribute('mode') === 'redirect' ? 'redirect' : 'popup',
|
|
111
|
+
labels: this.#labels,
|
|
112
|
+
onLoad: (detail) => this.#emit('flung-load', detail),
|
|
113
|
+
onConnect: (detail) => this.#emit('flung-connect', detail),
|
|
114
|
+
onDisconnect: (detail) => this.#emit('flung-disconnect', detail),
|
|
115
|
+
onError: (detail) => this.#emit('flung-error', detail),
|
|
116
|
+
});
|
|
117
|
+
this.#controller = controller;
|
|
118
|
+
this.#unsubscribe = controller.subscribe(() => this.#render());
|
|
181
119
|
this.#render();
|
|
182
120
|
}
|
|
183
121
|
#emit(type, detail) {
|
|
184
122
|
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
185
123
|
}
|
|
186
124
|
#render() {
|
|
125
|
+
const state = this.state;
|
|
126
|
+
const labels = state.labels;
|
|
127
|
+
const layout = LAYOUTS.find((value) => value === this.getAttribute('layout')) ?? 'list';
|
|
187
128
|
const active = this.#root.activeElement instanceof HTMLElement ? (this.#root.activeElement.dataset['key'] ?? null) : null;
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
129
|
+
const tiles = this.getAttribute('variants') === 'tiles';
|
|
130
|
+
const list = html('ul', { class: 'list', part: 'list' }, ...state.platforms.flatMap((item) => this.#platformRows(item, labels, tiles)));
|
|
131
|
+
const empty = state.message.text === '';
|
|
132
|
+
const message = html('p', { class: state.message.error ? 'message error' : 'message', part: state.message.error ? 'message message-error' : 'message', role: 'status', 'aria-live': 'polite', hidden: empty }, state.message.text);
|
|
133
|
+
const panel = html('section', { class: 'panel', part: 'panel', 'data-layout': layout, 'aria-label': labels.panel }, list, message, this.#footer(labels));
|
|
134
|
+
const styles = this.hasAttribute('unstyled') ? BASE_STYLES + TOOLTIP_BASE_STYLES : STYLES + TOOLTIP_BASE_STYLES + TOOLTIP_STYLES;
|
|
135
|
+
// The element a tooltip points at is about to be replaced, so the tooltip goes with it.
|
|
136
|
+
this.#tooltip.hide();
|
|
137
|
+
this.#root.replaceChildren(html('style', {}, styles), panel, this.#tooltip.element);
|
|
138
|
+
this.#placeFocus(state, active);
|
|
139
|
+
this.dispatchEvent(new CustomEvent('flung-change', { detail: { state } }));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Keeps focus where the person is: on the field when a question opens, on the first choice when the platform asks
|
|
143
|
+
* what to connect, on Keep when a disconnect asks to confirm, and back on Connect when a question or a wait closes.
|
|
144
|
+
* Otherwise on whatever had it before the panel was drawn again.
|
|
145
|
+
*/
|
|
146
|
+
#placeFocus(state, active) {
|
|
147
|
+
let wanted = active;
|
|
148
|
+
const fallback = [];
|
|
149
|
+
for (const item of state.platforms) {
|
|
150
|
+
const before = this.#steps.get(item.platform) ?? { kind: 'idle', variant: null };
|
|
151
|
+
const now = focusKind(item.step);
|
|
152
|
+
if (now === before.kind)
|
|
153
|
+
continue;
|
|
154
|
+
if (now === 'handle')
|
|
155
|
+
wanted = `handle-${item.platform}`;
|
|
156
|
+
else if (now === 'approve-link')
|
|
157
|
+
wanted = `approve-open-${item.platform}`;
|
|
158
|
+
else if (now === 'approve-ended')
|
|
159
|
+
wanted = `approve-retry-${item.platform}`;
|
|
160
|
+
else if (now === 'choose')
|
|
161
|
+
wanted = `choose-${item.platform}-personal`;
|
|
162
|
+
else if (now === 'confirm')
|
|
163
|
+
wanted = `keep-${item.platform}`;
|
|
164
|
+
// Back to the Connect that started it: a variant's own tile, else the platform's.
|
|
165
|
+
else if (now === 'idle')
|
|
166
|
+
fallback.push(connectKey(item.platform, before.variant), connectKey(item.platform, null));
|
|
167
|
+
}
|
|
168
|
+
this.#steps = new Map(state.platforms.map((item) => [item.platform, { kind: focusKind(item.step), variant: item.step.kind === 'waiting' || item.step.kind === 'approve' ? item.step.variant : null }]));
|
|
169
|
+
const find = (key) => (key === null ? undefined : [...this.#root.querySelectorAll('[data-key]')].find((node) => node.dataset['key'] === key));
|
|
170
|
+
(find(wanted) ?? (active !== null ? fallback.map(find).find((node) => node !== undefined) : undefined))?.focus();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* A platform's rows: one, or with `variants="tiles"` one per way it connects (LinkedIn, then LinkedIn Page), each with
|
|
174
|
+
* its own accounts. A platform that connects only through a variant is drawn as that variant's tile.
|
|
175
|
+
*/
|
|
176
|
+
#platformRows(item, labels, tiles) {
|
|
177
|
+
const slot = `platform-${item.platform}`;
|
|
178
|
+
if (item.variants.length === 0)
|
|
179
|
+
return [this.#platformRow(item, { variant: null, accounts: item.accounts }, labels, { chooser: false, extra: true, slot })];
|
|
180
|
+
if (!tiles) {
|
|
181
|
+
const variant = item.connectable ? null : (item.variants[0] ?? null);
|
|
182
|
+
return [this.#platformRow(item, { variant, accounts: item.accounts }, labels, { chooser: item.connectable, extra: true, slot })];
|
|
183
|
+
}
|
|
184
|
+
const own = item.connectable ? [{ variant: null, accounts: item.accounts.filter((account) => !account.page) }] : [];
|
|
185
|
+
const split = [...own, ...item.variants.map((variant) => ({ variant, accounts: item.accounts.filter((account) => account.page) }))];
|
|
186
|
+
const at = Math.max(0, stepTile(item, split));
|
|
187
|
+
return split.map((tile, index) => this.#platformRow(item, tile, labels, { chooser: false, extra: index === at, slot: index === 0 ? `platform-${item.platform}` : `platform-${item.platform}-${tile.variant ?? ''}` }));
|
|
196
188
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
189
|
+
/**
|
|
190
|
+
* One tile: mark, name, Connect, and its accounts. `chooser` makes Connect ask what to connect; `extra` puts the
|
|
191
|
+
* platform's question, wait or confirm under it; `slot` names the slot a page's own row goes in.
|
|
192
|
+
*/
|
|
193
|
+
#platformRow(item, tile, labels, options) {
|
|
194
|
+
const { chooser } = options;
|
|
195
|
+
const { platform, busy, step } = item;
|
|
196
|
+
const { variant } = tile;
|
|
197
|
+
const name = variant === null ? item.name : format(labels.pagesName, { platform: item.name });
|
|
198
|
+
const first = tile.accounts.length === 0;
|
|
199
|
+
const text = first ? labels.connect : labels.connectAnother;
|
|
200
|
+
const choosing = chooser && step.kind === 'choose';
|
|
201
|
+
// With a chooser, Connect opens the question and closes it again; a tile connects its own way straight away.
|
|
202
|
+
const onConnect = () => (choosing ? this.#controller?.cancel(platform) : this.#controller?.connect(platform, chooser ? {} : { variant }));
|
|
203
|
+
const connect = button(text, connectKey(platform, variant), 'connect', onConnect, {
|
|
204
|
+
variant: first ? 'primary' : 'secondary',
|
|
205
|
+
disabled: busy,
|
|
206
|
+
label: `${text} ${name}`,
|
|
207
|
+
...(chooser ? { expanded: choosing, controls: `choose-${platform}` } : {}),
|
|
208
|
+
});
|
|
209
|
+
const count = first ? labels.notConnected : format(labels.connectedCount, { count: String(tile.accounts.length) });
|
|
210
|
+
const title = html('span', { class: 'title', part: 'title' }, html('span', { class: 'name', part: 'name' }, name), html('span', { class: first ? 'count' : 'count on', part: first ? 'count' : 'count count-connected' }, count));
|
|
211
|
+
const badge = html('span', { class: 'badge', part: 'badge', 'aria-hidden': 'true' }, mark(PLATFORM_MARKS[platform].path));
|
|
212
|
+
const row = html('div', { class: 'row', part: 'row' }, html('slot', { name: options.slot }, badge, title, connect));
|
|
213
|
+
const items = tile.accounts.map((account) => this.#accountRow(account, busy, labels));
|
|
214
|
+
const parts = variant === null ? `platform platform-${platform}` : `platform platform-${platform} platform-${platform}-${variant}`;
|
|
215
|
+
return html('li', { class: 'platform', part: parts, 'data-platform': platform, 'data-variant': variant ?? undefined }, row, items.length === 0 ? null : html('ul', { class: 'accounts', part: 'accounts' }, ...items), options.extra ? this.#rowExtra(item, labels) : null);
|
|
209
216
|
}
|
|
210
|
-
#accountRow(
|
|
211
|
-
const
|
|
212
|
-
const status =
|
|
213
|
-
const reconnect = status === null ? null : button(
|
|
214
|
-
const disconnect = button(
|
|
215
|
-
|
|
217
|
+
#accountRow(item, busy, labels) {
|
|
218
|
+
const { account } = item;
|
|
219
|
+
const status = item.reconnect === null ? null : item.reconnect.reason === 'needs_reconnect' ? labels.needsReconnect : format(labels.reconnectBy, { date: new Date(item.reconnect.by).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) });
|
|
220
|
+
const reconnect = status === null ? null : button(labels.reconnect, `reconnect-${account.id}`, 'reconnect', () => this.#controller?.reconnect(account.id), { disabled: busy, label: `${labels.reconnect} ${item.name}` });
|
|
221
|
+
const disconnect = button(labels.disconnect, `disconnect-${account.id}`, 'disconnect', () => this.#controller?.requestDisconnect(account.id), { variant: 'quiet', disabled: busy, label: `${labels.disconnect} ${item.name}` });
|
|
222
|
+
const names = html('span', { class: 'names', part: 'account-names' }, html('span', { class: 'handle', part: 'account-name', 'data-tooltip': item.displayName, 'data-tooltip-truncated': true }, item.displayName), item.username === null ? null : html('span', { class: 'username', part: 'account-username', 'data-tooltip': `@${item.username}`, 'data-tooltip-truncated': true }, `@${item.username}`));
|
|
223
|
+
const tag = item.page ? html('span', { class: 'tag', part: 'tag tag-page' }, labels.pageTag) : null;
|
|
224
|
+
const who = html('span', { class: 'who', part: 'account-details' }, names, tag, status === null ? null : html('span', { class: 'status', part: 'status' }, status));
|
|
225
|
+
return html('li', { class: 'account', part: 'account', 'data-account': account.id }, html('slot', { name: `account-${account.id}` }, avatar(item), who, html('span', { class: 'actions', part: 'actions' }, reconnect, disconnect)));
|
|
216
226
|
}
|
|
217
227
|
/** Flung's mark and name, linking to its docs, unless the page set `branding="none"`. */
|
|
218
|
-
#footer() {
|
|
228
|
+
#footer(labels) {
|
|
219
229
|
if (this.getAttribute('branding') === 'none')
|
|
220
230
|
return null;
|
|
221
|
-
const link = html('a', { href: 'https://docs.flung.dev', target: '_blank', rel: 'noopener' }, mark(FLUNG_MARK.path, FLUNG_MARK.viewBox, 'flung'), 'Flung');
|
|
222
|
-
return html('p', { class: 'footer', part: 'footer' },
|
|
231
|
+
const link = html('a', { href: 'https://docs.flung.dev', target: '_blank', rel: 'noopener', part: 'footer-link' }, mark(FLUNG_MARK.path, { viewBox: FLUNG_MARK.viewBox, className: 'flung', part: 'footer-mark', size: 14 }), 'Flung');
|
|
232
|
+
return html('p', { class: 'footer', part: 'footer' }, labels.securedBy, link);
|
|
223
233
|
}
|
|
224
|
-
#rowExtra(
|
|
225
|
-
const name =
|
|
226
|
-
if (
|
|
234
|
+
#rowExtra(item, labels) {
|
|
235
|
+
const { platform, name, step } = item;
|
|
236
|
+
if (step.kind === 'handle') {
|
|
227
237
|
const id = `handle-${platform}`;
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
const form = html('form', { class: 'form', part: 'form' }, html('label', { for: id }, platform === 'mastodon' ?
|
|
238
|
+
const input = html('input', { id, name: 'handle', part: 'input', 'data-key': id, autocomplete: 'username', spellcheck: 'false', required: step.required });
|
|
239
|
+
const submit = html('button', { type: 'submit', class: 'primary', part: 'button button-primary button-continue', 'data-key': `continue-${platform}` }, labels.continue);
|
|
240
|
+
const form = html('form', { class: 'form', part: 'form' }, html('label', { for: id, part: 'label' }, platform === 'mastodon' ? labels.handleMastodon : labels.handleBluesky), input, submit, button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform)));
|
|
231
241
|
form.addEventListener('submit', (event) => {
|
|
232
242
|
event.preventDefault();
|
|
233
|
-
|
|
234
|
-
this.#start(platform, null, handle === '' ? undefined : handle);
|
|
243
|
+
this.#controller?.submitHandle(platform, input.value);
|
|
235
244
|
});
|
|
236
245
|
return form;
|
|
237
246
|
}
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
if (step.kind === 'choose')
|
|
248
|
+
return this.#chooser(item, step.variants, labels);
|
|
249
|
+
if (step.kind === 'waiting') {
|
|
250
|
+
const link = step.blockedUrl === null ? null : html('a', { href: step.blockedUrl, target: '_blank', rel: 'noopener', part: 'link' }, format(labels.openSignIn, { platform: name }));
|
|
251
|
+
const text = step.blockedUrl === null ? format(labels.waiting, { platform: name }) : `${format(labels.blocked, { platform: name })} `;
|
|
252
|
+
return html('p', { class: 'note', part: 'note' }, html('span', { class: 'spinner', part: 'spinner', 'aria-hidden': 'true' }), html('span', { class: 'note-text', part: 'note-text' }, text, link), button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform)));
|
|
242
253
|
}
|
|
243
|
-
if (
|
|
244
|
-
return
|
|
254
|
+
if (step.kind === 'approve')
|
|
255
|
+
return this.#approval(item, step, labels);
|
|
256
|
+
if (step.kind === 'confirm') {
|
|
257
|
+
return html('div', { class: 'confirm', part: 'confirm' }, html('p', { part: 'confirm-text' }, format(labels.confirmDisconnect, { account: step.account.name })), button(labels.disconnect, `confirm-${platform}`, 'confirm', () => void this.#controller?.confirmDisconnect(platform), { variant: 'danger' }), button(labels.keep, `keep-${platform}`, 'keep', () => this.#controller?.cancel(platform)));
|
|
245
258
|
}
|
|
246
259
|
return null;
|
|
247
260
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
261
|
+
/**
|
|
262
|
+
* An approval in the platform's phone app (Farcaster): the line saying so, Open Warpcast, a QR code of the same link
|
|
263
|
+
* for a computer, and the wait. Ended, why it ended, with Try again and Cancel.
|
|
264
|
+
*/
|
|
265
|
+
#approval(item, step, labels) {
|
|
266
|
+
const { platform, name } = item;
|
|
267
|
+
const cancel = button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform));
|
|
268
|
+
if (step.ended !== null) {
|
|
269
|
+
const why = step.ended.status === 'expired' ? format(labels.approvalExpired, { platform: name }) : step.ended.message === '' ? format(labels.approvalDeclined, { platform: name }) : format(labels.approvalFailed, { platform: name, message: step.ended.message });
|
|
270
|
+
const accountId = step.accountId;
|
|
271
|
+
const retry = button(labels.tryAgain, `approve-retry-${platform}`, 'retry', () => (accountId === null ? this.#controller?.connect(platform) : this.#controller?.reconnect(accountId)), { variant: 'primary' });
|
|
272
|
+
return html('div', { class: 'approve ended', part: 'approve approve-ended' }, html('p', { class: 'approve-text', part: 'approve-text', role: 'alert' }, why), html('div', { class: 'approve-actions', part: 'approve-actions' }, retry, cancel));
|
|
273
|
+
}
|
|
274
|
+
const url = step.approvalUrl;
|
|
275
|
+
const go = url === null
|
|
276
|
+
? null
|
|
277
|
+
: html('div', { class: 'approve-go', part: 'approve-go' }, html('a', { class: 'approve-open', href: url, target: '_blank', rel: 'noopener', part: 'link link-approval', 'data-key': `approve-open-${platform}` }, labels.openApproval), html('div', { class: 'approve-scan', part: 'approve-scan' }, qrCode(url, format(labels.approvalQr, { platform: name })), html('p', { class: 'approve-hint', part: 'approve-hint' }, labels.scanApproval)));
|
|
278
|
+
return html('div', { class: 'approve', part: 'approve' }, html('p', { class: 'approve-text', part: 'approve-text' }, format(labels.approve, { platform: name })), go, html('p', { class: 'note', part: 'note', role: 'status' }, html('span', { class: 'spinner', part: 'spinner', 'aria-hidden': 'true' }), html('span', { class: 'note-text', part: 'note-text' }, labels.awaitingApproval), cancel));
|
|
259
279
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
/**
|
|
281
|
+
* What to connect, when a platform offers its own connect and a variant: a group of two buttons, each with an icon,
|
|
282
|
+
* a name and a line under it (read as the button's description). Arrow keys move between them, Escape closes the
|
|
283
|
+
* question, and focus starts on the first.
|
|
284
|
+
*/
|
|
285
|
+
#chooser(item, variants, labels) {
|
|
286
|
+
const { platform } = item;
|
|
287
|
+
const titleId = `choose-title-${platform}`;
|
|
288
|
+
const option = (variant, key, icon, name, description) => {
|
|
289
|
+
const nameId = `${key}-name`;
|
|
290
|
+
const descriptionId = `${key}-description`;
|
|
291
|
+
const element = html('button', { type: 'button', class: 'option', part: `button button-option button-option-${variant ?? 'personal'}`, 'data-key': key, 'aria-labelledby': nameId, 'aria-describedby': descriptionId }, html('span', { class: 'option-icon', part: 'option-icon', 'aria-hidden': 'true' }, mark(icon, { className: 'option-mark', part: 'option-mark', size: 18 })), html('span', { class: 'option-text', part: 'option-text' }, html('span', { class: 'option-name', part: 'option-name', id: nameId }, name), html('span', { class: 'option-description', part: 'option-description', id: descriptionId }, description)));
|
|
292
|
+
element.addEventListener('click', () => this.#controller?.connect(platform, { variant }));
|
|
293
|
+
return element;
|
|
294
|
+
};
|
|
295
|
+
const options = [
|
|
296
|
+
option(null, `choose-${platform}-personal`, CHOICE_MARKS.personal, labels.variantPersonal, labels.variantPersonalDescription),
|
|
297
|
+
...variants.map((variant) => option(variant, `choose-${platform}-${variant}`, CHOICE_MARKS[variant], labels.variantPages, labels.variantPagesDescription)),
|
|
298
|
+
];
|
|
299
|
+
const group = html('div', { class: 'choose', part: 'choose', id: `choose-${platform}`, role: 'group', 'aria-labelledby': titleId }, html('p', { class: 'choose-title', part: 'choose-title', id: titleId }, format(labels.chooseVariant, { platform: item.name })), html('div', { class: 'options', part: 'choose-options' }, ...options), button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform)));
|
|
300
|
+
group.addEventListener('keydown', (event) => {
|
|
301
|
+
if (event.key === 'Escape') {
|
|
302
|
+
event.preventDefault();
|
|
303
|
+
this.#controller?.cancel(platform);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
|
|
307
|
+
const at = options.indexOf(event.target);
|
|
308
|
+
if (step === undefined || at === -1)
|
|
309
|
+
return;
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
options[(at + step + options.length) % options.length]?.focus();
|
|
312
|
+
});
|
|
313
|
+
return group;
|
|
264
314
|
}
|
|
265
315
|
};
|
|
266
316
|
}
|