@dinghy-chat/widget 0.0.0-stage → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dinghy contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,82 @@
1
- # Temporary Holding Version
1
+ # Dinghy widget
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The chat widget for [Dinghy](https://dinghy.chat), a small live support chat you can host yourself. MIT licensed.
4
+
5
+ ## Add it with a tag
6
+
7
+ ```html
8
+ <script src="https://chat.example.com/widget/loader.js" data-key="YOUR_PUBLIC_KEY" async></script>
9
+ ```
10
+
11
+ ## Or add it from npm
12
+
13
+ ```sh
14
+ npm install @dinghy-chat/widget
15
+ ```
16
+
17
+ ```ts
18
+ import { init } from "@dinghy-chat/widget";
19
+
20
+ const dinghy = init({ url: "https://chat.example.com", key: "YOUR_PUBLIC_KEY" });
21
+ dinghy.on("unread", (count) => console.log(count));
22
+ ```
23
+
24
+ `init` adds the tag once and returns the typed `Dinghy` object. Calls made before the script loads wait for it. It takes `url`, `key`, and `primary`, `accent`, `mascot`, `launcher`, `position`, `offsetX`, `offsetY` and `zIndex`.
25
+
26
+ ## Attributes
27
+
28
+ | Attribute | Effect |
29
+ | ------------------------ | ------------------------------------------------------------------------------------------- |
30
+ | `data-primary="#0b2239"` | Sets the header, your visitor's messages and the boat's hull. Use six hex digits. |
31
+ | `data-accent="#e5352b"` | Sets the sail and the send button, and the launcher if the boat is off. Use six hex digits. |
32
+ | `data-mascot="false"` | Removes the boat. Every state keeps its text. |
33
+ | `data-launcher="hidden"` | Hides the launcher, so your own button opens the chat. |
34
+ | `data-position="left"` | Puts the launcher and the chat on the left. The default is `right`. |
35
+ | `data-offset-x="24"` | Pixels from the side of the window. The default is `16`. |
36
+ | `data-offset-y="80"` | Pixels from the bottom of the window. The default is `16`. |
37
+
38
+ The widget ignores a value in any other format. The boat stops moving for a visitor who asks for reduced motion.
39
+
40
+ Set `--dinghy-font`, `--dinghy-radius` and `--dinghy-launcher-radius` on the `dinghy-chat` element to match your fonts and corners.
41
+
42
+ ## Host page API
43
+
44
+ | Call | What it does |
45
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- |
46
+ | `Dinghy.open({ message })` | Opens the chat. `message` is optional and fills the message box without sending it |
47
+ | `Dinghy.close()`, `Dinghy.toggle()` | Closes the chat, or opens it if it is closed |
48
+ | `Dinghy.show()`, `Dinghy.hide()` | Shows or hides the launcher |
49
+ | `Dinghy.identify(token)` | Signs in a user. `token` is a string, or a function that returns a promise of one, so a tab can renew a token |
50
+ | `Dinghy.reset()` | Ends the session in all tabs. Call this at sign-out |
51
+ | `Dinghy.setContext(data)` | Unsigned facts for operators, such as a plan. Up to 20 keys, each a short string, a number, a boolean or `null` |
52
+ | `Dinghy.configure(options)` | Changes `primary`, `accent`, `position`, `offsetX`, `offsetY` and `zIndex` now |
53
+ | `Dinghy.on(event, callback)` | Calls `callback` on each event, and returns a function that removes it |
54
+ | `Dinghy.state` | `{ ready, open, unread }` |
55
+
56
+ The events are `ready`, `open`, `close`, `unread` (the count, at once and on each change), `message:sent` and `message:received` (`{ body }`) and `conversation:started`.
57
+
58
+ To call these before the loader finishes, queue them:
59
+
60
+ ```html
61
+ <script>window.Dinghy = { q: [["identify", "TOKEN"], ["on", "unread", console.log]] };</script>
62
+ ```
63
+
64
+ Your backend signs the token with HS256 and the workspace's signing secret. It needs `sub` (your user ID), `aud` (the public key), `iat`, and `exp` at most 24 hours later. `email`, `name` (one line, up to 100 characters) and `attributes` (an object up to 2 KB) are optional, and operators read them.
65
+
66
+ The widget stores nothing in the browser until the visitor sends a message, and then only its own token in `localStorage`. While such a visitor has the chat open, it notes that in `sessionStorage`, so the chat opens again after a reload in the same tab. Once the visitor has chatted, it reports the page's address, without its query string or `#` part, its title and the `setContext` data, so operators see where the visitor is.
67
+
68
+ ## Content Security Policy
69
+
70
+ If your site sets a CSP, add the origin of your Dinghy server to these directives:
71
+
72
+ ```
73
+ script-src https://chat.example.com;
74
+ connect-src https://chat.example.com;
75
+ img-src https://chat.example.com;
76
+ ```
77
+
78
+ If your Dinghy serves images from a separate origin (`IMAGES_ORIGIN`), allow that origin in `img-src` too.
79
+
80
+ The widget needs no `style-src` change, because it uses constructable stylesheets and the CSSOM.
81
+
82
+ The full guide is at <https://dinghy.chat/docs/add-to-your-site>.
@@ -0,0 +1,19 @@
1
+ import type { Appearance, DinghyApi } from "./shared.ts";
2
+ export type { Appearance, DinghyApi, DinghyEvent, DinghyEvents, DinghyState, Identity, OpenOptions, PageContext, } from "./shared.ts";
3
+ export interface InitOptions extends Appearance {
4
+ /** Your Dinghy server, such as `https://chat.example.com`. */
5
+ url: string;
6
+ /** The workspace's public key, from Settings > Widget snippet. */
7
+ key: string;
8
+ /** False removes the boat. Every state keeps its text. */
9
+ mascot?: boolean;
10
+ /** False hides the launcher, so your own button opens the chat. */
11
+ launcher?: boolean;
12
+ }
13
+ /** The same `Dinghy` as the loader's, usable before the loader runs. */
14
+ export declare const Dinghy: DinghyApi;
15
+ /**
16
+ * Adds the chat to the page once, and returns `Dinghy`. A second call returns the same object and
17
+ * adds nothing. On a server, with no document, it only returns `Dinghy`.
18
+ */
19
+ export declare function init(options: InitOptions): DinghyApi;
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ // src/index.ts
2
+ var slot = globalThis;
3
+ function loaded() {
4
+ const current = slot.Dinghy;
5
+ return current !== void 0 && "on" in current ? current : void 0;
6
+ }
7
+ function enqueue(call) {
8
+ const queue = slot.Dinghy ?? {};
9
+ queue.q = [...queue.q ?? [], call];
10
+ slot.Dinghy = queue;
11
+ }
12
+ function forward(method, ...args) {
13
+ const api = loaded();
14
+ if (api === void 0) enqueue([method, ...args]);
15
+ else api[method](...args);
16
+ }
17
+ var NOT_READY = { ready: false, open: false, unread: 0 };
18
+ var Dinghy = {
19
+ open: (options) => forward("open", ...options === void 0 ? [] : [options]),
20
+ close: () => forward("close"),
21
+ toggle: () => forward("toggle"),
22
+ show: () => forward("show"),
23
+ hide: () => forward("hide"),
24
+ identify: (identity) => forward("identify", identity),
25
+ reset: () => forward("reset"),
26
+ setContext: (data) => forward("setContext", data),
27
+ configure: (options) => forward("configure", options),
28
+ on(event, callback) {
29
+ let active = true;
30
+ const listener = (data) => {
31
+ if (active) callback(data);
32
+ };
33
+ const api = loaded();
34
+ let off;
35
+ if (api === void 0) enqueue(["on", event, listener]);
36
+ else off = api.on(event, listener);
37
+ return () => {
38
+ active = false;
39
+ off?.();
40
+ };
41
+ },
42
+ get state() {
43
+ return loaded()?.state ?? { ...NOT_READY };
44
+ }
45
+ };
46
+ var added = false;
47
+ function init(options) {
48
+ if (added || typeof document === "undefined") return Dinghy;
49
+ added = true;
50
+ const base = options.url.endsWith("/") ? options.url : `${options.url}/`;
51
+ const script = document.createElement("script");
52
+ script.src = new URL("widget/loader.js", base).href;
53
+ script.async = true;
54
+ const attributes = [
55
+ ["key", options.key],
56
+ ["primary", options.primary],
57
+ ["accent", options.accent],
58
+ ["position", options.position],
59
+ ["offset-x", options.offsetX],
60
+ ["offset-y", options.offsetY],
61
+ ["mascot", options.mascot === false ? "false" : void 0],
62
+ ["launcher", options.launcher === false ? "hidden" : void 0]
63
+ ];
64
+ for (const [name, value] of attributes) {
65
+ if (value !== void 0) script.setAttribute(`data-${name}`, String(value));
66
+ }
67
+ if (options.zIndex !== void 0) Dinghy.configure({ zIndex: options.zIndex });
68
+ slot.Dinghy ??= { q: [] };
69
+ document.head.append(script);
70
+ return Dinghy;
71
+ }
72
+ export {
73
+ Dinghy,
74
+ init
75
+ };
@@ -0,0 +1,106 @@
1
+ /** A signed token from the host backend, or a function that fetches a new one. */
2
+ export type Identity = string | (() => Promise<string>);
3
+ /** Unsigned facts from the page, such as a plan or a cart total. Operators see them as unverified. */
4
+ export type PageContext = Record<string, string | number | boolean | null>;
5
+ export interface OpenOptions {
6
+ /** Fills the message box. The visitor still sends it. */
7
+ message?: string;
8
+ }
9
+ /** What `configure` and the script tag's data attributes change. Invalid values are ignored. */
10
+ export interface Appearance {
11
+ /** A six-digit hex color, such as `#0b2239`. */
12
+ primary?: string;
13
+ accent?: string;
14
+ position?: "left" | "right";
15
+ /** Pixels from the side and from the bottom of the window, 0 to 1000. */
16
+ offsetX?: number;
17
+ offsetY?: number;
18
+ zIndex?: number;
19
+ }
20
+ export interface DinghyState {
21
+ /** The chat bundle has loaded. */
22
+ ready: boolean;
23
+ open: boolean;
24
+ unread: number;
25
+ }
26
+ /** Each event and what its callback receives. */
27
+ export interface DinghyEvents {
28
+ ready: undefined;
29
+ open: undefined;
30
+ close: undefined;
31
+ unread: number;
32
+ /** The visitor's message reached the server. `body` is null for an image. */
33
+ "message:sent": {
34
+ body: string | null;
35
+ };
36
+ /** A reply arrived while the page was open. `body` is null for an image. */
37
+ "message:received": {
38
+ body: string | null;
39
+ };
40
+ "conversation:started": undefined;
41
+ }
42
+ export type DinghyEvent = keyof DinghyEvents;
43
+ /** The public API on the host page, `window.Dinghy`. */
44
+ export interface DinghyApi {
45
+ open(options?: OpenOptions): void;
46
+ close(): void;
47
+ toggle(): void;
48
+ /** Shows or hides the launcher button. */
49
+ show(): void;
50
+ hide(): void;
51
+ identify(identity: Identity): void;
52
+ reset(): void;
53
+ setContext(data: PageContext): void;
54
+ configure(options: Appearance): void;
55
+ /** Calls `callback` on each event, and returns a function that removes it. */
56
+ on<E extends DinghyEvent>(event: E, callback: (data: DinghyEvents[E]) => void): () => void;
57
+ readonly state: DinghyState;
58
+ }
59
+ /** A call queued before the loader runs, as `Dinghy = { q: [["open"]] }`. */
60
+ export type QueuedCall = [string, ...unknown[]];
61
+ /** What the chat bundle gives the loader once it runs. */
62
+ export interface ChatHandlers {
63
+ open(message: string | undefined): void;
64
+ close(): void;
65
+ /** identify() or reset(): start again. */
66
+ session(): void;
67
+ /** setContext() was called. */
68
+ context(): void;
69
+ }
70
+ /** Each event's callbacks. The loader keeps them, and the chat bundle calls them. */
71
+ export type Listeners = {
72
+ [E in DinghyEvent]?: Set<(data: DinghyEvents[E]) => void>;
73
+ };
74
+ /** What the loader hands to the chat bundle. */
75
+ export interface WidgetContext {
76
+ publicKey: string;
77
+ base: string;
78
+ shadow: ShadowRoot;
79
+ /** False when the page set data-mascot="false". Every state still has its text. */
80
+ mascot: boolean;
81
+ /** The full-page chat that a reply email opens, on the Dinghy server's own origin. */
82
+ page?: boolean;
83
+ /** Set by identify() and cleared by reset(). */
84
+ identity: Identity | null;
85
+ /** The last setContext() value, as the page gave it. */
86
+ pageContext: unknown;
87
+ /** The chat bundle sets this when it runs, then opens the chat if `wanted` asks for it. */
88
+ chat?: ChatHandlers;
89
+ /** An open() made before the chat bundle ran, with its message. */
90
+ wanted?: [string | undefined] | undefined;
91
+ /** The loader's `Dinghy.state`, which the chat bundle keeps up to date. */
92
+ state: DinghyState;
93
+ listeners: Listeners;
94
+ }
95
+ export declare const RESET_PREFIX = "dinghy:reset:";
96
+ /** Storage can throw in a private window or with blocked site data. */
97
+ export declare function readToken(publicKey: string): string | null;
98
+ export declare function writeToken(publicKey: string, token: string | null): void;
99
+ /** Whether the chat was open in this tab before a reload. Only a visitor with a token has this. */
100
+ export declare function wasOpen(publicKey: string): boolean;
101
+ export declare function rememberOpen(publicKey: string, open: boolean): void;
102
+ /**
103
+ * Tells other tabs of this site to start again. A storage event fires only in other tabs. The key
104
+ * goes at once, so nothing stays stored for a visitor who never chatted.
105
+ */
106
+ export declare function broadcastReset(publicKey: string): void;
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@dinghy-chat/widget",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.12.0",
4
+ "description": "Add the Dinghy chat to your site from code, with a typed API.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://dinghy.chat/docs/add-to-your-site",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dinghy-chat/dinghy.git",
11
+ "directory": "packages/widget"
12
+ },
13
+ "sideEffects": false,
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "types": "./dist/index.d.ts",
21
+ "files": [
22
+ "dist",
23
+ "LICENSE",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "prepack": "esbuild src/index.ts --bundle --format=esm --target=es2022 --outfile=dist/index.js && tsc -p tsconfig.npm.json"
28
+ },
29
+ "devDependencies": {
30
+ "esbuild": "^0.28.2",
31
+ "preact": "^11.2.0"
32
+ }
33
+ }