@joinskips/brand 5.0.0 → 6.0.0-develop.124
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 +7 -0
- package/chat/attachments.d.ts +9 -0
- package/chat/attachments.js +34 -0
- package/chat/components/Bubble.d.ts +4 -0
- package/chat/components/Bubble.js +11 -0
- package/chat/components/Conversation.d.ts +28 -0
- package/chat/components/Conversation.js +117 -0
- package/chat/components/Markdown.d.ts +3 -0
- package/chat/components/Markdown.js +47 -0
- package/chat/components/Welcome.d.ts +72 -0
- package/chat/components/Welcome.js +148 -0
- package/chat/components/parts.d.ts +35 -0
- package/chat/components/parts.js +6 -0
- package/chat/index.d.ts +10 -0
- package/chat/index.js +9 -0
- package/chat/markdown.d.ts +47 -0
- package/chat/markdown.js +129 -0
- package/chat/message.d.ts +46 -0
- package/chat/message.js +12 -0
- package/chat/skipTyping.d.ts +26 -0
- package/chat/skipTyping.js +29 -0
- package/chat/thread.d.ts +40 -0
- package/chat/thread.js +107 -0
- package/chat/typing.d.ts +13 -0
- package/chat/typing.js +60 -0
- package/chat/visibleArea.d.ts +15 -0
- package/chat/visibleArea.js +11 -0
- package/chat/welcome.d.ts +25 -0
- package/chat/welcome.js +51 -0
- package/logos/png-2048/skips-wordmark-dark-2048.png +0 -0
- package/manifest.json +9 -1
- package/package.json +33 -1
- package/tokens/skips-tailwind-theme.css +106 -0
package/chat/markdown.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
// Only addresses a visitor can safely follow. markdown-it already drops javascript:, vbscript:, file: and data:.
|
|
3
|
+
export const LINK_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:'];
|
|
4
|
+
// HTML is shown as the text it is, and only an explicit address becomes a link. A single newline is a line break
|
|
5
|
+
// (the tree has no soft breaks).
|
|
6
|
+
// Headings, tables, images and reference links are left as typed: a chat bubble has no use for them.
|
|
7
|
+
const reader = new MarkdownIt({ html: false, linkify: true, typographer: false })
|
|
8
|
+
.disable(['table', 'heading', 'lheading', 'image', 'reference'])
|
|
9
|
+
.enable('strikethrough');
|
|
10
|
+
reader.linkify.set({ fuzzyLink: false, fuzzyEmail: false, fuzzyIP: false });
|
|
11
|
+
function isFollowable(href) {
|
|
12
|
+
try {
|
|
13
|
+
return LINK_PROTOCOLS.includes(new URL(href).protocol);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const MARKS = {
|
|
20
|
+
strong_open: { type: 'strong', close: 'strong_close' },
|
|
21
|
+
em_open: { type: 'em', close: 'em_close' },
|
|
22
|
+
s_open: { type: 'del', close: 's_close' },
|
|
23
|
+
};
|
|
24
|
+
// markdown-it leaves empty and split text tokens where marks were weighed, so words are joined up here.
|
|
25
|
+
function addInline(nodes, node) {
|
|
26
|
+
const last = nodes.at(-1);
|
|
27
|
+
if (node.type !== 'text')
|
|
28
|
+
nodes.push(node);
|
|
29
|
+
else if (last?.type === 'text')
|
|
30
|
+
last.text += node.text;
|
|
31
|
+
else if (node.text !== '')
|
|
32
|
+
nodes.push({ ...node });
|
|
33
|
+
}
|
|
34
|
+
// The inline nodes from tokens[from] up to the token that closes them.
|
|
35
|
+
function inlinesFrom(tokens, from, closing) {
|
|
36
|
+
const nodes = [];
|
|
37
|
+
let i = from;
|
|
38
|
+
for (let token = tokens[i]; token !== undefined && token.type !== closing; token = tokens[i]) {
|
|
39
|
+
switch (token.type) {
|
|
40
|
+
case 'text':
|
|
41
|
+
addInline(nodes, { type: 'text', text: token.content });
|
|
42
|
+
break;
|
|
43
|
+
case 'softbreak':
|
|
44
|
+
case 'hardbreak':
|
|
45
|
+
addInline(nodes, { type: 'break' });
|
|
46
|
+
break;
|
|
47
|
+
case 'code_inline':
|
|
48
|
+
addInline(nodes, { type: 'code', text: token.content });
|
|
49
|
+
break;
|
|
50
|
+
case 'strong_open':
|
|
51
|
+
case 'em_open':
|
|
52
|
+
case 's_open': {
|
|
53
|
+
const mark = MARKS[token.type];
|
|
54
|
+
const inner = inlinesFrom(tokens, i + 1, mark.close);
|
|
55
|
+
addInline(nodes, { type: mark.type, children: inner.nodes });
|
|
56
|
+
i = inner.end;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case 'link_open': {
|
|
60
|
+
const inner = inlinesFrom(tokens, i + 1, 'link_close');
|
|
61
|
+
// markdown-it gives every link its address. One that cannot be followed keeps its words and drops it.
|
|
62
|
+
const href = String(token.attrGet('href'));
|
|
63
|
+
if (isFollowable(href))
|
|
64
|
+
addInline(nodes, { type: 'link', href, children: inner.nodes });
|
|
65
|
+
else
|
|
66
|
+
for (const node of inner.nodes)
|
|
67
|
+
addInline(nodes, node);
|
|
68
|
+
i = inner.end;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
// no default
|
|
72
|
+
}
|
|
73
|
+
i += 1;
|
|
74
|
+
}
|
|
75
|
+
return { nodes, end: i };
|
|
76
|
+
}
|
|
77
|
+
// An inline token's words. markdown-it's types allow a token with no children.
|
|
78
|
+
export function inlinesOf(token) {
|
|
79
|
+
return inlinesFrom(token.children ?? [], 0, '').nodes;
|
|
80
|
+
}
|
|
81
|
+
// The blocks from tokens[from] up to the token that closes them.
|
|
82
|
+
function blocksFrom(tokens, from, closing) {
|
|
83
|
+
const blocks = [];
|
|
84
|
+
let i = from;
|
|
85
|
+
for (let token = tokens[i]; token !== undefined && token.type !== closing; token = tokens[i]) {
|
|
86
|
+
switch (token.type) {
|
|
87
|
+
case 'inline':
|
|
88
|
+
blocks.push({ type: 'paragraph', children: inlinesOf(token) });
|
|
89
|
+
break;
|
|
90
|
+
case 'bullet_list_open':
|
|
91
|
+
case 'ordered_list_open': {
|
|
92
|
+
const items = [];
|
|
93
|
+
const close = token.type === 'bullet_list_open' ? 'bullet_list_close' : 'ordered_list_close';
|
|
94
|
+
// Each item holds its own blocks and ends at its closing token.
|
|
95
|
+
for (let at = i + 1; tokens[at]?.type !== close; at = i + 1) {
|
|
96
|
+
const item = blocksFrom(tokens, at + 1, 'list_item_close');
|
|
97
|
+
items.push(item.blocks);
|
|
98
|
+
i = item.end;
|
|
99
|
+
}
|
|
100
|
+
blocks.push({
|
|
101
|
+
type: 'list',
|
|
102
|
+
ordered: token.type === 'ordered_list_open',
|
|
103
|
+
start: Number(token.attrGet('start') ?? 1),
|
|
104
|
+
items,
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'blockquote_open': {
|
|
109
|
+
const quoted = blocksFrom(tokens, i + 1, 'blockquote_close');
|
|
110
|
+
blocks.push({ type: 'quote', children: quoted.blocks });
|
|
111
|
+
i = quoted.end;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case 'fence':
|
|
115
|
+
case 'code_block':
|
|
116
|
+
blocks.push({ type: 'code', text: token.content });
|
|
117
|
+
break;
|
|
118
|
+
case 'hr':
|
|
119
|
+
blocks.push({ type: 'rule' });
|
|
120
|
+
break;
|
|
121
|
+
// no default
|
|
122
|
+
}
|
|
123
|
+
i += 1;
|
|
124
|
+
}
|
|
125
|
+
return { blocks, end: i };
|
|
126
|
+
}
|
|
127
|
+
export function readMarkdown(source) {
|
|
128
|
+
return blocksFrom(reader.parse(source, {}), 0, '').blocks;
|
|
129
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Who on Skips' side posted a line: a care team member, or the Skips App user or bot Skip's replies come from. */
|
|
2
|
+
export interface ChatAuthor {
|
|
3
|
+
id: number | null;
|
|
4
|
+
name: string;
|
|
5
|
+
avatarUrl: string | null;
|
|
6
|
+
bot: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface ChatAttachment {
|
|
9
|
+
kind: 'image' | 'file';
|
|
10
|
+
url: string;
|
|
11
|
+
thumbUrl: string | null;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
/** A photo or PDF the member picked, before it is sent. The web's picker gives the file itself. */
|
|
15
|
+
export interface PickedFile {
|
|
16
|
+
uri: string;
|
|
17
|
+
name: string;
|
|
18
|
+
type: string;
|
|
19
|
+
size: number;
|
|
20
|
+
file?: File;
|
|
21
|
+
}
|
|
22
|
+
/** One answer a question from Skips offers: the words on its button, and the value the Skips App keeps. */
|
|
23
|
+
export interface ChoiceItem {
|
|
24
|
+
title: string;
|
|
25
|
+
value: string;
|
|
26
|
+
}
|
|
27
|
+
/** A question answered with one of its buttons. `chosen` is the answer's value, or null while there is none. */
|
|
28
|
+
export interface MessageChoice {
|
|
29
|
+
items: ChoiceItem[];
|
|
30
|
+
chosen: string | null;
|
|
31
|
+
}
|
|
32
|
+
export interface ChatMessage {
|
|
33
|
+
id: number;
|
|
34
|
+
text: string;
|
|
35
|
+
sentAt: number;
|
|
36
|
+
mine: boolean;
|
|
37
|
+
notice: boolean;
|
|
38
|
+
author: ChatAuthor | null;
|
|
39
|
+
attachments: ChatAttachment[];
|
|
40
|
+
choice?: MessageChoice;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether a line from Skips is Skip's: one with no author, a bot's, or one from the Skips App user Skip's replies are
|
|
44
|
+
* posted as. A person on the care team is none of these.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isSkip(message: Pick<ChatMessage, 'mine' | 'author'>, skipUserId: number | null): boolean;
|
package/chat/message.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// A message as the chat shows it. Each app maps its own Skips App API into this shape, so the chat never sees a
|
|
2
|
+
// private note or an event the member does not.
|
|
3
|
+
/**
|
|
4
|
+
* Whether a line from Skips is Skip's: one with no author, a bot's, or one from the Skips App user Skip's replies are
|
|
5
|
+
* posted as. A person on the care team is none of these.
|
|
6
|
+
*/
|
|
7
|
+
export function isSkip(message, skipUserId) {
|
|
8
|
+
if (message.mine)
|
|
9
|
+
return false;
|
|
10
|
+
const { author } = message;
|
|
11
|
+
return author === null || author.bot || author.id === null || author.id === skipUserId;
|
|
12
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const DOT_CYCLE_MS = 1000;
|
|
2
|
+
export interface TypingState {
|
|
3
|
+
dots: boolean;
|
|
4
|
+
shown: boolean;
|
|
5
|
+
ending: boolean;
|
|
6
|
+
since: number;
|
|
7
|
+
turn: number;
|
|
8
|
+
beats: number;
|
|
9
|
+
}
|
|
10
|
+
export interface TypingStarted {
|
|
11
|
+
type: 'typing';
|
|
12
|
+
skipsLines: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TypingSignal {
|
|
15
|
+
type: 'stopped' | 'shown' | 'capped';
|
|
16
|
+
}
|
|
17
|
+
export type TypingEvent = TypingStarted | TypingSignal;
|
|
18
|
+
export declare const TYPING_IDLE: TypingState;
|
|
19
|
+
export declare function typingReducer(state: TypingState, event: TypingEvent): TypingState;
|
|
20
|
+
/** Where Skips' lines start being kept out of sight, or null while nothing is held. */
|
|
21
|
+
export declare function heldFrom(state: TypingState): number | null;
|
|
22
|
+
export interface TypingTimes {
|
|
23
|
+
leastMs: number;
|
|
24
|
+
limitMs: number;
|
|
25
|
+
}
|
|
26
|
+
export declare const TYPING_TIMES: TypingTimes;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Skip typing, as the Skips App reports it: the dots come up when it says Skip is typing and go when it says Skip
|
|
2
|
+
// stopped or the reply lands. They stay up for at least their minimum, and a reply that lands sooner waits out of
|
|
3
|
+
// sight until then, so the dots never flash.
|
|
4
|
+
// One rise and fall of the three dots. The dots stay up at least this long.
|
|
5
|
+
export const DOT_CYCLE_MS = 1000;
|
|
6
|
+
export const TYPING_IDLE = { dots: false, shown: false, ending: false, since: 0, turn: 0, beats: 0 };
|
|
7
|
+
function down(state) {
|
|
8
|
+
return { ...state, dots: false, shown: false, ending: false };
|
|
9
|
+
}
|
|
10
|
+
export function typingReducer(state, event) {
|
|
11
|
+
if (event.type === 'typing') {
|
|
12
|
+
if (state.dots)
|
|
13
|
+
return { ...state, ending: false, beats: state.beats + 1 };
|
|
14
|
+
return { dots: true, shown: false, ending: false, since: event.skipsLines, turn: state.turn + 1, beats: state.beats + 1 };
|
|
15
|
+
}
|
|
16
|
+
// A timer or a stop that comes after the dots have gone changes nothing.
|
|
17
|
+
if (!state.dots)
|
|
18
|
+
return state;
|
|
19
|
+
if (event.type === 'shown')
|
|
20
|
+
return state.ending ? down(state) : { ...state, shown: true };
|
|
21
|
+
if (event.type === 'stopped' && !state.shown)
|
|
22
|
+
return { ...state, ending: true };
|
|
23
|
+
return down(state);
|
|
24
|
+
}
|
|
25
|
+
/** Where Skips' lines start being kept out of sight, or null while nothing is held. */
|
|
26
|
+
export function heldFrom(state) {
|
|
27
|
+
return state.dots && !state.shown ? state.since : null;
|
|
28
|
+
}
|
|
29
|
+
export const TYPING_TIMES = { leastMs: DOT_CYCLE_MS, limitMs: 30_000 };
|
package/chat/thread.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ChatAuthor, type ChatMessage, type PickedFile } from './message.ts';
|
|
2
|
+
export type Delivery = 'sent' | 'pending' | 'failed';
|
|
3
|
+
export interface ThreadMessage extends ChatMessage {
|
|
4
|
+
delivery: Delivery;
|
|
5
|
+
localId?: number;
|
|
6
|
+
file?: PickedFile;
|
|
7
|
+
}
|
|
8
|
+
export interface Thread {
|
|
9
|
+
messages: ThreadMessage[];
|
|
10
|
+
}
|
|
11
|
+
export declare const EMPTY_THREAD: Thread;
|
|
12
|
+
export declare function pendingId(): number;
|
|
13
|
+
export declare function withPending(thread: Thread, id: number, text: string, sentAt: number, file?: PickedFile): Thread;
|
|
14
|
+
/** The send answered: the pending line becomes the message the Skips App stored, or goes when the answer has none. */
|
|
15
|
+
export declare function withSent(thread: Thread, pending: number, message: ChatMessage | null): Thread;
|
|
16
|
+
/** Marks a pending line failed, or pending again as it is retried. */
|
|
17
|
+
export declare function withFailed(thread: Thread, pending: number, failed?: boolean): Thread;
|
|
18
|
+
export declare function withoutMessage(thread: Thread, id: number): Thread;
|
|
19
|
+
/**
|
|
20
|
+
* A message from a page or the socket, added once. The socket can echo the member's line before the send that stored
|
|
21
|
+
* it answers, and the echo then takes the place of the earliest pending line with its text instead of showing twice.
|
|
22
|
+
*/
|
|
23
|
+
export declare function withReceived(thread: Thread, message: ChatMessage): Thread;
|
|
24
|
+
export declare function withReceivedAll(thread: Thread, messages: ChatMessage[]): Thread;
|
|
25
|
+
export declare const CLUSTER_GAP_MS = 120000;
|
|
26
|
+
/** Who a run is from: the member, Skip, or a person on the care team. */
|
|
27
|
+
export type RunSender = 'mine' | 'skip' | ChatAuthor;
|
|
28
|
+
export type ThreadItem = {
|
|
29
|
+
kind: 'notice';
|
|
30
|
+
message: ThreadMessage;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'unsent';
|
|
33
|
+
message: ThreadMessage;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'run';
|
|
36
|
+
sender: RunSender;
|
|
37
|
+
messages: ThreadMessage[];
|
|
38
|
+
};
|
|
39
|
+
/** Notices and the member's unsent lines on their own, every other line in runs by sender, in order. */
|
|
40
|
+
export declare function groupThread(messages: ThreadMessage[], skipUserId: number | null): ThreadItem[];
|
package/chat/thread.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// One conversation as the chat shows it: messages in time order, each sent, pending or failed, with no duplicates
|
|
2
|
+
// when a page and the socket bring the same message, or when the socket echoes what the member just sent.
|
|
3
|
+
import { isSkip } from "./message.js";
|
|
4
|
+
export const EMPTY_THREAD = { messages: [] };
|
|
5
|
+
// Pending lines get negative ids, which never collide with the Skips App's.
|
|
6
|
+
let nextPendingId = -1;
|
|
7
|
+
export function pendingId() {
|
|
8
|
+
const id = nextPendingId;
|
|
9
|
+
nextPendingId -= 1;
|
|
10
|
+
return id;
|
|
11
|
+
}
|
|
12
|
+
function ordered(messages) {
|
|
13
|
+
return { messages: [...messages].sort((a, b) => a.sentAt - b.sentAt || a.id - b.id) };
|
|
14
|
+
}
|
|
15
|
+
export function withPending(thread, id, text, sentAt, file) {
|
|
16
|
+
const line = {
|
|
17
|
+
id,
|
|
18
|
+
text,
|
|
19
|
+
sentAt,
|
|
20
|
+
mine: true,
|
|
21
|
+
notice: false,
|
|
22
|
+
author: null,
|
|
23
|
+
attachments: [],
|
|
24
|
+
delivery: 'pending',
|
|
25
|
+
};
|
|
26
|
+
return ordered([...thread.messages, file ? { ...line, file } : line]);
|
|
27
|
+
}
|
|
28
|
+
/** The send answered: the pending line becomes the message the Skips App stored, or goes when the answer has none. */
|
|
29
|
+
export function withSent(thread, pending, message) {
|
|
30
|
+
const rest = thread.messages.filter((m) => m.id !== pending && m.id !== message?.id);
|
|
31
|
+
return message ? ordered([...rest, { ...message, delivery: 'sent', localId: pending }]) : { messages: rest };
|
|
32
|
+
}
|
|
33
|
+
/** Marks a pending line failed, or pending again as it is retried. */
|
|
34
|
+
export function withFailed(thread, pending, failed = true) {
|
|
35
|
+
return {
|
|
36
|
+
messages: thread.messages.map((m) => (m.id === pending ? { ...m, delivery: failed ? 'failed' : 'pending' } : m)),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function withoutMessage(thread, id) {
|
|
40
|
+
return { messages: thread.messages.filter((m) => m.id !== id) };
|
|
41
|
+
}
|
|
42
|
+
// An answer is never taken back, so a copy of a question without one keeps the answer the chat has.
|
|
43
|
+
function keptChoice(known, incoming) {
|
|
44
|
+
if (incoming === undefined)
|
|
45
|
+
return known;
|
|
46
|
+
return incoming.chosen === null && known?.chosen != null ? known : incoming;
|
|
47
|
+
}
|
|
48
|
+
// A later copy of a message the chat has, such as an edit or an answered question, takes its place.
|
|
49
|
+
function updated(known, message) {
|
|
50
|
+
const { choice: _incoming, ...rest } = message;
|
|
51
|
+
const choice = keptChoice(known.choice, message.choice);
|
|
52
|
+
return {
|
|
53
|
+
...rest,
|
|
54
|
+
delivery: 'sent',
|
|
55
|
+
...(known.localId === undefined ? {} : { localId: known.localId }),
|
|
56
|
+
...(choice === undefined ? {} : { choice }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A message from a page or the socket, added once. The socket can echo the member's line before the send that stored
|
|
61
|
+
* it answers, and the echo then takes the place of the earliest pending line with its text instead of showing twice.
|
|
62
|
+
*/
|
|
63
|
+
export function withReceived(thread, message) {
|
|
64
|
+
const known = thread.messages.find((m) => m.id === message.id);
|
|
65
|
+
if (known)
|
|
66
|
+
return { messages: thread.messages.map((m) => (m === known ? updated(known, message) : m)) };
|
|
67
|
+
const echoed = message.mine
|
|
68
|
+
? thread.messages.find((m) => m.delivery === 'pending' && m.text === message.text)
|
|
69
|
+
: undefined;
|
|
70
|
+
if (echoed)
|
|
71
|
+
return withSent(thread, echoed.id, message);
|
|
72
|
+
return ordered([...thread.messages, { ...message, delivery: 'sent' }]);
|
|
73
|
+
}
|
|
74
|
+
export function withReceivedAll(thread, messages) {
|
|
75
|
+
return messages.reduce(withReceived, thread);
|
|
76
|
+
}
|
|
77
|
+
// Lines from the same sender within two minutes of the one before read as one run.
|
|
78
|
+
export const CLUSTER_GAP_MS = 120_000;
|
|
79
|
+
const senderKey = (sender) => (typeof sender === 'string' ? sender : `team:${sender.name}`);
|
|
80
|
+
/** Notices and the member's unsent lines on their own, every other line in runs by sender, in order. */
|
|
81
|
+
export function groupThread(messages, skipUserId) {
|
|
82
|
+
const senderOf = (m) => {
|
|
83
|
+
if (m.mine)
|
|
84
|
+
return 'mine';
|
|
85
|
+
return m.author && !isSkip(m, skipUserId) ? m.author : 'skip';
|
|
86
|
+
};
|
|
87
|
+
const items = [];
|
|
88
|
+
let run = null;
|
|
89
|
+
let runEnd = 0;
|
|
90
|
+
for (const message of messages) {
|
|
91
|
+
if (message.notice || message.delivery !== 'sent') {
|
|
92
|
+
items.push({ kind: message.notice ? 'notice' : 'unsent', message });
|
|
93
|
+
run = null;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const sender = senderOf(message);
|
|
97
|
+
if (run && senderKey(sender) === senderKey(run.sender) && message.sentAt - runEnd <= CLUSTER_GAP_MS) {
|
|
98
|
+
run.messages.push(message);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
run = { kind: 'run', sender, messages: [message] };
|
|
102
|
+
items.push(run);
|
|
103
|
+
}
|
|
104
|
+
runEnd = message.sentAt;
|
|
105
|
+
}
|
|
106
|
+
return items;
|
|
107
|
+
}
|
package/chat/typing.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const TYPING_IDLE_MS = 3000;
|
|
2
|
+
export declare const TYPING_CLEAR_MS = 10000;
|
|
3
|
+
/** Tells the care team the member is typing. Call `typed` on each change of the text and `stop` when it is sent. */
|
|
4
|
+
export declare function createTypingSignal(send: (typing: boolean) => void): {
|
|
5
|
+
typed(text: string): void;
|
|
6
|
+
stop: () => void;
|
|
7
|
+
};
|
|
8
|
+
/** Who on the care team is typing, each cleared after TYPING_CLEAR_MS without news. */
|
|
9
|
+
export declare function createTypingWatch(onChange: (names: string[]) => void): {
|
|
10
|
+
update(name: string, typing: boolean): void;
|
|
11
|
+
clear: (name: string) => void;
|
|
12
|
+
dispose(): void;
|
|
13
|
+
};
|
package/chat/typing.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Typing, both ways. The member's typing goes out once when it starts and once when it stops, never per keystroke.
|
|
2
|
+
// The care team's typing clears itself if the stop never arrives.
|
|
3
|
+
export const TYPING_IDLE_MS = 3_000;
|
|
4
|
+
export const TYPING_CLEAR_MS = 10_000;
|
|
5
|
+
/** Tells the care team the member is typing. Call `typed` on each change of the text and `stop` when it is sent. */
|
|
6
|
+
export function createTypingSignal(send) {
|
|
7
|
+
let typing = false;
|
|
8
|
+
let idle = null;
|
|
9
|
+
const stop = () => {
|
|
10
|
+
if (idle)
|
|
11
|
+
clearTimeout(idle);
|
|
12
|
+
idle = null;
|
|
13
|
+
if (typing)
|
|
14
|
+
send((typing = false));
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
typed(text) {
|
|
18
|
+
if (!text.trim())
|
|
19
|
+
return stop();
|
|
20
|
+
if (!typing)
|
|
21
|
+
send((typing = true));
|
|
22
|
+
if (idle)
|
|
23
|
+
clearTimeout(idle);
|
|
24
|
+
idle = setTimeout(stop, TYPING_IDLE_MS);
|
|
25
|
+
},
|
|
26
|
+
stop,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Who on the care team is typing, each cleared after TYPING_CLEAR_MS without news. */
|
|
30
|
+
export function createTypingWatch(onChange) {
|
|
31
|
+
const timers = new Map();
|
|
32
|
+
const publish = () => onChange([...timers.keys()]);
|
|
33
|
+
const clear = (name) => {
|
|
34
|
+
const timer = timers.get(name);
|
|
35
|
+
if (!timer)
|
|
36
|
+
return;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
timers.delete(name);
|
|
39
|
+
publish();
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
update(name, typing) {
|
|
43
|
+
if (!typing)
|
|
44
|
+
return clear(name);
|
|
45
|
+
const timer = timers.get(name);
|
|
46
|
+
if (timer)
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
timers.set(name, setTimeout(() => clear(name), TYPING_CLEAR_MS));
|
|
49
|
+
if (!timer)
|
|
50
|
+
publish();
|
|
51
|
+
},
|
|
52
|
+
// A message from someone ends their typing.
|
|
53
|
+
clear,
|
|
54
|
+
dispose() {
|
|
55
|
+
for (const timer of timers.values())
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
timers.clear();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface VisualViewportBox {
|
|
2
|
+
offsetTop: number;
|
|
3
|
+
height: number;
|
|
4
|
+
scale: number;
|
|
5
|
+
}
|
|
6
|
+
export interface Fit {
|
|
7
|
+
top: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const UNZOOMED_WITHIN = 0.01;
|
|
11
|
+
/**
|
|
12
|
+
* Where the chat sits so that it fills exactly what the visitor can see. A visitor who has zoomed in is reading one
|
|
13
|
+
* part of the chat, so it keeps the size it has and they move around it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function fitVisibleArea(viewport: VisualViewportBox): Fit | undefined;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A browser reports a scale a hair off 1 on a page nobody zoomed.
|
|
2
|
+
export const UNZOOMED_WITHIN = 0.01;
|
|
3
|
+
/**
|
|
4
|
+
* Where the chat sits so that it fills exactly what the visitor can see. A visitor who has zoomed in is reading one
|
|
5
|
+
* part of the chat, so it keeps the size it has and they move around it.
|
|
6
|
+
*/
|
|
7
|
+
export function fitVisibleArea(viewport) {
|
|
8
|
+
if (Math.abs(viewport.scale - 1) > UNZOOMED_WITHIN)
|
|
9
|
+
return undefined;
|
|
10
|
+
return { top: viewport.offsetTop, height: viewport.height };
|
|
11
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type WelcomeStep = 'choosing' | 'email' | 'code' | 'name' | 'through';
|
|
2
|
+
export declare const NAME_MAX_LENGTH = 100;
|
|
3
|
+
export declare const EMAIL_MAX_LENGTH = 254;
|
|
4
|
+
export declare function isEmail(email: string): boolean;
|
|
5
|
+
export declare function isName(name: string): boolean;
|
|
6
|
+
export declare function givenEmail(typed: string): string | null;
|
|
7
|
+
export declare function givenName(typed: string): string | null;
|
|
8
|
+
export interface WelcomeTypingTimes {
|
|
9
|
+
leastMs: number;
|
|
10
|
+
mostMs: number;
|
|
11
|
+
}
|
|
12
|
+
export declare const WELCOME_TYPING_TIMES: WelcomeTypingTimes;
|
|
13
|
+
/**
|
|
14
|
+
* Whether reaching this step has Skip say a new line. Going back a step, as Change email does, or staying on one,
|
|
15
|
+
* as a new code does, says nothing new, and the conversation after the welcome is the Skips App's to type.
|
|
16
|
+
*/
|
|
17
|
+
export declare function typesBefore(from: WelcomeStep | null, to: WelcomeStep): boolean;
|
|
18
|
+
/** What the code field holds: its digits only, never more than the code has. A pasted "246 810" keeps its digits. */
|
|
19
|
+
export declare function codeDigits(typed: string, length: number): string;
|
|
20
|
+
/** Whether a change to the code field has just made it a whole code, so it goes on with no button pressed. */
|
|
21
|
+
export declare function completesCode(digits: string, before: string, length: number): boolean;
|
|
22
|
+
export declare const RESEND_AFTER_MS = 30000;
|
|
23
|
+
/** The wait before a new code, as a clock: 0:30, then 0:09. */
|
|
24
|
+
export declare function resendClock(seconds: number): string;
|
|
25
|
+
export declare function typingMs(times: WelcomeTypingTimes, random: number): number;
|
package/chat/welcome.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// What a new visitor gives before the conversation starts, as the website's welcome asks for it: a choice, their
|
|
2
|
+
// email, the code sent to it, and their name if the Skips App does not know them.
|
|
3
|
+
import isEmailAddress from 'validator/lib/isEmail.js';
|
|
4
|
+
export const NAME_MAX_LENGTH = 100;
|
|
5
|
+
// The longest address a mailbox can have.
|
|
6
|
+
export const EMAIL_MAX_LENGTH = 254;
|
|
7
|
+
export function isEmail(email) {
|
|
8
|
+
return isEmailAddress(email);
|
|
9
|
+
}
|
|
10
|
+
export function isName(name) {
|
|
11
|
+
return name.length > 0 && name.length <= NAME_MAX_LENGTH;
|
|
12
|
+
}
|
|
13
|
+
// What was typed, without the spaces around it that a phone keyboard can add, or null when it is no address.
|
|
14
|
+
export function givenEmail(typed) {
|
|
15
|
+
const given = typed.trim();
|
|
16
|
+
return isEmail(given) ? given : null;
|
|
17
|
+
}
|
|
18
|
+
export function givenName(typed) {
|
|
19
|
+
const given = typed.trim();
|
|
20
|
+
return isName(given) ? given : null;
|
|
21
|
+
}
|
|
22
|
+
// Skip types before a line the welcome says, as before a reply from the Skips App, for a random time between the
|
|
23
|
+
// two so it reads as typed.
|
|
24
|
+
export const WELCOME_TYPING_TIMES = { leastMs: 1000, mostMs: 2000 };
|
|
25
|
+
// The steps that each bring a new line from Skip, in the order the welcome goes through them.
|
|
26
|
+
const NEW_LINE_STEPS = ['choosing', 'email', 'code', 'name'];
|
|
27
|
+
/**
|
|
28
|
+
* Whether reaching this step has Skip say a new line. Going back a step, as Change email does, or staying on one,
|
|
29
|
+
* as a new code does, says nothing new, and the conversation after the welcome is the Skips App's to type.
|
|
30
|
+
*/
|
|
31
|
+
export function typesBefore(from, to) {
|
|
32
|
+
const next = NEW_LINE_STEPS.indexOf(to);
|
|
33
|
+
return next !== -1 && (from === null || next > NEW_LINE_STEPS.indexOf(from));
|
|
34
|
+
}
|
|
35
|
+
/** What the code field holds: its digits only, never more than the code has. A pasted "246 810" keeps its digits. */
|
|
36
|
+
export function codeDigits(typed, length) {
|
|
37
|
+
return typed.replace(/\D/g, '').slice(0, length);
|
|
38
|
+
}
|
|
39
|
+
/** Whether a change to the code field has just made it a whole code, so it goes on with no button pressed. */
|
|
40
|
+
export function completesCode(digits, before, length) {
|
|
41
|
+
return digits.length === length && digits !== before;
|
|
42
|
+
}
|
|
43
|
+
// How long after a code goes out before a new one can be asked for. The code service holds the same wait.
|
|
44
|
+
export const RESEND_AFTER_MS = 30_000;
|
|
45
|
+
/** The wait before a new code, as a clock: 0:30, then 0:09. */
|
|
46
|
+
export function resendClock(seconds) {
|
|
47
|
+
return `${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`;
|
|
48
|
+
}
|
|
49
|
+
export function typingMs(times, random) {
|
|
50
|
+
return times.leastMs + random * (times.mostMs - times.leastMs);
|
|
51
|
+
}
|
|
Binary file
|
package/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
2
|
+
"version": 6,
|
|
3
3
|
"files": {
|
|
4
4
|
"app-icons/adaptive-foreground-1024.png": {
|
|
5
5
|
"sha256": "1cfdc826654866b7bd1e4f549417f9f1918a66d5d8a0133dafbfd3d75d5ecc5f",
|
|
@@ -409,6 +409,10 @@
|
|
|
409
409
|
"sha256": "6606d135bbbd06103bacef4cb89bf4019e53e092dcd19b51278edcbab1786b9d",
|
|
410
410
|
"bytes": 130031
|
|
411
411
|
},
|
|
412
|
+
"logos/png-2048/skips-wordmark-dark-2048.png": {
|
|
413
|
+
"sha256": "286ed3c2b3d37b8559ab773a692eca07d04d843ae093820c94ce5bdadff78713",
|
|
414
|
+
"bytes": 46330
|
|
415
|
+
},
|
|
412
416
|
"logos/png-2048/skips-wordmark-light-2048.png": {
|
|
413
417
|
"sha256": "61da312bae3aec7dddef398494fa98d13de940c81b3ecdb038c6904d26d85506",
|
|
414
418
|
"bytes": 59707
|
|
@@ -484,6 +488,10 @@
|
|
|
484
488
|
"tokens/skips-design-tokens.json": {
|
|
485
489
|
"sha256": "bd0deecdedbad1a5ab28d0ec3231095d2b4691390a7d42257d259f3197f181ef",
|
|
486
490
|
"bytes": 3205
|
|
491
|
+
},
|
|
492
|
+
"tokens/skips-tailwind-theme.css": {
|
|
493
|
+
"sha256": "00fef53e4e6c3bde68e93a2e5f0ba28edcf17e6da5df6823c7601461462364af",
|
|
494
|
+
"bytes": 3240
|
|
487
495
|
}
|
|
488
496
|
}
|
|
489
497
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinskips/brand",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0-develop.124",
|
|
4
4
|
"description": "Skips design tokens, Onest, logos, favicons, interface icons, member pictures and app icons.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://brand.joinskips.com",
|
|
@@ -9,6 +9,38 @@
|
|
|
9
9
|
"url": "git+https://github.com/joinskips/brand.git"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
|
+
"./chat": {
|
|
13
|
+
"types": "./chat/index.d.ts",
|
|
14
|
+
"default": "./chat/index.js"
|
|
15
|
+
},
|
|
12
16
|
"./*": "./*"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"markdown-it": "^14.3.3",
|
|
20
|
+
"validator": "^13.15.35"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": "^19.0.0",
|
|
24
|
+
"react-native": ">=0.81.0",
|
|
25
|
+
"react-native-web": "~0.21.0",
|
|
26
|
+
"tailwindcss": "^4.0.0",
|
|
27
|
+
"uniwind": "1.13.2"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"react": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"react-native": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"react-native-web": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"tailwindcss": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"uniwind": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
13
45
|
}
|
|
14
46
|
}
|