@timmo001/groundwork-mobile 0.13.0 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-mobile",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "The page side of Groundwork's Android shell: the app config, the typed bridge to the shell, and a fallback to the PWA outside it",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -33,8 +33,8 @@
33
33
  "format:check": "prettier --check ."
34
34
  },
35
35
  "dependencies": {
36
- "@timmo001/groundwork-pwa": "0.13.0",
37
- "@timmo001/groundwork-tokens": "0.13.0",
36
+ "@timmo001/groundwork-pwa": "0.15.0",
37
+ "@timmo001/groundwork-tokens": "0.15.0",
38
38
  "effect": "4.1.2"
39
39
  },
40
40
  "devDependencies": {
package/src/bridge.ts CHANGED
@@ -26,13 +26,43 @@ export const Notice = Schema.Struct({
26
26
 
27
27
  export interface Notice extends Schema.Schema.Type<typeof Notice> {}
28
28
 
29
- /** A share from another app, the same shape the PWA's share target gives. */
29
+ /**
30
+ * A share from another app, the same shape the PWA's share target gives,
31
+ * plus the id of the sharing shortcut it was sent to, in the shell.
32
+ */
30
33
  export const ShellShare = Schema.Struct({
31
34
  title: Schema.optionalKey(Schema.String),
32
35
  text: Schema.optionalKey(Schema.String),
33
36
  url: Schema.optionalKey(Schema.String),
37
+ shortcutId: Schema.optionalKey(Schema.String),
38
+ });
39
+
40
+ export interface ShellShare extends Schema.Schema.Type<typeof ShellShare> {}
41
+
42
+ /**
43
+ * A launcher shortcut that opens `url` as a deep link: on a long press of
44
+ * the app's icon, pinned to the home screen, and with `share` in the share
45
+ * sheet's direct row too.
46
+ */
47
+ export const Shortcut = Schema.Struct({
48
+ /** Stays the same across updates, so a pinned one keeps working. */
49
+ id: Schema.String,
50
+ label: Schema.String,
51
+ /** Shown where there's room, such as in the long-press menu. */
52
+ longLabel: Schema.optionalKey(Schema.String),
53
+ /** An allowed origin's page, or the app's own scheme. */
54
+ url: Schema.String,
55
+ /** An SVG path on a 24 unit grid, such as a Material Design Icon. */
56
+ icon: Schema.optionalKey(Schema.String),
57
+ /**
58
+ * A sharing shortcut, whose shares carry its id as `shortcutId`. It shows
59
+ * on a long press as well, so list it after the ones meant for that.
60
+ */
61
+ share: Schema.optionalKey(Schema.Boolean),
34
62
  });
35
63
 
64
+ export interface Shortcut extends Schema.Schema.Type<typeof Shortcut> {}
65
+
36
66
  export const DeepLink = Schema.Struct({ url: Schema.String });
37
67
 
38
68
  export interface DeepLink extends Schema.Schema.Type<typeof DeepLink> {}
@@ -145,6 +175,14 @@ export const mobileRequests = {
145
175
  discoverServices: Schema.Array(DiscoveredService),
146
176
  /** Scans a QR code with the camera: its text, or `null` when cancelled. */
147
177
  scanQr: Schema.NullOr(Schema.String),
178
+ /** Replaces the dynamic shortcuts: how many the launcher took. */
179
+ setShortcuts: Schema.Number,
180
+ /** Asks the launcher to pin a shortcut: false where it can't. */
181
+ pinShortcut: Schema.Boolean,
182
+ /** Removes shortcuts, greying out pinned ones. */
183
+ disableShortcuts: Schema.Boolean,
184
+ /** Saves a file the page made to Downloads, from `{ name, type, data }` in base64. */
185
+ saveFile: Schema.Boolean,
148
186
  } as const;
149
187
 
150
188
  /** The shell's messages, each with the schema its data is read with. */
package/src/config.ts CHANGED
@@ -12,6 +12,8 @@ export interface MobileLabels {
12
12
  readonly ongoing?: string;
13
13
  /** Defaults to `Download finished`. */
14
14
  readonly downloadFinished?: string;
15
+ /** Shown once a file the page made is saved. Defaults to `Saved to Downloads`. */
16
+ readonly fileSaved?: string;
15
17
  }
16
18
 
17
19
  export type ResolvedMobileLabels = Required<MobileLabels>;
@@ -104,6 +106,7 @@ export const resolveConfig = (
104
106
  notifications: "Notifications",
105
107
  ongoing: "Ongoing",
106
108
  downloadFinished: "Download finished",
109
+ fileSaved: "Saved to Downloads",
107
110
  ...config.labels,
108
111
  },
109
112
  });
package/src/view.ts CHANGED
@@ -23,7 +23,6 @@ import {
23
23
  unsubscribePush,
24
24
  type Pwa,
25
25
  type PwaConfig,
26
- type Share,
27
26
  } from "@timmo001/groundwork-pwa/view";
28
27
  import { Option, Schema } from "effect";
29
28
 
@@ -35,6 +34,7 @@ import {
35
34
  mobileRequests,
36
35
  Notice,
37
36
  ShellShare,
37
+ Shortcut,
38
38
  type BridgeMessage,
39
39
  type DiscoveredService,
40
40
  type DiscoveryMode,
@@ -77,8 +77,11 @@ export interface MobilePushOptions {
77
77
  export interface ConnectMobileOptions {
78
78
  /** The PWA config, for the floor's service worker, install and share target. */
79
79
  readonly pwa?: PwaConfig;
80
- /** Called with each share, from the shell or the share target. */
81
- readonly onShare?: (share: Share) => void;
80
+ /**
81
+ * Called with each share, from the shell or the share target, with the
82
+ * sharing shortcut's id when it was sent to one.
83
+ */
84
+ readonly onShare?: (share: ShellShare) => void;
82
85
  /** Called with each deep link the shell opens the app with. */
83
86
  readonly onDeepLink?: (url: string) => void;
84
87
  /**
@@ -146,8 +149,72 @@ export interface Mobile {
146
149
  readonly scanQr: (options?: {
147
150
  readonly hint?: string;
148
151
  }) => Promise<string | undefined>;
152
+ /**
153
+ * Replaces the dynamic shortcuts with these, in order, and answers how
154
+ * many the launcher took. Answers 0 outside the shell, where the PWA
155
+ * manifest's `shortcuts` give the long-press menu.
156
+ */
157
+ readonly setShortcuts: (shortcuts: readonly Shortcut[]) => Promise<number>;
158
+ /**
159
+ * Asks the launcher to pin a shortcut to the home screen. False where it
160
+ * can't, and outside the shell.
161
+ */
162
+ readonly pinShortcut: (shortcut: Shortcut) => Promise<boolean>;
163
+ /**
164
+ * Removes the shortcuts with these ids. Pinned ones stay, greyed out,
165
+ * showing `message` when tapped. Does nothing outside the shell.
166
+ */
167
+ readonly disableShortcuts: (
168
+ ids: readonly string[],
169
+ message?: string,
170
+ ) => Promise<void>;
171
+ /**
172
+ * Saves a file the page made, such as an export: to the phone's Downloads
173
+ * in the shell, through the browser's download outside it.
174
+ */
175
+ readonly saveFile: (file: SavedFile) => Promise<void>;
149
176
  }
150
177
 
178
+ /** A file for `saveFile`: its name, its type and what's in it. */
179
+ export interface SavedFile {
180
+ readonly name: string;
181
+ readonly type: string;
182
+ readonly contents: string | Blob;
183
+ }
184
+
185
+ const blobOf = ({ type, contents }: SavedFile) =>
186
+ contents instanceof Blob ? contents : new Blob([contents], { type });
187
+
188
+ /** Saves a file through the browser's download. */
189
+ const downloadFile = (file: SavedFile) => {
190
+ const url = URL.createObjectURL(blobOf(file));
191
+ const link = document.createElement("a");
192
+
193
+ link.href = url;
194
+ link.download = file.name;
195
+ link.click();
196
+ URL.revokeObjectURL(url);
197
+ };
198
+
199
+ const decodeText = Schema.decodeUnknownOption(Schema.String);
200
+
201
+ /** A blob's bytes in base64, for the bridge's JSON. */
202
+ const base64Of = (blob: Blob) =>
203
+ new Promise<string>((resolve, reject) => {
204
+ const reader = new FileReader();
205
+
206
+ // readAsDataURL gives data:<type>;base64,<data>
207
+ reader.addEventListener("load", () => {
208
+ const url = Option.getOrElse(decodeText(reader.result), () => "");
209
+
210
+ resolve(url.split(",")[1] ?? "");
211
+ });
212
+ reader.addEventListener("error", () =>
213
+ reject(reader.error ?? new Error("Couldn't read the file")),
214
+ );
215
+ reader.readAsDataURL(blob);
216
+ });
217
+
151
218
  interface Waiting {
152
219
  readonly resolve: (value: Schema.Json) => void;
153
220
  readonly reject: (error: Error) => void;
@@ -171,13 +238,18 @@ const read = <S extends Schema.ConstraintDecoder<unknown>>(
171
238
 
172
239
  const noticeJson = Schema.encodeSync(Schema.toCodecJson(Notice));
173
240
 
241
+ const shortcutJson = Schema.encodeSync(Schema.toCodecJson(Shortcut));
242
+
243
+ /** The `disableShortcuts` request's params. */
244
+ type DisableParams = { ids: string[]; message?: string };
245
+
174
246
  const pushParams = ({ service, client, headers = {} }: MobilePushOptions) => ({
175
247
  service,
176
248
  client: { id: client.id, name: client.name, platform: client.platform },
177
249
  headers: { ...headers },
178
250
  });
179
251
 
180
- const dispatchShare = (share: Share) =>
252
+ const dispatchShare = (share: ShellShare) =>
181
253
  window.dispatchEvent(new CustomEvent("gw-share", { detail: share }));
182
254
 
183
255
  const dispatchDeepLink = (link: DeepLink) =>
@@ -240,6 +312,10 @@ const connectFloor = (
240
312
  scanQr: async () => {
241
313
  throw new Error("The QR scanner needs the Android shell");
242
314
  },
315
+ setShortcuts: async () => 0,
316
+ pinShortcut: async () => false,
317
+ disableShortcuts: async () => {},
318
+ saveFile: async (file) => downloadFile(file),
243
319
  };
244
320
  };
245
321
 
@@ -395,5 +471,27 @@ export const connectMobile = async (
395
471
 
396
472
  return (await call("scanQr", mobileRequests.scanQr, params)) ?? undefined;
397
473
  },
474
+ setShortcuts: (shortcuts) =>
475
+ call("setShortcuts", mobileRequests.setShortcuts, {
476
+ shortcuts: shortcuts.map((shortcut) => shortcutJson(shortcut)),
477
+ }),
478
+ pinShortcut: (shortcut) =>
479
+ call("pinShortcut", mobileRequests.pinShortcut, shortcutJson(shortcut)),
480
+ disableShortcuts: async (ids, message) => {
481
+ const params: DisableParams = { ids: [...ids] };
482
+
483
+ if (message !== undefined) params["message"] = message;
484
+
485
+ await call("disableShortcuts", mobileRequests.disableShortcuts, params);
486
+ },
487
+ saveFile: async (file) => {
488
+ const blob = blobOf(file);
489
+
490
+ await call("saveFile", mobileRequests.saveFile, {
491
+ name: file.name,
492
+ type: blob.type || file.type,
493
+ data: await base64Of(blob),
494
+ });
495
+ },
398
496
  };
399
497
  };