@flung.dev/connect 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +222 -12
- package/dist/core/client.d.ts +9 -2
- package/dist/core/client.js +29 -10
- package/dist/core/controller.d.ts +214 -0
- package/dist/core/controller.js +407 -0
- package/dist/core/events.d.ts +25 -0
- package/dist/core/events.js +1 -0
- package/dist/core/index.d.ts +10 -4
- package/dist/core/index.js +6 -3
- package/dist/core/labels.d.ts +78 -0
- package/dist/core/labels.js +41 -0
- package/dist/core/platforms.d.ts +7 -1
- package/dist/core/platforms.js +11 -1
- package/dist/core/types.d.ts +20 -0
- package/dist/core/types.js +1 -0
- package/dist/element/copy.d.ts +3 -33
- package/dist/element/copy.js +2 -29
- package/dist/element/dom.d.ts +25 -5
- package/dist/element/dom.js +49 -5
- package/dist/element/events.d.ts +14 -18
- package/dist/element/flung-connect.d.ts +21 -1
- package/dist/element/flung-connect.js +221 -190
- package/dist/element/marks.d.ts +17 -0
- package/dist/element/marks.js +35 -0
- package/dist/element/styles.d.ts +6 -4
- package/dist/element/styles.js +195 -52
- package/dist/element/tooltip.d.ts +26 -0
- package/dist/element/tooltip.js +135 -0
- package/dist/index.d.ts +2 -4
- package/dist/index.js +0 -1
- package/dist/react/index.d.ts +53 -3
- package/dist/react/index.js +40 -2
- package/dist/react/use-flung-connect.d.ts +13 -0
- package/dist/react/use-flung-connect.js +65 -0
- package/package.json +1 -1
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { ConnectClient, FlungConnectError } from './client.js';
|
|
2
|
+
import { connectInPopup, readRedirectResult, withoutRedirectResult } from './flows.js';
|
|
3
|
+
import { DEFAULT_LABELS, formatLabel } from './labels.js';
|
|
4
|
+
import { HANDLE_PROMPT, isConnectVariant, isPageAccount, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
|
|
5
|
+
/** How soon a reconnect deadline is worth showing. */
|
|
6
|
+
const SOON_MS = 14 * 86_400_000;
|
|
7
|
+
const IDLE = Object.freeze({ kind: 'idle' });
|
|
8
|
+
const NO_VARIANTS = Object.freeze([]);
|
|
9
|
+
/**
|
|
10
|
+
* The connect panel with no markup: it reads the platforms and accounts, runs connects, reconnects and disconnects, and
|
|
11
|
+
* keeps one immutable snapshot of it all. `<flung-connect>` and `useFlungConnect` are drawn from it; a page can draw its
|
|
12
|
+
* own panel from it too. It starts reading as soon as it has a token.
|
|
13
|
+
*/
|
|
14
|
+
export function createConnectController(options) {
|
|
15
|
+
return new Controller(options);
|
|
16
|
+
}
|
|
17
|
+
/** The snapshot a panel with no controller yet shows, the same object every time for each `hasToken`. */
|
|
18
|
+
export function initialConnectState(hasToken) {
|
|
19
|
+
return hasToken ? INITIAL_WITH_TOKEN : INITIAL_WITHOUT_TOKEN;
|
|
20
|
+
}
|
|
21
|
+
class Controller {
|
|
22
|
+
#options;
|
|
23
|
+
#client;
|
|
24
|
+
#listeners = new Set();
|
|
25
|
+
#asked;
|
|
26
|
+
#mode;
|
|
27
|
+
#labels;
|
|
28
|
+
#platforms = [];
|
|
29
|
+
#offers = new Map();
|
|
30
|
+
#accounts = [];
|
|
31
|
+
#steps = new Map();
|
|
32
|
+
#notice = null;
|
|
33
|
+
#loading = false;
|
|
34
|
+
#loaded = false;
|
|
35
|
+
#destroyed = false;
|
|
36
|
+
#state;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.#options = options;
|
|
39
|
+
this.#asked = options.platforms ?? null;
|
|
40
|
+
this.#mode = options.mode ?? 'popup';
|
|
41
|
+
this.#labels = { ...DEFAULT_LABELS, ...options.labels };
|
|
42
|
+
this.#client = options.token === null ? null : new ConnectClient({ token: options.token, baseUrl: options.baseUrl, fetch: options.fetch });
|
|
43
|
+
this.#loading = this.#client !== null;
|
|
44
|
+
this.#state = this.#snapshot();
|
|
45
|
+
if (this.#client !== null) {
|
|
46
|
+
void this.#load().then(() => {
|
|
47
|
+
if (options.readRedirect !== false)
|
|
48
|
+
this.#readReturn();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
getState = () => this.#state;
|
|
53
|
+
subscribe = (listener) => {
|
|
54
|
+
if (this.#destroyed)
|
|
55
|
+
return () => undefined;
|
|
56
|
+
this.#listeners.add(listener);
|
|
57
|
+
return () => {
|
|
58
|
+
this.#listeners.delete(listener);
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
configure(options) {
|
|
62
|
+
if (this.#destroyed)
|
|
63
|
+
return;
|
|
64
|
+
if (options.mode !== undefined)
|
|
65
|
+
this.#mode = options.mode;
|
|
66
|
+
let changed = false;
|
|
67
|
+
if ('labels' in options) {
|
|
68
|
+
const labels = { ...DEFAULT_LABELS, ...options.labels };
|
|
69
|
+
if (Object.keys(labels).some((key) => labels[key] !== this.#labels[key])) {
|
|
70
|
+
this.#labels = labels;
|
|
71
|
+
changed = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if ('platforms' in options) {
|
|
75
|
+
const asked = options.platforms ?? null;
|
|
76
|
+
if ((asked ?? []).join(',') !== (this.#asked ?? []).join(',')) {
|
|
77
|
+
this.#asked = asked;
|
|
78
|
+
void this.#load();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (changed)
|
|
82
|
+
this.#emit();
|
|
83
|
+
}
|
|
84
|
+
destroy() {
|
|
85
|
+
if (this.#destroyed)
|
|
86
|
+
return;
|
|
87
|
+
this.#destroyed = true;
|
|
88
|
+
for (const step of this.#steps.values())
|
|
89
|
+
if (step.kind === 'waiting')
|
|
90
|
+
step.abort.abort();
|
|
91
|
+
this.#listeners.clear();
|
|
92
|
+
}
|
|
93
|
+
refresh = () => this.#load();
|
|
94
|
+
connect = (platform, options = {}) => {
|
|
95
|
+
if (!this.#ready(platform))
|
|
96
|
+
return;
|
|
97
|
+
const offer = this.#offers.get(platform);
|
|
98
|
+
let variant = options.variant ?? null;
|
|
99
|
+
if (options.variant === undefined && offer !== undefined && offer.variants.length > 0) {
|
|
100
|
+
// Both ways set up: ask. Only a variant set up: that is the connect.
|
|
101
|
+
if (offer.connectable)
|
|
102
|
+
return this.#set(platform, { kind: 'choose', variants: offer.variants });
|
|
103
|
+
variant = offer.variants[0] ?? null;
|
|
104
|
+
}
|
|
105
|
+
const prompt = HANDLE_PROMPT[platform];
|
|
106
|
+
if (variant === null && prompt !== undefined)
|
|
107
|
+
return this.#set(platform, { kind: 'handle', required: prompt.required });
|
|
108
|
+
this.#start(platform, null, undefined, variant);
|
|
109
|
+
};
|
|
110
|
+
submitHandle = (platform, handle) => {
|
|
111
|
+
if (!this.#ready(platform))
|
|
112
|
+
return;
|
|
113
|
+
const trimmed = handle.trim();
|
|
114
|
+
this.#start(platform, null, trimmed === '' ? undefined : trimmed, null);
|
|
115
|
+
};
|
|
116
|
+
reconnect = (accountId) => {
|
|
117
|
+
const account = this.#accounts.find((candidate) => candidate.id === accountId);
|
|
118
|
+
if (account === undefined || !this.#ready(account.platform))
|
|
119
|
+
return;
|
|
120
|
+
// The API reconnects through the app the account was connected with, so a page needs no variant here.
|
|
121
|
+
this.#start(account.platform, account, undefined, null);
|
|
122
|
+
};
|
|
123
|
+
requestDisconnect = (accountId) => {
|
|
124
|
+
const account = this.#accounts.find((candidate) => candidate.id === accountId);
|
|
125
|
+
if (account === undefined || !this.#ready(account.platform))
|
|
126
|
+
return;
|
|
127
|
+
this.#set(account.platform, { kind: 'confirm', account });
|
|
128
|
+
};
|
|
129
|
+
confirmDisconnect = async (platform) => {
|
|
130
|
+
const step = this.#steps.get(platform);
|
|
131
|
+
const client = this.#client;
|
|
132
|
+
if (step?.kind !== 'confirm' || client === null || this.#destroyed)
|
|
133
|
+
return;
|
|
134
|
+
this.#set(platform, { kind: 'idle' });
|
|
135
|
+
try {
|
|
136
|
+
const disconnected = await client.disconnect(step.account.id);
|
|
137
|
+
if (this.#destroyed)
|
|
138
|
+
return;
|
|
139
|
+
await this.#load();
|
|
140
|
+
this.#tell({ kind: 'disconnected', account: step.account });
|
|
141
|
+
this.#options.onDisconnect?.({ account: disconnected });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
this.#fail(error, 'disconnect', platform);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
cancel = (platform) => {
|
|
148
|
+
const step = this.#steps.get(platform);
|
|
149
|
+
if (step === undefined || step.kind === 'idle')
|
|
150
|
+
return;
|
|
151
|
+
if (step.kind === 'waiting')
|
|
152
|
+
step.abort.abort();
|
|
153
|
+
this.#set(platform, { kind: 'idle' });
|
|
154
|
+
};
|
|
155
|
+
dismissNotice = () => {
|
|
156
|
+
if (this.#notice !== null)
|
|
157
|
+
this.#tell(null);
|
|
158
|
+
};
|
|
159
|
+
/** A connect may start: there is a token, and the platform is not already waiting on one. */
|
|
160
|
+
#ready(platform) {
|
|
161
|
+
return !this.#destroyed && this.#client !== null && this.#steps.get(platform)?.kind !== 'waiting';
|
|
162
|
+
}
|
|
163
|
+
async #load() {
|
|
164
|
+
const client = this.#client;
|
|
165
|
+
if (client === null || this.#destroyed)
|
|
166
|
+
return;
|
|
167
|
+
if (!this.#loading) {
|
|
168
|
+
this.#loading = true;
|
|
169
|
+
this.#emit();
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const [platforms, accounts] = await Promise.all([client.platforms(), client.accounts()]);
|
|
173
|
+
if (this.#destroyed)
|
|
174
|
+
return;
|
|
175
|
+
const asked = this.#asked;
|
|
176
|
+
const granted = typeof this.#options.token === 'string' ? tokenPlatforms(this.#options.token) : null;
|
|
177
|
+
const offered = platforms
|
|
178
|
+
.map((info) => ({ platform: info.platform, connectable: info.connectable, variants: (info.variants ?? []).filter(isConnectVariant) }))
|
|
179
|
+
.filter((info) => (info.connectable || info.variants.length > 0) && (asked ?? [info.platform]).includes(info.platform) && (granted ?? [info.platform]).includes(info.platform));
|
|
180
|
+
this.#platforms = offered.map((info) => info.platform);
|
|
181
|
+
this.#offers = new Map(offered.map((info) => [info.platform, Object.freeze({ connectable: info.connectable, variants: Object.freeze(info.variants) })]));
|
|
182
|
+
this.#accounts = accounts.filter((account) => account.status !== 'disconnected' && this.#platforms.includes(account.platform));
|
|
183
|
+
this.#loading = false;
|
|
184
|
+
this.#loaded = true;
|
|
185
|
+
this.#emit();
|
|
186
|
+
this.#options.onLoad?.({ accounts: this.#accounts });
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (this.#destroyed)
|
|
190
|
+
return;
|
|
191
|
+
this.#loading = false;
|
|
192
|
+
this.#fail(error, 'load', null);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** A connect that left the page (`mode: 'redirect'`) and came back: report it, and take Flung's result off the address. */
|
|
196
|
+
#readReturn() {
|
|
197
|
+
if (this.#destroyed || typeof location === 'undefined' || typeof history === 'undefined')
|
|
198
|
+
return;
|
|
199
|
+
const result = readRedirectResult(location.href);
|
|
200
|
+
if (result === null)
|
|
201
|
+
return;
|
|
202
|
+
history.replaceState(history.state, '', withoutRedirectResult(location.href));
|
|
203
|
+
if (result.status === 'connected') {
|
|
204
|
+
const accounts = this.#accounts.filter((account) => result.accountIds.includes(account.id));
|
|
205
|
+
this.#tell({ kind: 'connected', platform: accounts[0]?.platform ?? null, accounts, session: null });
|
|
206
|
+
this.#options.onConnect?.({ session: null, accounts });
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const code = result.error?.code ?? 'platform_error';
|
|
210
|
+
const message = result.error?.message ?? '';
|
|
211
|
+
this.#tell({ kind: 'error', during: 'redirect', platform: null, code, message });
|
|
212
|
+
this.#options.onError?.({ code, message });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Called straight from a click or a submit: a window can only be opened during one. */
|
|
216
|
+
#start(platform, account, handle, variant) {
|
|
217
|
+
const client = this.#client;
|
|
218
|
+
const redirect = this.#mode === 'redirect' && typeof location !== 'undefined';
|
|
219
|
+
const redirectUrl = redirect ? withoutRedirectResult(location.href) : undefined;
|
|
220
|
+
const begin = () => (account === null ? client.connect(platform, { handle, redirectUrl, variant: variant ?? undefined }) : client.reconnect(account.id, { redirectUrl }));
|
|
221
|
+
const abort = new AbortController();
|
|
222
|
+
const step = { kind: 'waiting', abort, blockedUrl: null, accountId: account?.id ?? null, variant };
|
|
223
|
+
this.#notice = null;
|
|
224
|
+
this.#set(platform, step);
|
|
225
|
+
if (redirect) {
|
|
226
|
+
void begin().then((session) => {
|
|
227
|
+
const url = safeUrl(session.authorizationUrl);
|
|
228
|
+
if (url !== null && !this.#destroyed)
|
|
229
|
+
location.assign(url);
|
|
230
|
+
}, (error) => this.#settle(platform, step, error));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
void connectInPopup(client, begin, { signal: abort.signal })
|
|
234
|
+
.then(async (outcome) => {
|
|
235
|
+
if (outcome.kind === 'finished')
|
|
236
|
+
return outcome.session;
|
|
237
|
+
if (this.#steps.get(platform) === step)
|
|
238
|
+
this.#set(platform, { ...step, blockedUrl: safeUrl(outcome.session.authorizationUrl) });
|
|
239
|
+
return client.waitForSession(outcome.session.id, { signal: abort.signal });
|
|
240
|
+
})
|
|
241
|
+
.then((session) => this.#finish(platform, session), (error) => this.#settle(platform, step, error));
|
|
242
|
+
}
|
|
243
|
+
async #finish(platform, session) {
|
|
244
|
+
if (this.#destroyed)
|
|
245
|
+
return;
|
|
246
|
+
this.#set(platform, { kind: 'idle' });
|
|
247
|
+
if (session.status === 'connected') {
|
|
248
|
+
await this.#load();
|
|
249
|
+
if (this.#destroyed)
|
|
250
|
+
return;
|
|
251
|
+
const accounts = this.#accounts.filter((account) => session.accountIds.includes(account.id));
|
|
252
|
+
this.#tell({ kind: 'connected', platform, accounts, session });
|
|
253
|
+
this.#options.onConnect?.({ session, accounts });
|
|
254
|
+
}
|
|
255
|
+
else if (session.status === 'expired') {
|
|
256
|
+
this.#tell({ kind: 'expired', platform });
|
|
257
|
+
this.#options.onError?.({ code: 'expired', message: this.#state.message.text });
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
const code = session.error?.code ?? 'platform_error';
|
|
261
|
+
const message = session.error?.message ?? '';
|
|
262
|
+
this.#tell({ kind: 'error', during: 'connect', platform, code, message });
|
|
263
|
+
this.#options.onError?.({ code, message });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** A connect that stopped early: cancelled by the person, or refused by the API. */
|
|
267
|
+
#settle(platform, step, error) {
|
|
268
|
+
if (this.#destroyed)
|
|
269
|
+
return;
|
|
270
|
+
if (this.#steps.get(platform) === step)
|
|
271
|
+
this.#set(platform, { kind: 'idle' });
|
|
272
|
+
// By name and by the signal, not `instanceof DOMException`: the reason may come from another realm's DOMException.
|
|
273
|
+
const aborted = (step.kind === 'waiting' && step.abort.signal.aborted) || (typeof error === 'object' && error !== null && error.name === 'AbortError');
|
|
274
|
+
if (aborted)
|
|
275
|
+
return;
|
|
276
|
+
this.#fail(error, 'connect', platform);
|
|
277
|
+
}
|
|
278
|
+
#fail(error, during, platform) {
|
|
279
|
+
if (this.#destroyed)
|
|
280
|
+
return;
|
|
281
|
+
const detail = error instanceof FlungConnectError ? { code: error.code, message: error.message } : { code: 'unknown', message: String(error) };
|
|
282
|
+
this.#tell({ kind: 'error', during, platform, ...detail });
|
|
283
|
+
this.#options.onError?.(detail);
|
|
284
|
+
}
|
|
285
|
+
#set(platform, step) {
|
|
286
|
+
this.#steps.set(platform, step);
|
|
287
|
+
this.#emit();
|
|
288
|
+
}
|
|
289
|
+
#tell(notice) {
|
|
290
|
+
this.#notice = notice;
|
|
291
|
+
this.#emit();
|
|
292
|
+
}
|
|
293
|
+
#emit() {
|
|
294
|
+
if (this.#destroyed)
|
|
295
|
+
return;
|
|
296
|
+
this.#state = this.#snapshot();
|
|
297
|
+
for (const listener of [...this.#listeners])
|
|
298
|
+
listener();
|
|
299
|
+
}
|
|
300
|
+
#snapshot() {
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
const items = new Map(this.#accounts.map((account) => [account.id, accountItem(account, now)]));
|
|
303
|
+
const platforms = this.#platforms.map((platform) => {
|
|
304
|
+
const step = publicStep(this.#steps.get(platform), items, now);
|
|
305
|
+
const prompt = HANDLE_PROMPT[platform];
|
|
306
|
+
const offer = this.#offers.get(platform);
|
|
307
|
+
return Object.freeze({
|
|
308
|
+
platform,
|
|
309
|
+
name: PLATFORM_NAMES[platform],
|
|
310
|
+
accounts: Object.freeze(this.#accounts.filter((account) => account.platform === platform).map((account) => items.get(account.id))),
|
|
311
|
+
step,
|
|
312
|
+
busy: step.kind === 'waiting',
|
|
313
|
+
handlePrompt: prompt === undefined ? null : Object.freeze({ required: prompt.required }),
|
|
314
|
+
connectable: offer?.connectable ?? true,
|
|
315
|
+
variants: offer?.variants ?? NO_VARIANTS,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
return Object.freeze({
|
|
319
|
+
hasToken: this.#client !== null,
|
|
320
|
+
loading: this.#loading,
|
|
321
|
+
loaded: this.#loaded,
|
|
322
|
+
platforms: Object.freeze(platforms),
|
|
323
|
+
accounts: Object.freeze([...this.#accounts]),
|
|
324
|
+
notice: this.#notice,
|
|
325
|
+
message: Object.freeze(statusLine(this.#notice, this.#client !== null, this.#loaded, this.#platforms.length, this.#labels)),
|
|
326
|
+
labels: Object.freeze({ ...this.#labels }),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function publicStep(step, items, now) {
|
|
331
|
+
if (step === undefined || step.kind === 'idle')
|
|
332
|
+
return IDLE;
|
|
333
|
+
if (step.kind === 'handle')
|
|
334
|
+
return Object.freeze({ kind: 'handle', required: step.required });
|
|
335
|
+
if (step.kind === 'choose')
|
|
336
|
+
return Object.freeze({ kind: 'choose', variants: step.variants });
|
|
337
|
+
if (step.kind === 'waiting')
|
|
338
|
+
return Object.freeze({ kind: 'waiting', blockedUrl: step.blockedUrl, accountId: step.accountId, variant: step.variant });
|
|
339
|
+
return Object.freeze({ kind: 'confirm', account: items.get(step.account.id) ?? accountItem(step.account, now) });
|
|
340
|
+
}
|
|
341
|
+
/** What to show for an account. */
|
|
342
|
+
function accountItem(account, now) {
|
|
343
|
+
const name = account.username ?? account.platformUserId;
|
|
344
|
+
const shown = nonEmpty(account.displayName) ?? name;
|
|
345
|
+
const username = account.username !== null && account.username !== shown ? account.username : null;
|
|
346
|
+
const due = account.reconnectBy === null ? null : Date.parse(account.reconnectBy);
|
|
347
|
+
const reconnect = account.status === 'needs_reconnect' ? { reason: 'needs_reconnect' } : due !== null && !Number.isNaN(due) && due - now < SOON_MS ? { reason: 'deadline', by: account.reconnectBy } : null;
|
|
348
|
+
return Object.freeze({ account, name, displayName: shown, username, avatarUrl: safeUrl(account.avatarUrl ?? null, ['https:']), initial: initialOf(shown), page: isPageAccount(account), reconnect });
|
|
349
|
+
}
|
|
350
|
+
function statusLine(notice, hasToken, loaded, offered, labels) {
|
|
351
|
+
if (notice !== null)
|
|
352
|
+
return { text: noticeText(notice, labels), error: notice.kind === 'error' || notice.kind === 'expired' };
|
|
353
|
+
if (!hasToken)
|
|
354
|
+
return { text: labels.noToken, error: false };
|
|
355
|
+
if (offered === 0)
|
|
356
|
+
return { text: loaded ? labels.empty : labels.loading, error: false };
|
|
357
|
+
return { text: '', error: false };
|
|
358
|
+
}
|
|
359
|
+
function noticeText(notice, labels) {
|
|
360
|
+
switch (notice.kind) {
|
|
361
|
+
case 'connected':
|
|
362
|
+
return formatLabel(labels.connected, { platform: notice.platform === null ? 'The account' : PLATFORM_NAMES[notice.platform] });
|
|
363
|
+
case 'disconnected':
|
|
364
|
+
return formatLabel(labels.disconnected, { account: notice.account.username ?? notice.account.platformUserId });
|
|
365
|
+
case 'expired':
|
|
366
|
+
return formatLabel(labels.expired, { platform: PLATFORM_NAMES[notice.platform] });
|
|
367
|
+
case 'error':
|
|
368
|
+
if (notice.during === 'load')
|
|
369
|
+
return formatLabel(labels.loadFailed, { message: notice.message });
|
|
370
|
+
if (notice.during === 'connect' && notice.platform !== null)
|
|
371
|
+
return formatLabel(labels.failed, { platform: PLATFORM_NAMES[notice.platform], message: notice.message });
|
|
372
|
+
return notice.message;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function nonEmpty(value) {
|
|
376
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
377
|
+
}
|
|
378
|
+
/** The first letter of a name, without a leading `@`, upper-cased; `?` when there is none. */
|
|
379
|
+
function initialOf(name) {
|
|
380
|
+
const first = Array.from(name.replace(/^@+/, '').trim())[0];
|
|
381
|
+
return first === undefined ? '?' : first.toLocaleUpperCase();
|
|
382
|
+
}
|
|
383
|
+
/** `value` when it is an address on one of `protocols`, else null: an address from the API never becomes a `javascript:` link. */
|
|
384
|
+
function safeUrl(value, protocols = ['https:', 'http:']) {
|
|
385
|
+
if (value === null)
|
|
386
|
+
return null;
|
|
387
|
+
try {
|
|
388
|
+
return protocols.includes(new URL(value).protocol) ? value : null;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function frozenInitial(hasToken) {
|
|
395
|
+
return Object.freeze({
|
|
396
|
+
hasToken,
|
|
397
|
+
loading: hasToken,
|
|
398
|
+
loaded: false,
|
|
399
|
+
platforms: Object.freeze([]),
|
|
400
|
+
accounts: Object.freeze([]),
|
|
401
|
+
notice: null,
|
|
402
|
+
message: Object.freeze(statusLine(null, hasToken, false, 0, DEFAULT_LABELS)),
|
|
403
|
+
labels: Object.freeze({ ...DEFAULT_LABELS }),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const INITIAL_WITH_TOKEN = frozenInitial(true);
|
|
407
|
+
const INITIAL_WITHOUT_TOKEN = frozenInitial(false);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Account, ConnectSession } from './types.js';
|
|
2
|
+
/** `flung-load`: the accounts were read, on first show and after every change. */
|
|
3
|
+
export interface LoadDetail {
|
|
4
|
+
/** The connected accounts the panel shows, newest first. */
|
|
5
|
+
accounts: Account[];
|
|
6
|
+
}
|
|
7
|
+
/** `flung-connect`: a connect or reconnect finished. `accounts` are the ones stored; Facebook stores one per page. */
|
|
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;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@flung.dev/connect/core`: the connect calls and
|
|
2
|
+
* `@flung.dev/connect/core`: the connect calls, flows and headless controller with no element, for a page that draws
|
|
3
|
+
* its own panel.
|
|
3
4
|
*/
|
|
4
5
|
export { ConnectClient, DEFAULT_BASE_URL, FlungConnectError } from './client.js';
|
|
5
6
|
export type { ConnectClientOptions, TokenSource } from './client.js';
|
|
7
|
+
export { createConnectController, initialConnectState } from './controller.js';
|
|
8
|
+
export type { AccountItem, ConnectActions, ConnectController, ConnectControllerOptions, ConnectNotice, ConnectOptions, ConnectState, PlatformItem, PlatformStep } from './controller.js';
|
|
9
|
+
export type { ConnectDetail, DisconnectDetail, ErrorDetail, LoadDetail } from './events.js';
|
|
6
10
|
export { connectInPopup, readRedirectResult, withoutRedirectResult } from './flows.js';
|
|
7
11
|
export type { PopupOutcome, RedirectResult } from './flows.js';
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
10
|
-
export
|
|
12
|
+
export { DEFAULT_LABELS, formatLabel } from './labels.js';
|
|
13
|
+
export type { ConnectLabels } from './labels.js';
|
|
14
|
+
export { HANDLE_PROMPT, isConnectVariant, isPageAccount, isPlatform, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
|
|
15
|
+
export { CONNECT_VARIANTS, PLATFORMS } from './types.js';
|
|
16
|
+
export type { Account, AccountStatus, ConnectSession, ConnectSessionStatus, ConnectVariant, Platform, PlatformInfo } from './types.js';
|
package/dist/core/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@flung.dev/connect/core`: the connect calls and
|
|
2
|
+
* `@flung.dev/connect/core`: the connect calls, flows and headless controller with no element, for a page that draws
|
|
3
|
+
* its own panel.
|
|
3
4
|
*/
|
|
4
5
|
export { ConnectClient, DEFAULT_BASE_URL, FlungConnectError } from './client.js';
|
|
6
|
+
export { createConnectController, initialConnectState } from './controller.js';
|
|
5
7
|
export { connectInPopup, readRedirectResult, withoutRedirectResult } from './flows.js';
|
|
6
|
-
export {
|
|
7
|
-
export {
|
|
8
|
+
export { DEFAULT_LABELS, formatLabel } from './labels.js';
|
|
9
|
+
export { HANDLE_PROMPT, isConnectVariant, isPageAccount, isPlatform, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
|
|
10
|
+
export { CONNECT_VARIANTS, PLATFORMS } from './types.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every word the panel shows, in English. Pass `labels` to put it in another language or say it your way; `{platform}`, `{account}`,
|
|
3
|
+
* `{message}`, `{date}` and `{count}` 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
|
+
/** The button on a platform with no accounts yet. */
|
|
9
|
+
connect: string;
|
|
10
|
+
/** The button on a platform that already has an account. */
|
|
11
|
+
connectAnother: string;
|
|
12
|
+
/** The button beside an account that needs reconnecting. */
|
|
13
|
+
reconnect: string;
|
|
14
|
+
/** The button beside each account, and the one that confirms. */
|
|
15
|
+
disconnect: string;
|
|
16
|
+
/** The question before a disconnect. `{account}`. */
|
|
17
|
+
confirmDisconnect: string;
|
|
18
|
+
/** The button that keeps the account instead. */
|
|
19
|
+
keep: string;
|
|
20
|
+
/** Stops waiting, or closes a question. */
|
|
21
|
+
cancel: string;
|
|
22
|
+
/** Sends the Mastodon server or Bluesky handle. */
|
|
23
|
+
continue: string;
|
|
24
|
+
/** The Bluesky handle field's label. */
|
|
25
|
+
handleBluesky: string;
|
|
26
|
+
/** The Mastodon server field's label. */
|
|
27
|
+
handleMastodon: string;
|
|
28
|
+
/** While the sign-in window is open. `{platform}`. */
|
|
29
|
+
waiting: string;
|
|
30
|
+
/** When the browser refused to open the window. `{platform}`. */
|
|
31
|
+
blocked: string;
|
|
32
|
+
/** The link to the consent page when the window was blocked. `{platform}`. */
|
|
33
|
+
openSignIn: string;
|
|
34
|
+
/** After a connect. `{platform}`. */
|
|
35
|
+
connected: string;
|
|
36
|
+
/** After a connect that failed. `{platform}` and `{message}`. */
|
|
37
|
+
failed: string;
|
|
38
|
+
/** After a connect session ran out of time. `{platform}`. */
|
|
39
|
+
expired: string;
|
|
40
|
+
/** After a disconnect. `{account}`. */
|
|
41
|
+
disconnected: string;
|
|
42
|
+
/** Beside an account that must be reconnected. */
|
|
43
|
+
needsReconnect: string;
|
|
44
|
+
/** Beside an account whose connection ends soon. `{date}`. */
|
|
45
|
+
reconnectBy: string;
|
|
46
|
+
/** Under a platform's name once it has accounts. `{count}`. */
|
|
47
|
+
connectedCount: string;
|
|
48
|
+
/** Under a platform's name before it has any. */
|
|
49
|
+
notConnected: string;
|
|
50
|
+
/** Before Flung's name in the panel's footer, which `branding="none"` hides. */
|
|
51
|
+
securedBy: string;
|
|
52
|
+
/** While the accounts are read for the first time. */
|
|
53
|
+
loading: string;
|
|
54
|
+
/** When the accounts could not be read. `{message}`. */
|
|
55
|
+
loadFailed: string;
|
|
56
|
+
/** When there is no platform to offer. */
|
|
57
|
+
empty: string;
|
|
58
|
+
/** When the panel has no connect token. */
|
|
59
|
+
noToken: string;
|
|
60
|
+
/** The question above the choice between a profile and a page, when a platform offers both. `{platform}`. */
|
|
61
|
+
chooseVariant: string;
|
|
62
|
+
/** The choice that connects the person's own profile. */
|
|
63
|
+
variantPersonal: string;
|
|
64
|
+
/** The line under it. */
|
|
65
|
+
variantPersonalDescription: string;
|
|
66
|
+
/** The choice that connects the company pages the person administers. */
|
|
67
|
+
variantPages: string;
|
|
68
|
+
/** The line under it. */
|
|
69
|
+
variantPagesDescription: string;
|
|
70
|
+
/** The platform's name on its pages tile, with `variants="tiles"`, or when pages are all it can connect. `{platform}`. */
|
|
71
|
+
pagesName: string;
|
|
72
|
+
/** The small tag beside an account that is a company page, not a person. */
|
|
73
|
+
pageTag: string;
|
|
74
|
+
}
|
|
75
|
+
/** The panel's words in English. */
|
|
76
|
+
export declare const DEFAULT_LABELS: ConnectLabels;
|
|
77
|
+
/** Fills a label's `{name}` places from `values`, leaving any it has no value for as they are. The result is text, never markup. */
|
|
78
|
+
export declare function formatLabel(template: string, values: Readonly<Record<string, string>>): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The panel's words in English. */
|
|
2
|
+
export const DEFAULT_LABELS = {
|
|
3
|
+
panel: 'Connected accounts',
|
|
4
|
+
connect: 'Connect',
|
|
5
|
+
connectAnother: 'Add another',
|
|
6
|
+
reconnect: 'Reconnect',
|
|
7
|
+
disconnect: 'Disconnect',
|
|
8
|
+
confirmDisconnect: 'Disconnect {account}? Posts to it will stop.',
|
|
9
|
+
keep: 'Keep it',
|
|
10
|
+
cancel: 'Cancel',
|
|
11
|
+
continue: 'Continue',
|
|
12
|
+
handleBluesky: 'Your Bluesky handle, such as alice.bsky.social (optional)',
|
|
13
|
+
handleMastodon: 'Your Mastodon server, such as mastodon.social',
|
|
14
|
+
waiting: 'Finish signing in to {platform} in the window that opened.',
|
|
15
|
+
blocked: 'Your browser did not open the {platform} window.',
|
|
16
|
+
openSignIn: 'Open {platform} sign-in',
|
|
17
|
+
connected: '{platform} is connected.',
|
|
18
|
+
failed: '{platform} was not connected: {message}',
|
|
19
|
+
expired: 'Signing in to {platform} took too long. Try again.',
|
|
20
|
+
disconnected: '{account} is disconnected.',
|
|
21
|
+
needsReconnect: 'Needs reconnecting',
|
|
22
|
+
reconnectBy: 'Reconnect by {date}',
|
|
23
|
+
connectedCount: '{count} connected',
|
|
24
|
+
notConnected: 'Not connected',
|
|
25
|
+
securedBy: 'Secured by',
|
|
26
|
+
loading: 'Loading accounts…',
|
|
27
|
+
loadFailed: 'Could not load accounts: {message}',
|
|
28
|
+
empty: 'There is nothing to connect here.',
|
|
29
|
+
noToken: 'This panel has no connect token yet.',
|
|
30
|
+
chooseVariant: 'What do you want to connect on {platform}?',
|
|
31
|
+
variantPersonal: 'Personal profile',
|
|
32
|
+
variantPersonalDescription: 'Post as yourself.',
|
|
33
|
+
variantPages: 'Company page',
|
|
34
|
+
variantPagesDescription: 'Post as a page you admin.',
|
|
35
|
+
pagesName: '{platform} Page',
|
|
36
|
+
pageTag: 'Page',
|
|
37
|
+
};
|
|
38
|
+
/** Fills a label's `{name}` places from `values`, leaving any it has no value for as they are. The result is text, never markup. */
|
|
39
|
+
export function formatLabel(template, values) {
|
|
40
|
+
return template.replace(/\{(\w+)\}/g, (whole, name) => values[name] ?? whole);
|
|
41
|
+
}
|
package/dist/core/platforms.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Platform } from './types.js';
|
|
1
|
+
import type { Account, ConnectVariant, Platform } from './types.js';
|
|
2
2
|
export declare const PLATFORM_NAMES: Readonly<Record<Platform, string>>;
|
|
3
3
|
/** What a connect asks the person for first: Mastodon needs their instance; Bluesky takes a handle when they have one. */
|
|
4
4
|
export declare const HANDLE_PROMPT: Readonly<Partial<Record<Platform, {
|
|
@@ -12,3 +12,9 @@ export declare function parsePlatformList(value: string | null | undefined): Pla
|
|
|
12
12
|
* API checks the signature and refuses the rest. Null when the token names none or cannot be read.
|
|
13
13
|
*/
|
|
14
14
|
export declare function tokenPlatforms(token: string): Platform[] | null;
|
|
15
|
+
export declare function isConnectVariant(value: unknown): value is ConnectVariant;
|
|
16
|
+
/**
|
|
17
|
+
* Whether an account is a company page rather than a person: a LinkedIn account that posts as an organisation
|
|
18
|
+
* (`urn:li:organization:<id>`), as the `pages` variant connects them.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isPageAccount(account: Pick<Account, 'platform' | 'platformUserId'>): boolean;
|
package/dist/core/platforms.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PLATFORMS } from './types.js';
|
|
1
|
+
import { CONNECT_VARIANTS, PLATFORMS } from './types.js';
|
|
2
2
|
export const PLATFORM_NAMES = {
|
|
3
3
|
x: 'X',
|
|
4
4
|
instagram: 'Instagram',
|
|
@@ -47,3 +47,13 @@ export function tokenPlatforms(token) {
|
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
export function isConnectVariant(value) {
|
|
51
|
+
return typeof value === 'string' && CONNECT_VARIANTS.includes(value);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether an account is a company page rather than a person: a LinkedIn account that posts as an organisation
|
|
55
|
+
* (`urn:li:organization:<id>`), as the `pages` variant connects them.
|
|
56
|
+
*/
|
|
57
|
+
export function isPageAccount(account) {
|
|
58
|
+
return account.platform === 'linkedin' && account.platformUserId.startsWith('urn:li:organization:');
|
|
59
|
+
}
|