@flung.dev/connect 0.3.0 → 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 +4 -1
- package/dist/core/client.d.ts +5 -1
- package/dist/core/client.js +8 -2
- package/dist/core/controller.d.ts +19 -1
- package/dist/core/controller.js +48 -8
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +1 -0
- package/dist/core/labels.d.ts +18 -0
- package/dist/core/labels.js +9 -0
- package/dist/core/qr.d.ts +13 -0
- package/dist/core/qr.js +319 -0
- package/dist/core/types.d.ts +8 -0
- package/dist/element/dom.d.ts +5 -0
- package/dist/element/dom.js +37 -0
- package/dist/element/flung-connect.js +39 -5
- package/dist/element/styles.js +19 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,6 +158,7 @@ Set any of these on the element. Each has a default for light and dark.
|
|
|
158
158
|
| `--flung-focus-width` | `2px` | Its width. |
|
|
159
159
|
| `--flung-focus-offset` | `2px` | Its gap from the control. |
|
|
160
160
|
| `--flung-grid-min` | `16rem` | The narrowest card in the `grid` layout. |
|
|
161
|
+
| `--flung-qr-size` | `132px` | The QR code of an approval link (Farcaster). |
|
|
161
162
|
| `--flung-tooltip-background` | `--flung-text` | The tooltip that shows an account name cut short. |
|
|
162
163
|
| `--flung-tooltip-text` | `--flung-surface` | Its words. |
|
|
163
164
|
| `--flung-tooltip-radius` | `--flung-radius-inner` | Its corners. |
|
|
@@ -196,6 +197,7 @@ Every piece the panel draws has a `part` name for `::part()`. Pieces with a kind
|
|
|
196
197
|
| `tag`, `tag-page` | The **Page** tag beside a company page. |
|
|
197
198
|
| `form`, `label`, `input` | The Mastodon server or Bluesky handle question. |
|
|
198
199
|
| `note`, `spinner`, `note-text`, `link` | The wait, and the sign-in link when the browser blocked the window. |
|
|
200
|
+
| `approve`, `approve-ended`, `approve-text`, `approve-go`, `link-approval`, `approve-scan`, `qr`, `approve-hint`, `approve-actions`, `button-retry` | An approval in the platform's phone app (Farcaster): the line, Open Warpcast, the QR code, and Try again once it ended. |
|
|
199
201
|
| `confirm`, `confirm-text` | The question before a disconnect. |
|
|
200
202
|
| `message`, `message-error` | The status line under the list. It has `hidden` when empty. |
|
|
201
203
|
| `footer`, `footer-link`, `footer-mark` | "Secured by Flung". |
|
|
@@ -298,12 +300,13 @@ panel.subscribe(() => draw(panel.getState()))
|
|
|
298
300
|
| `configure({ platforms, mode, labels })` | Changes those options while shown. |
|
|
299
301
|
| `destroy()` | Stops every wait and listener. |
|
|
300
302
|
|
|
301
|
-
`ConnectState` has `hasToken`, `loading`, `loaded`, `platforms` (each with `name`, `accounts`, `step`, `busy`, `handlePrompt`, `connectable` and `
|
|
303
|
+
`ConnectState` has `hasToken`, `loading`, `loaded`, `platforms` (each with `name`, `accounts`, `step`, `busy`, `handlePrompt`, `connectable`, `variants` and `connectFlow`), `accounts`, `notice` (the last thing that happened, as data), `message` (that notice, or the loading, empty or no-token line, in the labels' words) and `labels`. Each account item has `displayName`, `username` (the handle when it differs), `avatarUrl` (only ever `https`), `initial`, `page` (a LinkedIn company page) and `reconnect`. A step is `idle`, `handle` (with `required`), `choose` (with `variants`), `waiting` (with `blockedUrl` and `variant`), `approve` (with `approvalUrl`, `accountId` and `ended`) or `confirm` (with `account`). `encodeQr(text)` gives the modules of a QR code for an `approve` step's link.
|
|
302
304
|
|
|
303
305
|
## Behaviour worth knowing
|
|
304
306
|
|
|
305
307
|
- **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.
|
|
306
308
|
- **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.
|
|
309
|
+
- **Farcaster** is approved in Warpcast, its phone app, not on a sign-in page (`connectFlow: 'approval'` in `GET /v1/platforms`). The panel opens no window and does not redirect: it shows **Open Warpcast** and a QR code of the same link for a computer (hidden on a narrow screen, where the button opens the app), and reads the session after two seconds, then half as long again each time, up to every ten. Approved, it reads as connected, like any connect; expired or declined, it says why, with **Try again** for a new link. `--flung-qr-size` sets the code's size; it stays dark on light in a dark theme, as cameras need.
|
|
307
310
|
- **Mastodon and Bluesky** ask first: the person's Mastodon server (required), or their Bluesky handle (optional; without it they sign in through bsky.social).
|
|
308
311
|
- **LinkedIn** asks whether to connect a personal profile or a company page when your Flung offers pages. A reconnect of a page goes through the pages app by itself.
|
|
309
312
|
- **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.
|
package/dist/core/client.d.ts
CHANGED
|
@@ -45,9 +45,13 @@ export declare class ConnectClient {
|
|
|
45
45
|
disconnect(accountId: string): Promise<Account>;
|
|
46
46
|
/** A connect session's state: pending, connected, failed or expired. */
|
|
47
47
|
session(id: string): Promise<ConnectSession>;
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. It reads the
|
|
50
|
+
* session every `intervalMs` (two seconds); with `maxIntervalMs` it waits half as long again after each read, up to that.
|
|
51
|
+
*/
|
|
49
52
|
waitForSession(id: string, options?: {
|
|
50
53
|
signal?: AbortSignal | undefined;
|
|
51
54
|
intervalMs?: number | undefined;
|
|
55
|
+
maxIntervalMs?: number | undefined;
|
|
52
56
|
}): Promise<ConnectSession>;
|
|
53
57
|
}
|
package/dist/core/client.js
CHANGED
|
@@ -57,14 +57,20 @@ export class ConnectClient {
|
|
|
57
57
|
async session(id) {
|
|
58
58
|
return this.#call('GET', `/v1/connect-sessions/${encodeURIComponent(id)}`);
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. It reads the
|
|
62
|
+
* session every `intervalMs` (two seconds); with `maxIntervalMs` it waits half as long again after each read, up to that.
|
|
63
|
+
*/
|
|
61
64
|
async waitForSession(id, options = {}) {
|
|
65
|
+
let wait = options.intervalMs ?? POLL_MS;
|
|
62
66
|
for (;;) {
|
|
63
67
|
options.signal?.throwIfAborted();
|
|
64
68
|
const session = await this.session(id);
|
|
65
69
|
if (session.status !== 'pending')
|
|
66
70
|
return session;
|
|
67
|
-
await delay(
|
|
71
|
+
await delay(wait, options.signal);
|
|
72
|
+
if (options.maxIntervalMs !== undefined)
|
|
73
|
+
wait = Math.min(options.maxIntervalMs, Math.round(wait * 1.5));
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
76
|
async #call(method, path, body) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { TokenSource } from './client.js';
|
|
2
2
|
import type { ConnectDetail, DisconnectDetail, ErrorDetail, LoadDetail } from './events.js';
|
|
3
3
|
import type { ConnectLabels } from './labels.js';
|
|
4
|
-
import type { Account, ConnectSession, ConnectVariant, Platform } from './types.js';
|
|
4
|
+
import type { Account, ConnectFlow, ConnectSession, ConnectVariant, Platform } from './types.js';
|
|
5
5
|
/** What one connected account looks like to a panel: the API's account and what to show for it. */
|
|
6
6
|
export interface AccountItem {
|
|
7
7
|
/** The account as the API returned it. */
|
|
@@ -55,6 +55,22 @@ export type PlatformStep =
|
|
|
55
55
|
readonly accountId: string | null;
|
|
56
56
|
readonly variant: ConnectVariant | null;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* An approval connect (Farcaster, `connectFlow: 'approval'`): no window and no redirect. Show `approvalUrl` as a link
|
|
60
|
+
* and a QR code (`encodeQr`) while the panel reads the session; it is null while the link is being made. `ended` says
|
|
61
|
+
* why it stopped, `expired` or `failed` with the platform's `message`: offer Try again (`connect`, or `reconnect` with
|
|
62
|
+
* `accountId`) and Cancel. A connected approval goes back to `idle` with a `connected` notice, as other connects do.
|
|
63
|
+
*/
|
|
64
|
+
| {
|
|
65
|
+
readonly kind: 'approve';
|
|
66
|
+
readonly approvalUrl: string | null;
|
|
67
|
+
readonly accountId: string | null;
|
|
68
|
+
readonly variant: ConnectVariant | null;
|
|
69
|
+
readonly ended: {
|
|
70
|
+
readonly status: 'expired' | 'failed';
|
|
71
|
+
readonly message: string;
|
|
72
|
+
} | null;
|
|
73
|
+
}
|
|
58
74
|
/** Asking whether to disconnect `account`: call `confirmDisconnect` or `cancel`. */
|
|
59
75
|
| {
|
|
60
76
|
readonly kind: 'confirm';
|
|
@@ -83,6 +99,8 @@ export interface PlatformItem {
|
|
|
83
99
|
readonly connectable: boolean;
|
|
84
100
|
/** The other ways it can connect here, such as LinkedIn's `pages`; empty for most platforms. */
|
|
85
101
|
readonly variants: readonly ConnectVariant[];
|
|
102
|
+
/** How its connect ends: `consent` through a sign-in window or redirect, `approval` in the platform's phone app (Farcaster). */
|
|
103
|
+
readonly connectFlow: ConnectFlow;
|
|
86
104
|
}
|
|
87
105
|
/** The last thing that happened, as data. `message` in the state words it. */
|
|
88
106
|
export type ConnectNotice =
|
package/dist/core/controller.js
CHANGED
|
@@ -4,6 +4,8 @@ import { DEFAULT_LABELS, formatLabel } from './labels.js';
|
|
|
4
4
|
import { HANDLE_PROMPT, isConnectVariant, isPageAccount, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
|
|
5
5
|
/** How soon a reconnect deadline is worth showing. */
|
|
6
6
|
const SOON_MS = 14 * 86_400_000;
|
|
7
|
+
/** How an approval connect reads its session: after two seconds, then half as long again each time, up to every ten. */
|
|
8
|
+
const APPROVAL_POLL = { intervalMs: 2_000, maxIntervalMs: 10_000 };
|
|
7
9
|
const IDLE = Object.freeze({ kind: 'idle' });
|
|
8
10
|
const NO_VARIANTS = Object.freeze([]);
|
|
9
11
|
/**
|
|
@@ -86,7 +88,7 @@ class Controller {
|
|
|
86
88
|
return;
|
|
87
89
|
this.#destroyed = true;
|
|
88
90
|
for (const step of this.#steps.values())
|
|
89
|
-
if (step.kind === 'waiting')
|
|
91
|
+
if (step.kind === 'waiting' || step.kind === 'approve')
|
|
90
92
|
step.abort.abort();
|
|
91
93
|
this.#listeners.clear();
|
|
92
94
|
}
|
|
@@ -148,7 +150,7 @@ class Controller {
|
|
|
148
150
|
const step = this.#steps.get(platform);
|
|
149
151
|
if (step === undefined || step.kind === 'idle')
|
|
150
152
|
return;
|
|
151
|
-
if (step.kind === 'waiting')
|
|
153
|
+
if (step.kind === 'waiting' || step.kind === 'approve')
|
|
152
154
|
step.abort.abort();
|
|
153
155
|
this.#set(platform, { kind: 'idle' });
|
|
154
156
|
};
|
|
@@ -156,9 +158,9 @@ class Controller {
|
|
|
156
158
|
if (this.#notice !== null)
|
|
157
159
|
this.#tell(null);
|
|
158
160
|
};
|
|
159
|
-
/** A connect may start: there is a token, and the platform is not already waiting on one. */
|
|
161
|
+
/** A connect may start: there is a token, and the platform is not already waiting on one. An approval that ended may start again. */
|
|
160
162
|
#ready(platform) {
|
|
161
|
-
return !this.#destroyed && this.#client !== null && this.#steps.get(platform)
|
|
163
|
+
return !this.#destroyed && this.#client !== null && !waitingStep(this.#steps.get(platform));
|
|
162
164
|
}
|
|
163
165
|
async #load() {
|
|
164
166
|
const client = this.#client;
|
|
@@ -175,10 +177,10 @@ class Controller {
|
|
|
175
177
|
const asked = this.#asked;
|
|
176
178
|
const granted = typeof this.#options.token === 'string' ? tokenPlatforms(this.#options.token) : null;
|
|
177
179
|
const offered = platforms
|
|
178
|
-
.map((info) => ({ platform: info.platform, connectable: info.connectable, variants: (info.variants ?? []).filter(isConnectVariant) }))
|
|
180
|
+
.map((info) => ({ platform: info.platform, connectable: info.connectable, variants: (info.variants ?? []).filter(isConnectVariant), connectFlow: info.connectFlow === 'approval' ? 'approval' : 'consent' }))
|
|
179
181
|
.filter((info) => (info.connectable || info.variants.length > 0) && (asked ?? [info.platform]).includes(info.platform) && (granted ?? [info.platform]).includes(info.platform));
|
|
180
182
|
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) })]));
|
|
183
|
+
this.#offers = new Map(offered.map((info) => [info.platform, Object.freeze({ connectable: info.connectable, variants: Object.freeze(info.variants), connectFlow: info.connectFlow })]));
|
|
182
184
|
this.#accounts = accounts.filter((account) => account.status !== 'disconnected' && this.#platforms.includes(account.platform));
|
|
183
185
|
this.#loading = false;
|
|
184
186
|
this.#loaded = true;
|
|
@@ -218,6 +220,8 @@ class Controller {
|
|
|
218
220
|
const redirect = this.#mode === 'redirect' && typeof location !== 'undefined';
|
|
219
221
|
const redirectUrl = redirect ? withoutRedirectResult(location.href) : undefined;
|
|
220
222
|
const begin = () => (account === null ? client.connect(platform, { handle, redirectUrl, variant: variant ?? undefined }) : client.reconnect(account.id, { redirectUrl }));
|
|
223
|
+
if (this.#offers.get(platform)?.connectFlow === 'approval')
|
|
224
|
+
return this.#approve(platform, account, begin);
|
|
221
225
|
const abort = new AbortController();
|
|
222
226
|
const step = { kind: 'waiting', abort, blockedUrl: null, accountId: account?.id ?? null, variant };
|
|
223
227
|
this.#notice = null;
|
|
@@ -240,6 +244,35 @@ class Controller {
|
|
|
240
244
|
})
|
|
241
245
|
.then((session) => this.#finish(platform, session), (error) => this.#settle(platform, step, error));
|
|
242
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* An approval connect: no window and no redirect. The link to the platform's app is shown in the step while the session
|
|
249
|
+
* is read, backing off; an approval that expires or fails stays in the step, saying why, until Try again or Cancel.
|
|
250
|
+
*/
|
|
251
|
+
#approve(platform, account, begin) {
|
|
252
|
+
const client = this.#client;
|
|
253
|
+
const step = { kind: 'approve', abort: new AbortController(), approvalUrl: null, accountId: account?.id ?? null, variant: null, ended: null };
|
|
254
|
+
this.#notice = null;
|
|
255
|
+
this.#set(platform, step);
|
|
256
|
+
void begin()
|
|
257
|
+
.then((session) => {
|
|
258
|
+
if (this.#steps.get(platform) !== step)
|
|
259
|
+
return null;
|
|
260
|
+
step.approvalUrl = safeUrl(session.authorizationUrl, ['https:']);
|
|
261
|
+
this.#emit();
|
|
262
|
+
return client.waitForSession(session.id, { signal: step.abort.signal, ...APPROVAL_POLL });
|
|
263
|
+
})
|
|
264
|
+
.then(async (session) => {
|
|
265
|
+
if (session === null || this.#destroyed || this.#steps.get(platform) !== step)
|
|
266
|
+
return;
|
|
267
|
+
if (session.status === 'connected')
|
|
268
|
+
return this.#finish(platform, session);
|
|
269
|
+
const message = session.error?.message ?? '';
|
|
270
|
+
step.ended = { status: session.status === 'expired' ? 'expired' : 'failed', message };
|
|
271
|
+
step.approvalUrl = null;
|
|
272
|
+
this.#emit();
|
|
273
|
+
this.#options.onError?.({ code: session.status === 'expired' ? 'expired' : (session.error?.code ?? 'platform_error'), message });
|
|
274
|
+
}, (error) => this.#settle(platform, step, error));
|
|
275
|
+
}
|
|
243
276
|
async #finish(platform, session) {
|
|
244
277
|
if (this.#destroyed)
|
|
245
278
|
return;
|
|
@@ -270,7 +303,7 @@ class Controller {
|
|
|
270
303
|
if (this.#steps.get(platform) === step)
|
|
271
304
|
this.#set(platform, { kind: 'idle' });
|
|
272
305
|
// 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');
|
|
306
|
+
const aborted = ((step.kind === 'waiting' || step.kind === 'approve') && step.abort.signal.aborted) || (typeof error === 'object' && error !== null && error.name === 'AbortError');
|
|
274
307
|
if (aborted)
|
|
275
308
|
return;
|
|
276
309
|
this.#fail(error, 'connect', platform);
|
|
@@ -309,10 +342,11 @@ class Controller {
|
|
|
309
342
|
name: PLATFORM_NAMES[platform],
|
|
310
343
|
accounts: Object.freeze(this.#accounts.filter((account) => account.platform === platform).map((account) => items.get(account.id))),
|
|
311
344
|
step,
|
|
312
|
-
busy: step.kind === 'waiting',
|
|
345
|
+
busy: step.kind === 'waiting' || (step.kind === 'approve' && step.ended === null),
|
|
313
346
|
handlePrompt: prompt === undefined ? null : Object.freeze({ required: prompt.required }),
|
|
314
347
|
connectable: offer?.connectable ?? true,
|
|
315
348
|
variants: offer?.variants ?? NO_VARIANTS,
|
|
349
|
+
connectFlow: offer?.connectFlow ?? 'consent',
|
|
316
350
|
});
|
|
317
351
|
});
|
|
318
352
|
return Object.freeze({
|
|
@@ -336,8 +370,14 @@ function publicStep(step, items, now) {
|
|
|
336
370
|
return Object.freeze({ kind: 'choose', variants: step.variants });
|
|
337
371
|
if (step.kind === 'waiting')
|
|
338
372
|
return Object.freeze({ kind: 'waiting', blockedUrl: step.blockedUrl, accountId: step.accountId, variant: step.variant });
|
|
373
|
+
if (step.kind === 'approve')
|
|
374
|
+
return Object.freeze({ kind: 'approve', approvalUrl: step.approvalUrl, accountId: step.accountId, variant: step.variant, ended: step.ended === null ? null : Object.freeze({ ...step.ended }) });
|
|
339
375
|
return Object.freeze({ kind: 'confirm', account: items.get(step.account.id) ?? accountItem(step.account, now) });
|
|
340
376
|
}
|
|
377
|
+
/** Whether a step holds the platform: a sign-in window open, or an approval still awaited. */
|
|
378
|
+
function waitingStep(step) {
|
|
379
|
+
return step?.kind === 'waiting' || (step?.kind === 'approve' && step.ended === null);
|
|
380
|
+
}
|
|
341
381
|
/** What to show for an account. */
|
|
342
382
|
function accountItem(account, now) {
|
|
343
383
|
const name = account.username ?? account.platformUserId;
|
package/dist/core/index.d.ts
CHANGED
|
@@ -13,4 +13,6 @@ export { DEFAULT_LABELS, formatLabel } from './labels.js';
|
|
|
13
13
|
export type { ConnectLabels } from './labels.js';
|
|
14
14
|
export { HANDLE_PROMPT, isConnectVariant, isPageAccount, isPlatform, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
|
|
15
15
|
export { CONNECT_VARIANTS, PLATFORMS } from './types.js';
|
|
16
|
-
export type { Account, AccountStatus, ConnectSession, ConnectSessionStatus, ConnectVariant, Platform, PlatformInfo } from './types.js';
|
|
16
|
+
export type { Account, AccountStatus, ConnectFlow, ConnectSession, ConnectSessionStatus, ConnectVariant, Platform, PlatformInfo } from './types.js';
|
|
17
|
+
export { encodeQr } from './qr.js';
|
|
18
|
+
export type { QrMatrix } from './qr.js';
|
package/dist/core/index.js
CHANGED
|
@@ -8,3 +8,4 @@ export { connectInPopup, readRedirectResult, withoutRedirectResult } from './flo
|
|
|
8
8
|
export { DEFAULT_LABELS, formatLabel } from './labels.js';
|
|
9
9
|
export { HANDLE_PROMPT, isConnectVariant, isPageAccount, isPlatform, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
|
|
10
10
|
export { CONNECT_VARIANTS, PLATFORMS } from './types.js';
|
|
11
|
+
export { encodeQr } from './qr.js';
|
package/dist/core/labels.d.ts
CHANGED
|
@@ -31,6 +31,24 @@ export interface ConnectLabels {
|
|
|
31
31
|
blocked: string;
|
|
32
32
|
/** The link to the consent page when the window was blocked. `{platform}`. */
|
|
33
33
|
openSignIn: string;
|
|
34
|
+
/** Above the approval link and QR code, for a platform approved in its phone app (Farcaster). `{platform}`. */
|
|
35
|
+
approve: string;
|
|
36
|
+
/** The link that opens the platform's app to approve. `{platform}`. */
|
|
37
|
+
openApproval: string;
|
|
38
|
+
/** Beside the QR code, for a person on a computer. */
|
|
39
|
+
scanApproval: string;
|
|
40
|
+
/** The QR code's name for a screen reader. `{platform}`. */
|
|
41
|
+
approvalQr: string;
|
|
42
|
+
/** While the approval is awaited. */
|
|
43
|
+
awaitingApproval: string;
|
|
44
|
+
/** Starts a new approval after one expired or failed. */
|
|
45
|
+
tryAgain: string;
|
|
46
|
+
/** After an approval link ran out of time. `{platform}`. */
|
|
47
|
+
approvalExpired: string;
|
|
48
|
+
/** After an approval was declined or failed. `{platform}` and `{message}`. */
|
|
49
|
+
approvalFailed: string;
|
|
50
|
+
/** After an approval was declined, when the platform said nothing more. `{platform}`. */
|
|
51
|
+
approvalDeclined: string;
|
|
34
52
|
/** After a connect. `{platform}`. */
|
|
35
53
|
connected: string;
|
|
36
54
|
/** After a connect that failed. `{platform}` and `{message}`. */
|
package/dist/core/labels.js
CHANGED
|
@@ -14,6 +14,15 @@ export const DEFAULT_LABELS = {
|
|
|
14
14
|
waiting: 'Finish signing in to {platform} in the window that opened.',
|
|
15
15
|
blocked: 'Your browser did not open the {platform} window.',
|
|
16
16
|
openSignIn: 'Open {platform} sign-in',
|
|
17
|
+
approve: 'Approve Flung in Warpcast, the {platform} app.',
|
|
18
|
+
openApproval: 'Open Warpcast',
|
|
19
|
+
scanApproval: 'On a computer, scan this with your phone’s camera instead.',
|
|
20
|
+
approvalQr: 'QR code that opens Warpcast to approve {platform}',
|
|
21
|
+
awaitingApproval: 'Waiting for approval',
|
|
22
|
+
tryAgain: 'Try again',
|
|
23
|
+
approvalExpired: 'The {platform} approval link ran out before it was approved.',
|
|
24
|
+
approvalFailed: '{platform} was not approved: {message}',
|
|
25
|
+
approvalDeclined: '{platform} was not approved. Try again for a new link.',
|
|
17
26
|
connected: '{platform} is connected.',
|
|
18
27
|
failed: '{platform} was not connected: {message}',
|
|
19
28
|
expired: 'Signing in to {platform} took too long. Try again.',
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A QR code of a short text, such as the link that opens Warpcast to approve Flung: byte mode, error correction level M,
|
|
3
|
+
* the smallest version that holds it, and the mask with the lowest penalty. Written here so the package keeps no runtime
|
|
4
|
+
* dependency. It follows Project Nayuki's QR Code generator (MIT, https://www.nayuki.io/page/qr-code-generator-library)
|
|
5
|
+
* step for step, so its output is that library's, module for module.
|
|
6
|
+
*/
|
|
7
|
+
/** The code as rows of modules, `true` for dark, with no quiet zone: draw one of at least four light modules around it. */
|
|
8
|
+
export interface QrMatrix {
|
|
9
|
+
readonly size: number;
|
|
10
|
+
readonly modules: readonly (readonly boolean[])[];
|
|
11
|
+
}
|
|
12
|
+
/** Encodes `text` as UTF-8. Throws a `RangeError` when it is too long for a QR code (about 2,300 bytes at level M). */
|
|
13
|
+
export declare function encodeQr(text: string): QrMatrix;
|
package/dist/core/qr.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A QR code of a short text, such as the link that opens Warpcast to approve Flung: byte mode, error correction level M,
|
|
3
|
+
* the smallest version that holds it, and the mask with the lowest penalty. Written here so the package keeps no runtime
|
|
4
|
+
* dependency. It follows Project Nayuki's QR Code generator (MIT, https://www.nayuki.io/page/qr-code-generator-library)
|
|
5
|
+
* step for step, so its output is that library's, module for module.
|
|
6
|
+
*/
|
|
7
|
+
/** Error correction codewords per block at level M, by version (index 0 unused). */
|
|
8
|
+
const ECC_PER_BLOCK = [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28];
|
|
9
|
+
/** Error correction blocks at level M, by version (index 0 unused). */
|
|
10
|
+
const BLOCKS = [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49];
|
|
11
|
+
/** Level M's two format bits. */
|
|
12
|
+
const FORMAT_BITS_M = 0;
|
|
13
|
+
const PENALTY_N1 = 3;
|
|
14
|
+
const PENALTY_N2 = 3;
|
|
15
|
+
const PENALTY_N3 = 40;
|
|
16
|
+
const PENALTY_N4 = 10;
|
|
17
|
+
/** Encodes `text` as UTF-8. Throws a `RangeError` when it is too long for a QR code (about 2,300 bytes at level M). */
|
|
18
|
+
export function encodeQr(text) {
|
|
19
|
+
const bytes = new TextEncoder().encode(text);
|
|
20
|
+
let version = 1;
|
|
21
|
+
for (;; version += 1) {
|
|
22
|
+
if (version > 40)
|
|
23
|
+
throw new RangeError('The text is too long for a QR code.');
|
|
24
|
+
if (4 + countBits(version) + bytes.length * 8 <= dataCodewords(version) * 8)
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
const bits = [];
|
|
28
|
+
const append = (value, length) => {
|
|
29
|
+
for (let i = length - 1; i >= 0; i -= 1)
|
|
30
|
+
bits.push((value >>> i) & 1);
|
|
31
|
+
};
|
|
32
|
+
append(0x4, 4);
|
|
33
|
+
append(bytes.length, countBits(version));
|
|
34
|
+
for (const byte of bytes)
|
|
35
|
+
append(byte, 8);
|
|
36
|
+
const capacity = dataCodewords(version) * 8;
|
|
37
|
+
append(0, Math.min(4, capacity - bits.length));
|
|
38
|
+
append(0, (8 - (bits.length % 8)) % 8);
|
|
39
|
+
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11)
|
|
40
|
+
append(pad, 8);
|
|
41
|
+
const data = new Array(bits.length / 8).fill(0);
|
|
42
|
+
bits.forEach((bit, i) => {
|
|
43
|
+
data[i >>> 3] = data[i >>> 3] | (bit << (7 - (i & 7)));
|
|
44
|
+
});
|
|
45
|
+
return new Grid(version).draw(interleave(version, data));
|
|
46
|
+
}
|
|
47
|
+
const countBits = (version) => (version <= 9 ? 8 : 16);
|
|
48
|
+
function rawDataModules(version) {
|
|
49
|
+
let result = (16 * version + 128) * version + 64;
|
|
50
|
+
if (version >= 2) {
|
|
51
|
+
const align = Math.floor(version / 7) + 2;
|
|
52
|
+
result -= (25 * align - 10) * align - 55;
|
|
53
|
+
if (version >= 7)
|
|
54
|
+
result -= 36;
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
const dataCodewords = (version) => Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[version] * BLOCKS[version];
|
|
59
|
+
/** Splits the data into blocks, adds each block's Reed-Solomon codewords, and interleaves them. */
|
|
60
|
+
function interleave(version, data) {
|
|
61
|
+
const blocks = BLOCKS[version];
|
|
62
|
+
const eccLength = ECC_PER_BLOCK[version];
|
|
63
|
+
const raw = Math.floor(rawDataModules(version) / 8);
|
|
64
|
+
const shortBlocks = blocks - (raw % blocks);
|
|
65
|
+
const shortLength = Math.floor(raw / blocks);
|
|
66
|
+
const divisor = rsDivisor(eccLength);
|
|
67
|
+
const split = [];
|
|
68
|
+
for (let i = 0, k = 0; i < blocks; i += 1) {
|
|
69
|
+
const block = data.slice(k, k + shortLength - eccLength + (i < shortBlocks ? 0 : 1));
|
|
70
|
+
k += block.length;
|
|
71
|
+
const ecc = rsRemainder(block, divisor);
|
|
72
|
+
if (i < shortBlocks)
|
|
73
|
+
block.push(0);
|
|
74
|
+
split.push(block.concat(ecc));
|
|
75
|
+
}
|
|
76
|
+
const result = [];
|
|
77
|
+
for (let i = 0; i < split[0].length; i += 1) {
|
|
78
|
+
split.forEach((block, j) => {
|
|
79
|
+
if (i !== shortLength - eccLength || j >= shortBlocks)
|
|
80
|
+
result.push(block[i]);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
function rsMultiply(x, y) {
|
|
86
|
+
let z = 0;
|
|
87
|
+
for (let i = 7; i >= 0; i -= 1) {
|
|
88
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
89
|
+
z ^= ((y >>> i) & 1) * x;
|
|
90
|
+
}
|
|
91
|
+
return z;
|
|
92
|
+
}
|
|
93
|
+
function rsDivisor(degree) {
|
|
94
|
+
const result = new Array(degree - 1).fill(0).concat([1]);
|
|
95
|
+
let root = 1;
|
|
96
|
+
for (let i = 0; i < degree; i += 1) {
|
|
97
|
+
for (let j = 0; j < result.length; j += 1) {
|
|
98
|
+
result[j] = rsMultiply(result[j], root);
|
|
99
|
+
if (j + 1 < result.length)
|
|
100
|
+
result[j] = result[j] ^ result[j + 1];
|
|
101
|
+
}
|
|
102
|
+
root = rsMultiply(root, 0x02);
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function rsRemainder(data, divisor) {
|
|
107
|
+
const result = divisor.map(() => 0);
|
|
108
|
+
for (const byte of data) {
|
|
109
|
+
const factor = byte ^ result.shift();
|
|
110
|
+
result.push(0);
|
|
111
|
+
divisor.forEach((coefficient, i) => {
|
|
112
|
+
result[i] = result[i] ^ rsMultiply(coefficient, factor);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
const bit = (value, i) => ((value >>> i) & 1) !== 0;
|
|
118
|
+
class Grid {
|
|
119
|
+
#size;
|
|
120
|
+
#version;
|
|
121
|
+
#modules;
|
|
122
|
+
#fixed;
|
|
123
|
+
constructor(version) {
|
|
124
|
+
this.#version = version;
|
|
125
|
+
this.#size = version * 4 + 17;
|
|
126
|
+
this.#modules = Array.from({ length: this.#size }, () => new Array(this.#size).fill(false));
|
|
127
|
+
this.#fixed = Array.from({ length: this.#size }, () => new Array(this.#size).fill(false));
|
|
128
|
+
}
|
|
129
|
+
draw(codewords) {
|
|
130
|
+
this.#functionPatterns();
|
|
131
|
+
this.#codewords(codewords);
|
|
132
|
+
let best = 0;
|
|
133
|
+
let lowest = Infinity;
|
|
134
|
+
for (let mask = 0; mask < 8; mask += 1) {
|
|
135
|
+
this.#mask(mask);
|
|
136
|
+
this.#format(mask);
|
|
137
|
+
const penalty = this.#penalty();
|
|
138
|
+
if (penalty < lowest) {
|
|
139
|
+
best = mask;
|
|
140
|
+
lowest = penalty;
|
|
141
|
+
}
|
|
142
|
+
this.#mask(mask);
|
|
143
|
+
}
|
|
144
|
+
this.#mask(best);
|
|
145
|
+
this.#format(best);
|
|
146
|
+
return { size: this.#size, modules: this.#modules.map((row) => [...row]) };
|
|
147
|
+
}
|
|
148
|
+
#set(x, y, dark) {
|
|
149
|
+
;
|
|
150
|
+
this.#modules[y][x] = dark;
|
|
151
|
+
this.#fixed[y][x] = true;
|
|
152
|
+
}
|
|
153
|
+
#functionPatterns() {
|
|
154
|
+
const size = this.#size;
|
|
155
|
+
for (let i = 0; i < size; i += 1) {
|
|
156
|
+
this.#set(6, i, i % 2 === 0);
|
|
157
|
+
this.#set(i, 6, i % 2 === 0);
|
|
158
|
+
}
|
|
159
|
+
this.#finder(3, 3);
|
|
160
|
+
this.#finder(size - 4, 3);
|
|
161
|
+
this.#finder(3, size - 4);
|
|
162
|
+
const positions = this.#alignmentPositions();
|
|
163
|
+
const last = positions.length - 1;
|
|
164
|
+
positions.forEach((x, i) => {
|
|
165
|
+
positions.forEach((y, j) => {
|
|
166
|
+
if (!((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)))
|
|
167
|
+
this.#alignment(x, y);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
this.#format(0);
|
|
171
|
+
this.#versionInfo();
|
|
172
|
+
}
|
|
173
|
+
#finder(x, y) {
|
|
174
|
+
for (let dy = -4; dy <= 4; dy += 1) {
|
|
175
|
+
for (let dx = -4; dx <= 4; dx += 1) {
|
|
176
|
+
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
|
177
|
+
const xx = x + dx;
|
|
178
|
+
const yy = y + dy;
|
|
179
|
+
if (xx >= 0 && xx < this.#size && yy >= 0 && yy < this.#size)
|
|
180
|
+
this.#set(xx, yy, distance !== 2 && distance !== 4);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
#alignment(x, y) {
|
|
185
|
+
for (let dy = -2; dy <= 2; dy += 1)
|
|
186
|
+
for (let dx = -2; dx <= 2; dx += 1)
|
|
187
|
+
this.#set(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
188
|
+
}
|
|
189
|
+
#alignmentPositions() {
|
|
190
|
+
if (this.#version === 1)
|
|
191
|
+
return [];
|
|
192
|
+
const count = Math.floor(this.#version / 7) + 2;
|
|
193
|
+
const step = Math.floor((this.#version * 8 + count * 3 + 5) / (count * 4 - 4)) * 2;
|
|
194
|
+
const result = [6];
|
|
195
|
+
for (let position = this.#size - 7; result.length < count; position -= step)
|
|
196
|
+
result.splice(1, 0, position);
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
#format(mask) {
|
|
200
|
+
const data = (FORMAT_BITS_M << 3) | mask;
|
|
201
|
+
let remainder = data;
|
|
202
|
+
for (let i = 0; i < 10; i += 1)
|
|
203
|
+
remainder = (remainder << 1) ^ ((remainder >>> 9) * 0x537);
|
|
204
|
+
const bits = ((data << 10) | remainder) ^ 0x5412;
|
|
205
|
+
const size = this.#size;
|
|
206
|
+
for (let i = 0; i <= 5; i += 1)
|
|
207
|
+
this.#set(8, i, bit(bits, i));
|
|
208
|
+
this.#set(8, 7, bit(bits, 6));
|
|
209
|
+
this.#set(8, 8, bit(bits, 7));
|
|
210
|
+
this.#set(7, 8, bit(bits, 8));
|
|
211
|
+
for (let i = 9; i < 15; i += 1)
|
|
212
|
+
this.#set(14 - i, 8, bit(bits, i));
|
|
213
|
+
for (let i = 0; i < 8; i += 1)
|
|
214
|
+
this.#set(size - 1 - i, 8, bit(bits, i));
|
|
215
|
+
for (let i = 8; i < 15; i += 1)
|
|
216
|
+
this.#set(8, size - 15 + i, bit(bits, i));
|
|
217
|
+
this.#set(8, size - 8, true);
|
|
218
|
+
}
|
|
219
|
+
#versionInfo() {
|
|
220
|
+
if (this.#version < 7)
|
|
221
|
+
return;
|
|
222
|
+
let remainder = this.#version;
|
|
223
|
+
for (let i = 0; i < 12; i += 1)
|
|
224
|
+
remainder = (remainder << 1) ^ ((remainder >>> 11) * 0x1f25);
|
|
225
|
+
const bits = (this.#version << 12) | remainder;
|
|
226
|
+
for (let i = 0; i < 18; i += 1) {
|
|
227
|
+
const a = this.#size - 11 + (i % 3);
|
|
228
|
+
const b = Math.floor(i / 3);
|
|
229
|
+
this.#set(a, b, bit(bits, i));
|
|
230
|
+
this.#set(b, a, bit(bits, i));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
#codewords(data) {
|
|
234
|
+
const size = this.#size;
|
|
235
|
+
let i = 0;
|
|
236
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
237
|
+
if (right === 6)
|
|
238
|
+
right = 5;
|
|
239
|
+
for (let vertical = 0; vertical < size; vertical += 1) {
|
|
240
|
+
for (let j = 0; j < 2; j += 1) {
|
|
241
|
+
const x = right - j;
|
|
242
|
+
const upward = ((right + 1) & 2) === 0;
|
|
243
|
+
const y = upward ? size - 1 - vertical : vertical;
|
|
244
|
+
if (!this.#fixed[y][x] && i < data.length * 8) {
|
|
245
|
+
;
|
|
246
|
+
this.#modules[y][x] = bit(data[i >>> 3], 7 - (i & 7));
|
|
247
|
+
i += 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Flips the data modules under `mask`; applying it twice undoes it. */
|
|
254
|
+
#mask(mask) {
|
|
255
|
+
for (let y = 0; y < this.#size; y += 1) {
|
|
256
|
+
for (let x = 0; x < this.#size; x += 1) {
|
|
257
|
+
const flip = [(x + y) % 2 === 0, y % 2 === 0, x % 3 === 0, (x + y) % 3 === 0, (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, ((x * y) % 2) + ((x * y) % 3) === 0, (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (((x + y) % 2) + ((x * y) % 3)) % 2 === 0][mask];
|
|
258
|
+
const row = this.#modules[y];
|
|
259
|
+
if (flip === true && !this.#fixed[y][x])
|
|
260
|
+
row[x] = !row[x];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
#penalty() {
|
|
265
|
+
const size = this.#size;
|
|
266
|
+
const at = (x, y) => this.#modules[y][x];
|
|
267
|
+
let result = 0;
|
|
268
|
+
for (const across of [true, false]) {
|
|
269
|
+
for (let a = 0; a < size; a += 1) {
|
|
270
|
+
let color = false;
|
|
271
|
+
let run = 0;
|
|
272
|
+
const history = [0, 0, 0, 0, 0, 0, 0];
|
|
273
|
+
for (let b = 0; b < size; b += 1) {
|
|
274
|
+
const dark = across ? at(b, a) : at(a, b);
|
|
275
|
+
if (dark === color) {
|
|
276
|
+
run += 1;
|
|
277
|
+
if (run === 5)
|
|
278
|
+
result += PENALTY_N1;
|
|
279
|
+
else if (run > 5)
|
|
280
|
+
result += 1;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
this.#history(run, history);
|
|
284
|
+
if (!color)
|
|
285
|
+
result += this.#finderLike(history) * PENALTY_N3;
|
|
286
|
+
color = dark;
|
|
287
|
+
run = 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (color) {
|
|
291
|
+
this.#history(run, history);
|
|
292
|
+
run = 0;
|
|
293
|
+
}
|
|
294
|
+
this.#history(run + size, history);
|
|
295
|
+
result += this.#finderLike(history) * PENALTY_N3;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (let y = 0; y < size - 1; y += 1) {
|
|
299
|
+
for (let x = 0; x < size - 1; x += 1) {
|
|
300
|
+
const color = at(x, y);
|
|
301
|
+
if (color === at(x + 1, y) && color === at(x, y + 1) && color === at(x + 1, y + 1))
|
|
302
|
+
result += PENALTY_N2;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const dark = this.#modules.reduce((sum, row) => sum + row.filter(Boolean).length, 0);
|
|
306
|
+
const total = size * size;
|
|
307
|
+
result += (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * PENALTY_N4;
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
#history(run, history) {
|
|
311
|
+
history.pop();
|
|
312
|
+
history.unshift(history[0] === 0 ? run + this.#size : run);
|
|
313
|
+
}
|
|
314
|
+
#finderLike(history) {
|
|
315
|
+
const [h0, n, h2, h3, h4, h5, h6] = history;
|
|
316
|
+
const core = n > 0 && h2 === n && h3 === n * 3 && h4 === n && h5 === n;
|
|
317
|
+
return (core && h0 >= n * 4 && h6 >= n ? 1 : 0) + (core && h6 >= n * 4 && h0 >= n ? 1 : 0);
|
|
318
|
+
}
|
|
319
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -16,7 +16,15 @@ export interface PlatformInfo {
|
|
|
16
16
|
connectable: boolean;
|
|
17
17
|
/** The connect variants set up here, such as LinkedIn's `pages`. Optional: an API older than the field leaves it out. */
|
|
18
18
|
variants?: string[] | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* How a connect ends: `consent`, a sign-in page that comes back; `approval` (Farcaster), a link that opens the
|
|
21
|
+
* platform's phone app to approve, after which the session finishes when read. Optional: an API older than the field
|
|
22
|
+
* leaves it out, which reads as `consent`.
|
|
23
|
+
*/
|
|
24
|
+
connectFlow?: ConnectFlow | undefined;
|
|
19
25
|
}
|
|
26
|
+
/** How a platform's connect ends: a sign-in page that sends the person back, or an approval in the platform's phone app. */
|
|
27
|
+
export type ConnectFlow = 'consent' | 'approval';
|
|
20
28
|
export type AccountStatus = 'active' | 'needs_reconnect' | 'disconnected';
|
|
21
29
|
/** A connected social account, as `GET /v1/accounts` returns it. */
|
|
22
30
|
export interface Account {
|
package/dist/element/dom.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export declare function mark(path: string, options?: {
|
|
|
30
30
|
part?: string;
|
|
31
31
|
size?: number;
|
|
32
32
|
}): SVGSVGElement;
|
|
33
|
+
/**
|
|
34
|
+
* A QR code of `text` as one SVG path of dark modules on a light square, named `label` for a screen reader. It stays dark
|
|
35
|
+
* on light in a dark theme too, as cameras need; `--flung-qr-size` sets its size.
|
|
36
|
+
*/
|
|
37
|
+
export declare function qrCode(text: string, label: string): SVGSVGElement;
|
|
33
38
|
/**
|
|
34
39
|
* An account's picture: an `<img>` that sends no referrer, loads lazily and has an empty `alt` (the name is beside it),
|
|
35
40
|
* or a circle with the account's initial when there is no picture or it does not load. `avatarUrl` is only ever an
|
package/dist/element/dom.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* supplies (an account name, an error message) can become markup. Attributes go through `setAttribute`, text through
|
|
4
4
|
* `textContent`.
|
|
5
5
|
*/
|
|
6
|
+
import { encodeQr } from '../core/qr.js';
|
|
6
7
|
/** An HTML element of the element's own. `true` sets an empty attribute; `false` and `undefined` set none. */
|
|
7
8
|
export function html(tag, attributes = {}, ...children) {
|
|
8
9
|
const element = document.createElement(tag);
|
|
@@ -57,6 +58,42 @@ export function mark(path, options = {}) {
|
|
|
57
58
|
svg.append(shape);
|
|
58
59
|
return svg;
|
|
59
60
|
}
|
|
61
|
+
/** The light modules kept around a QR code so a camera finds its edges (the standard asks for four; three reads well on a screen). */
|
|
62
|
+
const QR_QUIET = 3;
|
|
63
|
+
/**
|
|
64
|
+
* A QR code of `text` as one SVG path of dark modules on a light square, named `label` for a screen reader. It stays dark
|
|
65
|
+
* on light in a dark theme too, as cameras need; `--flung-qr-size` sets its size.
|
|
66
|
+
*/
|
|
67
|
+
export function qrCode(text, label) {
|
|
68
|
+
const code = encodeQr(text);
|
|
69
|
+
const size = code.size + QR_QUIET * 2;
|
|
70
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
71
|
+
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
|
|
72
|
+
svg.setAttribute('class', 'qr');
|
|
73
|
+
svg.setAttribute('part', 'qr');
|
|
74
|
+
svg.setAttribute('role', 'img');
|
|
75
|
+
svg.setAttribute('aria-label', label);
|
|
76
|
+
svg.setAttribute('shape-rendering', 'crispEdges');
|
|
77
|
+
// Presentation attributes, as `mark` sets: an unstyled panel still draws a code a camera reads.
|
|
78
|
+
svg.setAttribute('width', '132');
|
|
79
|
+
svg.setAttribute('height', '132');
|
|
80
|
+
const ground = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
81
|
+
ground.setAttribute('width', String(size));
|
|
82
|
+
ground.setAttribute('height', String(size));
|
|
83
|
+
ground.setAttribute('fill', '#ffffff');
|
|
84
|
+
const modules = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
85
|
+
const runs = [];
|
|
86
|
+
code.modules.forEach((row, y) => {
|
|
87
|
+
row.forEach((dark, x) => {
|
|
88
|
+
if (dark)
|
|
89
|
+
runs.push(`M${x + QR_QUIET} ${y + QR_QUIET}h1v1h-1z`);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
modules.setAttribute('d', runs.join(''));
|
|
93
|
+
modules.setAttribute('fill', '#000000');
|
|
94
|
+
svg.append(ground, modules);
|
|
95
|
+
return svg;
|
|
96
|
+
}
|
|
60
97
|
/**
|
|
61
98
|
* An account's picture: an `<img>` that sends no referrer, loads lazily and has an empty `alt` (the name is beside it),
|
|
62
99
|
* or a circle with the account's initial when there is no picture or it does not load. `avatarUrl` is only ever an
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createConnectController, initialConnectState } from '../core/controller.js';
|
|
2
2
|
import { DEFAULT_LABELS, formatLabel as format } from '../core/labels.js';
|
|
3
3
|
import { parsePlatformList } from '../core/platforms.js';
|
|
4
|
-
import { avatar, button, html, mark } from './dom.js';
|
|
4
|
+
import { avatar, button, html, mark, qrCode } from './dom.js';
|
|
5
5
|
import { CHOICE_MARKS, FLUNG_MARK, PLATFORM_MARKS } from './marks.js';
|
|
6
6
|
import { BASE_STYLES, STYLES } from './styles.js';
|
|
7
7
|
import { panelTooltip, TOOLTIP_BASE_STYLES, TOOLTIP_STYLES } from './tooltip.js';
|
|
@@ -12,13 +12,22 @@ const LAYOUTS = ['list', 'grid', 'compact'];
|
|
|
12
12
|
*/
|
|
13
13
|
function stepTile(item, tiles) {
|
|
14
14
|
const { step } = item;
|
|
15
|
-
const accountId = step.kind === 'confirm' ? step.account.account.id : step.kind === 'waiting' ? step.accountId : null;
|
|
15
|
+
const accountId = step.kind === 'confirm' ? step.account.account.id : step.kind === 'waiting' || step.kind === 'approve' ? step.accountId : null;
|
|
16
16
|
if (accountId !== null)
|
|
17
17
|
return tiles.findIndex((tile) => tile.accounts.some((account) => account.account.id === accountId));
|
|
18
|
-
if (step.kind === 'waiting')
|
|
18
|
+
if (step.kind === 'waiting' || step.kind === 'approve')
|
|
19
19
|
return tiles.findIndex((tile) => tile.variant === step.variant);
|
|
20
20
|
return 0;
|
|
21
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
|
+
}
|
|
22
31
|
/** The key of a tile's Connect button, so focus can come back to it. */
|
|
23
32
|
const connectKey = (platform, variant) => (variant === null ? `connect-${platform}` : `connect-${platform}-${variant}`);
|
|
24
33
|
/** Builds the class on first use, so importing the package on a server, where there is no `HTMLElement`, is safe. */
|
|
@@ -139,11 +148,15 @@ export function createFlungConnectClass() {
|
|
|
139
148
|
const fallback = [];
|
|
140
149
|
for (const item of state.platforms) {
|
|
141
150
|
const before = this.#steps.get(item.platform) ?? { kind: 'idle', variant: null };
|
|
142
|
-
const now = item.step
|
|
151
|
+
const now = focusKind(item.step);
|
|
143
152
|
if (now === before.kind)
|
|
144
153
|
continue;
|
|
145
154
|
if (now === 'handle')
|
|
146
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}`;
|
|
147
160
|
else if (now === 'choose')
|
|
148
161
|
wanted = `choose-${item.platform}-personal`;
|
|
149
162
|
else if (now === 'confirm')
|
|
@@ -152,7 +165,7 @@ export function createFlungConnectClass() {
|
|
|
152
165
|
else if (now === 'idle')
|
|
153
166
|
fallback.push(connectKey(item.platform, before.variant), connectKey(item.platform, null));
|
|
154
167
|
}
|
|
155
|
-
this.#steps = new Map(state.platforms.map((item) => [item.platform, { kind: item.step
|
|
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 }]));
|
|
156
169
|
const find = (key) => (key === null ? undefined : [...this.#root.querySelectorAll('[data-key]')].find((node) => node.dataset['key'] === key));
|
|
157
170
|
(find(wanted) ?? (active !== null ? fallback.map(find).find((node) => node !== undefined) : undefined))?.focus();
|
|
158
171
|
}
|
|
@@ -238,11 +251,32 @@ export function createFlungConnectClass() {
|
|
|
238
251
|
const text = step.blockedUrl === null ? format(labels.waiting, { platform: name }) : `${format(labels.blocked, { platform: name })} `;
|
|
239
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)));
|
|
240
253
|
}
|
|
254
|
+
if (step.kind === 'approve')
|
|
255
|
+
return this.#approval(item, step, labels);
|
|
241
256
|
if (step.kind === 'confirm') {
|
|
242
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)));
|
|
243
258
|
}
|
|
244
259
|
return null;
|
|
245
260
|
}
|
|
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));
|
|
279
|
+
}
|
|
246
280
|
/**
|
|
247
281
|
* What to connect, when a platform offers its own connect and a variant: a group of two buttons, each with an icon,
|
|
248
282
|
* a name and a line under it (read as the button's description). Arrow keys move between them, Escape closes the
|
package/dist/element/styles.js
CHANGED
|
@@ -84,6 +84,7 @@ export const STYLES = `
|
|
|
84
84
|
--flung-focus-width: 2px;
|
|
85
85
|
--flung-focus-offset: 2px;
|
|
86
86
|
--flung-grid-min: 16rem;
|
|
87
|
+
--flung-qr-size: 132px;
|
|
87
88
|
display: block;
|
|
88
89
|
font: var(--flung-font-size)/var(--flung-line-height) var(--flung-font);
|
|
89
90
|
color: var(--flung-text);
|
|
@@ -167,6 +168,21 @@ button.option:hover:not(:disabled) { background: var(--flung-surface); border-co
|
|
|
167
168
|
.option-text { min-width: 0; display: flex; flex-direction: column; }
|
|
168
169
|
.option-name { font-weight: var(--flung-font-weight-strong); line-height: 1.3; }
|
|
169
170
|
.option-description { font-size: var(--flung-font-size-small); font-weight: normal; color: var(--flung-muted); line-height: 1.3; }
|
|
171
|
+
.approve { margin: 10px 0 0 calc(var(--flung-badge-size) + var(--flung-gap)); padding: var(--flung-gap); border-radius: var(--flung-radius-inner); background: var(--flung-raised); display: flex; flex-direction: column; gap: 10px; }
|
|
172
|
+
.approve-text { margin: 0; font-size: calc(var(--flung-font-size) - 1px); }
|
|
173
|
+
.approve.ended .approve-text { color: var(--flung-danger); }
|
|
174
|
+
.approve-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
175
|
+
.approve-go { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
|
|
176
|
+
.approve-open {
|
|
177
|
+
display: inline-flex; align-items: center; height: var(--flung-button-height); padding: var(--flung-button-padding); border-radius: var(--flung-button-radius);
|
|
178
|
+
font-size: calc(var(--flung-font-size) - 1px); font-weight: var(--flung-font-weight-medium); text-decoration: none; white-space: nowrap;
|
|
179
|
+
background: var(--flung-accent); color: var(--flung-accent-text); border: var(--flung-border-width) solid var(--flung-accent);
|
|
180
|
+
}
|
|
181
|
+
.approve-open:hover { background: color-mix(in srgb, var(--flung-accent) 88%, var(--flung-surface)); }
|
|
182
|
+
.approve-scan { display: flex; align-items: center; gap: var(--flung-gap); }
|
|
183
|
+
.qr { flex: none; width: var(--flung-qr-size); height: var(--flung-qr-size); display: block; border-radius: var(--flung-radius-inner); border: var(--flung-border-width) solid var(--flung-border); }
|
|
184
|
+
.approve-hint { margin: 0; font-size: var(--flung-font-size-small); color: var(--flung-muted); max-width: 16rem; }
|
|
185
|
+
.approve .note { margin: 0; }
|
|
170
186
|
.form label { flex-basis: 100%; font-size: var(--flung-font-size-small); color: var(--flung-muted); }
|
|
171
187
|
.form input { flex: 1 1 12rem; min-width: 0; height: var(--flung-button-height); font: inherit; font-size: calc(var(--flung-font-size) - 1px); color: inherit; background: var(--flung-surface); border: var(--flung-border-width) solid var(--flung-border); border-radius: var(--flung-button-radius); padding: 0 10px; }
|
|
172
188
|
button {
|
|
@@ -196,7 +212,7 @@ button:focus-visible, input:focus-visible, a:focus-visible { outline: var(--flun
|
|
|
196
212
|
.footer .flung { width: 14px; height: 14px; fill: currentColor; display: block; }
|
|
197
213
|
.panel[data-layout="grid"] .list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--flung-grid-min)), 1fr)); gap: var(--flung-gap); padding: var(--flung-padding); }
|
|
198
214
|
.panel[data-layout="grid"] .platform, .panel[data-layout="grid"] .platform:first-child { border: var(--flung-border-width) solid var(--flung-border); border-radius: var(--flung-radius-inner); box-shadow: var(--flung-card-shadow); padding: var(--flung-gap); }
|
|
199
|
-
.panel[data-layout="grid"] .accounts, .panel[data-layout="grid"] .note, .panel[data-layout="grid"] .confirm, .panel[data-layout="grid"] .form, .panel[data-layout="grid"] .choose { margin-left: 0; }
|
|
215
|
+
.panel[data-layout="grid"] .accounts, .panel[data-layout="grid"] .note, .panel[data-layout="grid"] .confirm, .panel[data-layout="grid"] .form, .panel[data-layout="grid"] .choose, .panel[data-layout="grid"] .approve { margin-left: 0; }
|
|
200
216
|
.panel[data-layout="grid"] .accounts { border-left: 0; border-top: var(--flung-border-width) solid var(--flung-border); margin-top: var(--flung-gap); padding-top: 4px; }
|
|
201
217
|
.panel[data-layout="grid"] .account { padding-left: 0; }
|
|
202
218
|
.panel[data-layout="compact"] .count { display: none; }
|
|
@@ -204,7 +220,8 @@ button:focus-visible, input:focus-visible, a:focus-visible { outline: var(--flun
|
|
|
204
220
|
.panel[data-layout="compact"] .account { padding-top: 3px; padding-bottom: 3px; }
|
|
205
221
|
@media (max-width: 420px) {
|
|
206
222
|
.platform { padding: var(--flung-gap); }
|
|
207
|
-
.accounts, .note, .confirm, .form, .choose { margin-left: 0; }
|
|
223
|
+
.accounts, .note, .confirm, .form, .choose, .approve { margin-left: 0; }
|
|
224
|
+
.approve-scan { display: none; }
|
|
208
225
|
.accounts { border-left: 0; border-top: var(--flung-border-width) solid var(--flung-border); margin-top: 10px; }
|
|
209
226
|
.account { padding-left: 0; }
|
|
210
227
|
.row button { padding: 0 12px; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flung.dev/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|