@joinskips/brand 9.0.0-develop.147 → 9.0.0-develop.149

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.
Files changed (41) hide show
  1. package/chat/chat/SkipsChat.d.ts +36 -0
  2. package/chat/chat/SkipsChat.js +98 -0
  3. package/chat/chat/client.d.ts +48 -0
  4. package/chat/chat/client.js +173 -0
  5. package/chat/chat/connection.d.ts +4 -0
  6. package/chat/chat/connection.js +6 -0
  7. package/chat/chat/generated/skips-app/index.d.ts +1 -0
  8. package/chat/chat/generated/skips-app/index.js +2 -0
  9. package/chat/chat/generated/skips-app/types.gen.d.ts +733 -0
  10. package/chat/chat/generated/skips-app/types.gen.js +2 -0
  11. package/chat/chat/index.d.ts +3 -0
  12. package/chat/chat/index.js +2 -0
  13. package/chat/chat/platform.d.ts +4 -0
  14. package/chat/chat/platform.js +46 -0
  15. package/chat/chat/session.d.ts +19 -0
  16. package/chat/chat/session.js +95 -0
  17. package/chat/chat/useSkipsChat.d.ts +78 -0
  18. package/chat/chat/useSkipsChat.js +498 -0
  19. package/chat/chat/verify.d.ts +35 -0
  20. package/chat/chat/verify.js +84 -0
  21. package/chat/helpers/cable.d.ts +34 -0
  22. package/chat/helpers/cable.js +88 -0
  23. package/chat/helpers/cableFrames.d.ts +40 -0
  24. package/chat/helpers/cableFrames.js +70 -0
  25. package/chat/helpers/index.d.ts +4 -1
  26. package/chat/helpers/index.js +4 -1
  27. package/chat/helpers/message.d.ts +1 -1
  28. package/chat/helpers/skipsMessage.d.ts +52 -0
  29. package/chat/helpers/skipsMessage.js +129 -0
  30. package/chat/helpers/thread.d.ts +12 -1
  31. package/chat/helpers/thread.js +16 -1
  32. package/chat/index.d.ts +1 -0
  33. package/chat/index.js +1 -0
  34. package/chat/ui/Conversation.d.ts +29 -3
  35. package/chat/ui/Conversation.js +37 -12
  36. package/chat/ui/index.d.ts +0 -1
  37. package/chat/ui/index.js +0 -1
  38. package/chat/ui/parts.d.ts +1 -1
  39. package/package.json +9 -1
  40. package/chat/ui/CheckIn.d.ts +0 -25
  41. package/chat/ui/CheckIn.js +0 -24
@@ -0,0 +1,88 @@
1
+ // A small ActionCable client for the Skips App's RoomChannel. It says when the feed is live or down, so a silent drop
2
+ // never goes unnoticed: no frame for STALE_MS counts as down, and it reconnects with backoff. The Skips App pings
3
+ // every 3 seconds.
4
+ import { readFrame } from "./cableFrames.js";
5
+ export const STALE_MS = 10_000;
6
+ export const FIRST_RETRY_MS = 1_000;
7
+ export const MAX_RETRY_MS = 30_000;
8
+ /** The socket address for a Skips App origin, such as https://app.joinskips.com. */
9
+ export const cableUrl = (origin) => `${origin.replace(/\/$/, '').replace(/^http/, 'ws')}/cable`;
10
+ /** The wait before the nth reconnect, doubling up to MAX_RETRY_MS. */
11
+ export const retryDelay = (attempt) => Math.min(MAX_RETRY_MS, FIRST_RETRY_MS * 2 ** attempt);
12
+ /**
13
+ * Opens the feed for the member's keys, the Skips App's pubsub tokens, on sockets the app makes with createSocket.
14
+ * now is the app's clock, which the stale check reads.
15
+ * Returns a function that closes it for good.
16
+ */
17
+ export function openCable(url, keys, handlers, { careTeamName, createSocket, now }) {
18
+ let socket = null;
19
+ let attempt = 0;
20
+ let lastFrameAt = now();
21
+ let closed = false;
22
+ let retry = null;
23
+ let state = null;
24
+ const setState = (next) => {
25
+ if (next !== state)
26
+ handlers.onState((state = next));
27
+ };
28
+ const drop = (reconnect) => {
29
+ if (socket) {
30
+ const old = socket;
31
+ socket = null;
32
+ old.onclose = old.onmessage = old.onerror = old.onopen = null;
33
+ old.close();
34
+ }
35
+ setState('down');
36
+ if (reconnect && !closed && !retry)
37
+ retry = setTimeout(connect, retryDelay(attempt++));
38
+ };
39
+ function connect() {
40
+ retry = null;
41
+ setState('connecting');
42
+ lastFrameAt = now();
43
+ const next = createSocket(url);
44
+ socket = next;
45
+ next.onmessage = ({ data }) => {
46
+ lastFrameAt = now();
47
+ const frame = readFrame(String(data), careTeamName);
48
+ if (frame.type === 'welcome') {
49
+ attempt = 0;
50
+ for (const key of keys) {
51
+ next.send(JSON.stringify({
52
+ command: 'subscribe',
53
+ identifier: JSON.stringify({ channel: 'RoomChannel', pubsub_token: key }),
54
+ }));
55
+ }
56
+ }
57
+ else if (frame.type === 'confirm_subscription')
58
+ setState('live');
59
+ else if (frame.type === 'event')
60
+ handlers.onEvent(frame.event);
61
+ else if (frame.type === 'reject_subscription' || (frame.type === 'disconnect' && !frame.reconnect)) {
62
+ drop(false);
63
+ handlers.onKeysRefused();
64
+ }
65
+ else if (frame.type === 'disconnect')
66
+ drop(true);
67
+ };
68
+ next.onclose = () => {
69
+ drop(true);
70
+ };
71
+ next.onerror = () => {
72
+ drop(true);
73
+ };
74
+ }
75
+ const watchdog = setInterval(() => {
76
+ if (socket && now() - lastFrameAt > STALE_MS)
77
+ drop(true);
78
+ }, STALE_MS / 2);
79
+ connect();
80
+ return () => {
81
+ closed = true;
82
+ clearInterval(watchdog);
83
+ if (retry)
84
+ clearTimeout(retry);
85
+ retry = null;
86
+ drop(false);
87
+ };
88
+ }
@@ -0,0 +1,40 @@
1
+ import type { ChatMessage } from './message.ts';
2
+ interface ChatMessageEvent {
3
+ kind: 'message';
4
+ conversationId: number;
5
+ message: ChatMessage;
6
+ }
7
+ interface TypingEvent {
8
+ kind: 'typing';
9
+ conversationId: number;
10
+ typing: boolean;
11
+ name: string;
12
+ }
13
+ interface ConversationEvent {
14
+ kind: 'conversation';
15
+ conversationId: number;
16
+ }
17
+ export type ChatEvent = ChatMessageEvent | TypingEvent | ConversationEvent;
18
+ type ControlType = 'welcome' | 'ping' | 'confirm_subscription' | 'reject_subscription';
19
+ interface ControlFrame {
20
+ type: ControlType;
21
+ }
22
+ interface DisconnectFrame {
23
+ type: 'disconnect';
24
+ reconnect: boolean;
25
+ }
26
+ interface EventFrame {
27
+ type: 'event';
28
+ event: ChatEvent;
29
+ }
30
+ interface IgnoredFrame {
31
+ type: 'ignored';
32
+ }
33
+ /** One ActionCable frame: a control frame the client handles, or a Skips App event. */
34
+ export type CableFrame = ControlFrame | DisconnectFrame | EventFrame | IgnoredFrame;
35
+ /**
36
+ * Reads one frame of text from the socket. A frame that is not JSON, or not one the app knows, is ignored.
37
+ * careTeamName is the app's name for a care team line with no named sender (toChatMessage).
38
+ */
39
+ export declare function readFrame(text: string, careTeamName: string): CableFrame;
40
+ export {};
@@ -0,0 +1,70 @@
1
+ // What the Skips App's live socket says, read into the few things an app acts on. Shapes come from the socket itself
2
+ // (message_type and created_at as numbers, the member's own typing never sent back), and anything else is ignored.
3
+ import { firstFilled, isNullableString, isObject, isRawMessage, optional, toChatMessage } from "./skipsMessage.js";
4
+ const CONTROL_TYPES = ['welcome', 'ping', 'confirm_subscription', 'reject_subscription'];
5
+ const IGNORED = { type: 'ignored' };
6
+ function isTypingData(value) {
7
+ if (!isObject(value) || !isObject(value.user) || !isObject(value.conversation))
8
+ return false;
9
+ const { user } = value;
10
+ return (optional(value.is_private, (flag) => typeof flag === 'boolean') &&
11
+ optional(user.type, (type) => typeof type === 'string') &&
12
+ optional(user.available_name, isNullableString) &&
13
+ optional(user.name, isNullableString) &&
14
+ typeof value.conversation.id === 'number');
15
+ }
16
+ const fieldIn = (value, key) => (isObject(value) ? value[key] : undefined);
17
+ function toEvent(message, careTeamName) {
18
+ const event = fieldIn(message, 'event');
19
+ const data = fieldIn(message, 'data');
20
+ switch (event) {
21
+ case 'message.created':
22
+ case 'message.updated': {
23
+ if (!isRawMessage(data))
24
+ return null;
25
+ const chatMessage = toChatMessage(data, careTeamName);
26
+ return chatMessage ? { kind: 'message', conversationId: data.conversation_id ?? 0, message: chatMessage } : null;
27
+ }
28
+ case 'conversation.typing_on':
29
+ case 'conversation.typing_off': {
30
+ // A private note's typing is the care team's own, and a contact's typing is the member's.
31
+ if (!isTypingData(data) || data.is_private || data.user.type === 'contact')
32
+ return null;
33
+ return {
34
+ kind: 'typing',
35
+ conversationId: data.conversation.id,
36
+ typing: event === 'conversation.typing_on',
37
+ name: firstFilled(data.user.available_name, data.user.name) ?? '',
38
+ };
39
+ }
40
+ // Only which conversation changed: its fields on the socket can be partial, so the app asks for them again.
41
+ case 'conversation.updated':
42
+ case 'conversation.status_changed': {
43
+ const id = fieldIn(data, 'id');
44
+ return typeof id === 'number' ? { kind: 'conversation', conversationId: id } : null;
45
+ }
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Reads one frame of text from the socket. A frame that is not JSON, or not one the app knows, is ignored.
52
+ * careTeamName is the app's name for a care team line with no named sender (toChatMessage).
53
+ */
54
+ export function readFrame(text, careTeamName) {
55
+ let frame;
56
+ try {
57
+ frame = JSON.parse(text);
58
+ }
59
+ catch {
60
+ return IGNORED;
61
+ }
62
+ const type = fieldIn(frame, 'type');
63
+ if (type === 'disconnect')
64
+ return { type: 'disconnect', reconnect: fieldIn(frame, 'reconnect') !== false };
65
+ const control = CONTROL_TYPES.find((each) => each === type);
66
+ if (control)
67
+ return { type: control };
68
+ const event = toEvent(fieldIn(frame, 'message'), careTeamName);
69
+ return event ? { type: 'event', event } : IGNORED;
70
+ }
@@ -1,7 +1,10 @@
1
1
  export { ATTACHMENT_TYPES, MAX_ATTACHMENT_BYTES, attachmentProblem, attachmentType, type AttachmentProblem } from './attachments.ts';
2
2
  export { isSkip, type ChatAttachment, type ChatAuthor, type ChatMessage, type ChoiceItem, type MessageChoice, type PickedFile, } from './message.ts';
3
- export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, pendingId, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, type Delivery, type RunSender, type Thread, type ThreadItem, type ThreadMessage, } from './thread.ts';
3
+ export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, openQuestion, pendingId, storedIds, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, type Delivery, type OpenQuestion, type RunSender, type Thread, type ThreadItem, type ThreadMessage, } from './thread.ts';
4
4
  export { TYPING_CLEAR_MS, TYPING_IDLE_MS, createTypingSignal, createTypingWatch } from './typing.ts';
5
5
  export { DOT_CYCLE_MS, TYPING_IDLE, TYPING_TIMES, heldFrom, typingReducer, type TypingEvent, type TypingSignal, type TypingStarted, type TypingState, type TypingTimes, } from './skipTyping.ts';
6
6
  export { fitVisibleArea, type Fit, type VisualViewportBox } from './visibleArea.ts';
7
+ export { FIRST_RETRY_MS, MAX_RETRY_MS, STALE_MS, cableUrl, openCable, retryDelay, type CableHandlers, type CableOptions, type CableState, type Socket, type SocketMessage, } from './cable.ts';
8
+ export { readFrame, type CableFrame, type ChatEvent } from './cableFrames.ts';
9
+ export { CHOICE_CONTENT_TYPE, choiceFrom, fileNameIn, firstFilled, fromContact, isRawMessage, toChatMessage, toMs, type RawMessage, type RawMessageFile, type RawSender, } from './skipsMessage.ts';
7
10
  export { EMAIL_MAX_LENGTH, NAME_MAX_LENGTH, RESEND_AFTER_MS, WELCOME_TYPING_TIMES, givenEmail, givenName, isEmail, isName, typesBefore, typingMs, type WelcomeStep, type WelcomeTypingTimes, } from './welcome.ts';
@@ -1,7 +1,10 @@
1
1
  export { ATTACHMENT_TYPES, MAX_ATTACHMENT_BYTES, attachmentProblem, attachmentType } from "./attachments.js";
2
2
  export { isSkip, } from "./message.js";
3
- export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, pendingId, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, } from "./thread.js";
3
+ export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, openQuestion, pendingId, storedIds, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, } from "./thread.js";
4
4
  export { TYPING_CLEAR_MS, TYPING_IDLE_MS, createTypingSignal, createTypingWatch } from "./typing.js";
5
5
  export { DOT_CYCLE_MS, TYPING_IDLE, TYPING_TIMES, heldFrom, typingReducer, } from "./skipTyping.js";
6
6
  export { fitVisibleArea } from "./visibleArea.js";
7
+ export { FIRST_RETRY_MS, MAX_RETRY_MS, STALE_MS, cableUrl, openCable, retryDelay, } from "./cable.js";
8
+ export { readFrame } from "./cableFrames.js";
9
+ export { CHOICE_CONTENT_TYPE, choiceFrom, fileNameIn, firstFilled, fromContact, isRawMessage, toChatMessage, toMs, } from "./skipsMessage.js";
7
10
  export { EMAIL_MAX_LENGTH, NAME_MAX_LENGTH, RESEND_AFTER_MS, WELCOME_TYPING_TIMES, givenEmail, givenName, isEmail, isName, typesBefore, typingMs, } from "./welcome.js";
@@ -17,7 +17,7 @@ export interface PickedFile {
17
17
  name: string;
18
18
  type: string;
19
19
  size: number;
20
- file?: File;
20
+ file?: Blob;
21
21
  }
22
22
  /** One answer a question from Skips offers: the words on its button, and the value the Skips App keeps. */
23
23
  export interface ChoiceItem {
@@ -0,0 +1,52 @@
1
+ import type { ChatMessage, MessageChoice } from './message.ts';
2
+ export interface RawSender {
3
+ id?: number;
4
+ type?: string;
5
+ name?: string | null;
6
+ available_name?: string | null;
7
+ thumbnail?: string | null;
8
+ }
9
+ export interface RawMessageFile {
10
+ file_type?: string;
11
+ data_url?: string;
12
+ thumb_url?: string | null;
13
+ file_name?: string | null;
14
+ }
15
+ /** A message as the Skips App's API and its socket give it. The socket spells message_type as a word. */
16
+ export interface RawMessage {
17
+ id: number;
18
+ conversation_id?: number;
19
+ content: string | null;
20
+ message_type: number | string;
21
+ created_at: number | string;
22
+ private?: boolean;
23
+ sender?: RawSender | null;
24
+ attachments?: RawMessageFile[];
25
+ content_type?: unknown;
26
+ content_attributes?: unknown;
27
+ }
28
+ export type JsonObject = Record<string, unknown>;
29
+ export declare const isObject: (value: unknown) => value is JsonObject;
30
+ export declare const isNullableString: (value: unknown) => value is string | null;
31
+ export declare const optional: (value: unknown, check: (value: unknown) => boolean) => boolean;
32
+ /** Whether a value from the Skips App has a message's shape, every field it carries checked. */
33
+ export declare function isRawMessage(value: unknown): value is RawMessage;
34
+ /** The first value that holds text, an empty string counting as none, or undefined when none does. */
35
+ export declare const firstFilled: (...values: (string | null | undefined)[]) => string | undefined;
36
+ /** A file's name from its Skips App address, which ends with it. */
37
+ export declare const fileNameIn: (url: string) => string;
38
+ export declare const fromContact: (raw: RawMessage) => boolean;
39
+ /** A Skips App time in milliseconds: the API gives seconds, the socket text. */
40
+ export declare const toMs: (value: number | string) => number;
41
+ export declare const CHOICE_CONTENT_TYPE = "input_select";
42
+ /**
43
+ * A question's buttons, with the answer already given if it is one of them. A message of another type, or one whose
44
+ * buttons cannot all be read, offers none: a button with no words could not be pressed knowingly. Nor does one with
45
+ * two buttons of the same value, since the answer kept could not say which was pressed.
46
+ */
47
+ export declare function choiceFrom(contentType: unknown, attributes: unknown): MessageChoice | null;
48
+ /**
49
+ * The member's view of a message, or null when it is a private note or an activity the member does not see.
50
+ * careTeamName, the app's own words, names a care team line whose sender the Skips App leaves unnamed.
51
+ */
52
+ export declare function toChatMessage(raw: RawMessage, careTeamName: string): ChatMessage | null;
@@ -0,0 +1,129 @@
1
+ // A Skips App message as its API and its live socket give it, and the member's view of it. Neither ever shows the
2
+ // member a private note or an internal activity line.
3
+ export const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ const isNumber = (value) => typeof value === 'number';
5
+ const isString = (value) => typeof value === 'string';
6
+ const isBoolean = (value) => typeof value === 'boolean';
7
+ const isNumberOrString = (value) => isNumber(value) || isString(value);
8
+ export const isNullableString = (value) => value === null || isString(value);
9
+ // A field left out passes, and a field that is there must have its type.
10
+ export const optional = (value, check) => value === undefined || check(value);
11
+ function isRawSender(value) {
12
+ return (isObject(value) &&
13
+ optional(value.id, isNumber) &&
14
+ optional(value.type, isString) &&
15
+ optional(value.name, isNullableString) &&
16
+ optional(value.available_name, isNullableString) &&
17
+ optional(value.thumbnail, isNullableString));
18
+ }
19
+ function isRawMessageFile(value) {
20
+ return (isObject(value) &&
21
+ optional(value.file_type, isString) &&
22
+ optional(value.data_url, isString) &&
23
+ optional(value.thumb_url, isNullableString) &&
24
+ optional(value.file_name, isNullableString));
25
+ }
26
+ /** Whether a value from the Skips App has a message's shape, every field it carries checked. */
27
+ export function isRawMessage(value) {
28
+ return (isObject(value) &&
29
+ isNumber(value.id) &&
30
+ optional(value.conversation_id, isNumber) &&
31
+ isNullableString(value.content) &&
32
+ isNumberOrString(value.message_type) &&
33
+ isNumberOrString(value.created_at) &&
34
+ optional(value.private, isBoolean) &&
35
+ optional(value.sender, (sender) => sender === null || isRawSender(sender)) &&
36
+ optional(value.attachments, (files) => Array.isArray(files) && files.every(isRawMessageFile)));
37
+ }
38
+ /** The first value that holds text, an empty string counting as none, or undefined when none does. */
39
+ export const firstFilled = (...values) => values.find((value) => Boolean(value));
40
+ /** A file's name from its Skips App address, which ends with it. */
41
+ export const fileNameIn = (url) => {
42
+ let path = url;
43
+ try {
44
+ path = new URL(url).pathname;
45
+ }
46
+ catch {
47
+ // Not a full address: its last part is still the best name there is.
48
+ }
49
+ const name = path.slice(path.lastIndexOf('/') + 1);
50
+ try {
51
+ return decodeURIComponent(name);
52
+ }
53
+ catch {
54
+ return name;
55
+ }
56
+ };
57
+ // message_type 0 is the contact's, 2 an activity. The socket sends the names instead of the numbers.
58
+ export const fromContact = (raw) => raw.sender?.type === 'contact' || raw.message_type === 0 || raw.message_type === 'incoming';
59
+ const isActivity = (raw) => raw.message_type === 2 || raw.message_type === 'activity';
60
+ // Of the Skips App's activity lines, only a conversation closing or reopening is the member's business.
61
+ const NOTICE = /conversation was (resolved|reopened)|(resolved|reopened) the conversation/i;
62
+ /** A Skips App time in milliseconds: the API gives seconds, the socket text. */
63
+ export const toMs = (value) => (typeof value === 'number' ? value * 1000 : Date.parse(value));
64
+ // The Skips App's question answered with one of its buttons.
65
+ export const CHOICE_CONTENT_TYPE = 'input_select';
66
+ function isChoiceItem(value) {
67
+ return (isObject(value) &&
68
+ isString(value.title) &&
69
+ value.title.trim().length > 0 &&
70
+ isString(value.value) &&
71
+ value.value.length > 0);
72
+ }
73
+ /**
74
+ * A question's buttons, with the answer already given if it is one of them. A message of another type, or one whose
75
+ * buttons cannot all be read, offers none: a button with no words could not be pressed knowingly. Nor does one with
76
+ * two buttons of the same value, since the answer kept could not say which was pressed.
77
+ */
78
+ export function choiceFrom(contentType, attributes) {
79
+ if (contentType !== CHOICE_CONTENT_TYPE || !isObject(attributes))
80
+ return null;
81
+ const { items, submitted_values: submitted } = attributes;
82
+ if (!Array.isArray(items) || items.length === 0 || !items.every(isChoiceItem))
83
+ return null;
84
+ if (new Set(items.map((item) => item.value)).size !== items.length)
85
+ return null;
86
+ const first = Array.isArray(submitted) ? submitted[0] : null;
87
+ const given = isObject(first) ? first.value : null;
88
+ const chosen = items.find((item) => item.value === given)?.value ?? null;
89
+ return { items, chosen };
90
+ }
91
+ /**
92
+ * The member's view of a message, or null when it is a private note or an activity the member does not see.
93
+ * careTeamName, the app's own words, names a care team line whose sender the Skips App leaves unnamed.
94
+ */
95
+ export function toChatMessage(raw, careTeamName) {
96
+ if (raw.private)
97
+ return null;
98
+ const notice = isActivity(raw);
99
+ if (notice && !NOTICE.test(raw.content ?? ''))
100
+ return null;
101
+ const mine = !notice && fromContact(raw);
102
+ const choice = mine || notice ? null : choiceFrom(raw.content_type, raw.content_attributes);
103
+ return {
104
+ ...(choice === null ? {} : { choice }),
105
+ id: raw.id,
106
+ text: raw.content ?? '',
107
+ sentAt: toMs(raw.created_at),
108
+ mine,
109
+ notice,
110
+ author: mine || notice
111
+ ? null
112
+ : {
113
+ id: raw.sender?.id ?? null,
114
+ name: firstFilled(raw.sender?.available_name, raw.sender?.name) ?? careTeamName,
115
+ avatarUrl: firstFilled(raw.sender?.thumbnail) ?? null,
116
+ bot: raw.sender?.type === 'agent_bot',
117
+ },
118
+ attachments: (raw.attachments ?? []).flatMap((a) => a.data_url
119
+ ? [
120
+ {
121
+ kind: a.file_type === 'image' ? 'image' : 'file',
122
+ url: a.data_url,
123
+ thumbUrl: a.thumb_url ?? null,
124
+ name: firstFilled(a.file_name) ?? fileNameIn(a.data_url),
125
+ },
126
+ ]
127
+ : []),
128
+ };
129
+ }
@@ -1,4 +1,4 @@
1
- import { type ChatAuthor, type ChatMessage, type PickedFile } from './message.ts';
1
+ import { type ChatAuthor, type ChatMessage, type ChoiceItem, type PickedFile } from './message.ts';
2
2
  export type Delivery = 'sent' | 'pending' | 'failed';
3
3
  export interface ThreadMessage extends ChatMessage {
4
4
  delivery: Delivery;
@@ -16,6 +16,17 @@ export declare function withSent(thread: Thread, pending: number, message: ChatM
16
16
  /** Marks a pending line failed, or pending again as it is retried. */
17
17
  export declare function withFailed(thread: Thread, pending: number, failed?: boolean): Thread;
18
18
  export declare function withoutMessage(thread: Thread, id: number): Thread;
19
+ /** The ids of the thread's messages the Skips App holds, oldest first: pending and failed lines have no place in its pages. */
20
+ export declare const storedIds: (thread: Thread) => number[];
21
+ /**
22
+ * The question from Skip whose buttons can still be pressed: unanswered, with nothing written after it. A line from the
23
+ * care team after it leaves it open, since what asked it still waits for the press.
24
+ */
25
+ export declare function openQuestion(messages: readonly ThreadMessage[]): OpenQuestion | undefined;
26
+ export interface OpenQuestion {
27
+ message: ThreadMessage;
28
+ items: ChoiceItem[];
29
+ }
19
30
  /**
20
31
  * A message from a page or the socket, added once. The socket can echo the member's line before the send that stored
21
32
  * it answers, and the echo then takes the place of the earliest pending line with its text instead of showing twice.
@@ -1,6 +1,6 @@
1
1
  // One conversation as the chat shows it: messages in time order, each sent, pending or failed, with no duplicates
2
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";
3
+ import { isSkip, } from "./message.js";
4
4
  export const EMPTY_THREAD = { messages: [] };
5
5
  // Pending lines get negative ids, which never collide with the Skips App's.
6
6
  let nextPendingId = -1;
@@ -39,6 +39,21 @@ export function withFailed(thread, pending, failed = true) {
39
39
  export function withoutMessage(thread, id) {
40
40
  return { messages: thread.messages.filter((m) => m.id !== id) };
41
41
  }
42
+ /** The ids of the thread's messages the Skips App holds, oldest first: pending and failed lines have no place in its pages. */
43
+ export const storedIds = (thread) => thread.messages.filter((message) => message.delivery === 'sent').map((message) => message.id);
44
+ /**
45
+ * The question from Skip whose buttons can still be pressed: unanswered, with nothing written after it. A line from the
46
+ * care team after it leaves it open, since what asked it still waits for the press.
47
+ */
48
+ export function openQuestion(messages) {
49
+ for (const message of [...messages].reverse()) {
50
+ if (message.mine)
51
+ return undefined;
52
+ if (message.choice)
53
+ return message.choice.chosen === null ? { message, items: message.choice.items } : undefined;
54
+ }
55
+ return undefined;
56
+ }
42
57
  // An answer is never taken back, so a copy of a question without one keeps the answer the chat has.
43
58
  function keptChoice(known, incoming) {
44
59
  if (incoming === undefined)
package/chat/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './ui/index.ts';
2
2
  export * from './hooks/index.ts';
3
3
  export * from './helpers/index.ts';
4
+ export * from './chat/index.ts';
package/chat/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./ui/index.js";
2
2
  export * from "./hooks/index.js";
3
3
  export * from "./helpers/index.js";
4
+ export * from "./chat/index.js";
@@ -1,7 +1,10 @@
1
+ import { type ReactNode } from 'react';
1
2
  import { type ImageSourcePropType } from 'react-native';
2
3
  import type { Composer } from '../hooks/useComposer.ts';
3
- import { type Thread } from '../helpers/thread.ts';
4
+ import type { ChoiceItem } from '../helpers/message.ts';
5
+ import { type Thread, type ThreadMessage } from '../helpers/thread.ts';
4
6
  import { type ChatIcon, type ChatWords } from './parts.ts';
7
+ import { type WelcomeWords } from './Welcome.tsx';
5
8
  export type LoadState = 'loading' | 'ready' | 'failed';
6
9
  export interface ConversationProps {
7
10
  thread: Thread;
@@ -13,13 +16,36 @@ export interface ConversationProps {
13
16
  onRetry: (id: number) => void;
14
17
  onLoadOlder: () => void;
15
18
  onReload: () => void;
16
- keyboardOffset?: number;
17
- bottomInset?: number;
19
+ keyboardOffset?: number | undefined;
20
+ bottomInset?: number | undefined;
18
21
  words: ChatWords;
19
22
  Icon: ChatIcon;
20
23
  skipMark: ImageSourcePropType;
24
+ label?: string;
25
+ lead?: ReactNode;
26
+ trail?: ReactNode;
27
+ hidden?: ReadonlySet<number>;
28
+ choices?: ConversationChoices;
29
+ composerClosed?: boolean;
30
+ holding?: boolean;
31
+ }
32
+ export interface ConversationChoices {
33
+ sending: string | null;
34
+ failed: number | null;
35
+ holding?: boolean;
36
+ words: WelcomeWords;
37
+ onChoose: (question: ThreadMessage, answer: ChoiceItem) => void;
21
38
  }
22
39
  export declare const PHOTO_RETRIES = 5;
23
40
  export declare const PHOTO_RETRY_MS = 1500;
24
41
  /** One conversation: its messages oldest at the top, the care team's typing, and the box to write in. */
25
42
  export declare function Conversation(props: ConversationProps): import("react").JSX.Element;
43
+ export interface UnsentProps {
44
+ message: ThreadMessage;
45
+ words: ChatWords;
46
+ onRetry: (id: number) => void;
47
+ Icon: ChatIcon;
48
+ holding: boolean;
49
+ }
50
+ /** The member's line while it is on its way, or after it failed, with its retry. */
51
+ export declare function Unsent({ message, words, onRetry, Icon, holding, }: UnsentProps): import("react").JSX.Element;
@@ -1,9 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { ActivityIndicator, FlatList, Image, KeyboardAvoidingView, Linking, Platform, Pressable, Text, TextInput, View, } from 'react-native';
4
- import { groupThread } from "../helpers/thread.js";
4
+ import { groupThread, openQuestion, } from "../helpers/thread.js";
5
5
  import { Bubble } from "./Bubble.js";
6
6
  import { useColor } from "./parts.js";
7
+ import { Choices } from "./Welcome.js";
7
8
  // An image from require needs its size in style: the web draws it at its own size otherwise.
8
9
  const AVATAR_SIZE = 32;
9
10
  export const PHOTO_RETRIES = 5;
@@ -18,40 +19,64 @@ export function Conversation(props) {
18
19
  const { thread, load, feedDown, typists, words, onReload, onLoadOlder } = props;
19
20
  const secondary = useColor('ink-secondary');
20
21
  // The list is inverted so it rests at the newest message and stays put while the member reads older ones.
21
- const items = groupThread(thread.messages, props.skipUserId).reverse();
22
- return (_jsxs(KeyboardAvoidingView, { behavior: "padding", keyboardVerticalOffset: props.keyboardOffset ?? 0, className: "flex-1", children: [feedDown ? (_jsx(Line, { role: "status", className: "bg-muted px-gutter-phone py-2 text-center text-sm text-ink-secondary", children: words.feedDown })) : null, load === 'loading' ? (_jsx(ActivityIndicator, { accessibilityLabel: words.loading, className: "flex-1", color: secondary })) : load === 'failed' ? (_jsxs(View, { role: "alert", className: "flex-1 items-center justify-center gap-3 px-gutter-phone", children: [_jsx(Line, { className: "text-center text-base text-ink-secondary", children: words.loadFailed }), _jsx(Pressable, { role: "button", onPress: onReload, className: "h-control items-center justify-center rounded-pill border border-line-control px-6 active:opacity-80", children: _jsx(Line, { className: "text-md font-semibold text-ink", children: words.retry }) })] })) : (_jsx(FlatList, { inverted: true, data: items, keyExtractor: itemKey, onEndReached: onLoadOlder, contentContainerClassName: "gap-4 px-gutter-phone py-4 md:mx-auto md:w-full md:max-w-reading", ListHeaderComponent: typists.length ? _jsx(Line, { className: "text-sm text-ink-secondary", children: words.typingLine(typists) }) : undefined, renderItem: ({ item }) => _jsx(ThreadRow, { item: item, ...props }) })), _jsx(ComposerBox, { ...props })] }));
22
+ const shown = props.hidden ? thread.messages.filter((message) => !props.hidden?.has(message.id)) : thread.messages;
23
+ const items = groupThread(shown, props.skipUserId).reverse();
24
+ const question = props.choices ? openQuestion(shown) : undefined;
25
+ return (_jsxs(KeyboardAvoidingView, { behavior: "padding", keyboardVerticalOffset: props.keyboardOffset ?? 0, className: "flex-1", children: [feedDown ? (_jsx(Line, { role: "status", className: "bg-muted px-gutter-phone py-2 text-center text-sm text-ink-secondary", children: words.feedDown })) : null, load === 'loading' ? (_jsx(ActivityIndicator, { accessibilityLabel: words.loading, className: "flex-1", color: secondary })) : load === 'failed' ? (_jsxs(View, { role: "alert", className: "flex-1 items-center justify-center gap-3 px-gutter-phone", children: [_jsx(Line, { className: "text-center text-base text-ink-secondary", children: words.loadFailed }), _jsx(Pressable, { role: "button", onPress: onReload, className: "h-control items-center justify-center rounded-pill border border-line-control px-6 active:opacity-80", children: _jsx(Line, { className: "text-md font-semibold text-ink", children: words.retry }) })] })) : (_jsx(FlatList, { inverted: true, data: items, keyExtractor: itemKey, onEndReached: onLoadOlder, contentContainerClassName: "gap-4 px-gutter-phone py-4 md:mx-auto md:w-full md:max-w-reading", role: "log", "aria-label": props.label,
26
+ // The conversation scrolls, so the keyboard has to be able to reach it.
27
+ tabIndex: 0, ListHeaderComponent: _jsxs(_Fragment, { children: [props.trail, typists.length ? _jsx(Line, { className: "text-sm text-ink-secondary", children: words.typingLine(typists) }) : null] }), ListFooterComponent: _jsx(_Fragment, { children: props.lead }), renderItem: ({ item }) => _jsx(ThreadRow, { item: item, question: question, ...props }) })), _jsx(ComposerBox, { ...props })] }));
23
28
  }
24
29
  function itemKey(item) {
25
30
  if (item.kind === 'run')
26
31
  return `r${String(item.messages[0]?.id)}`;
27
32
  return `${item.kind === 'notice' ? 'n' : 'u'}${String(item.message.id)}`;
28
33
  }
29
- function ComposerBox({ composer, bottomInset, words, Icon }) {
34
+ // The web sends on Enter, as joinskips.com's chat does, and Shift+Enter starts a new line. A phone's return key
35
+ // starts a new line.
36
+ function sendOnEnter(canSend, submit) {
37
+ return (event) => {
38
+ const { nativeEvent } = event;
39
+ if (nativeEvent.key !== 'Enter' || ('shiftKey' in nativeEvent && nativeEvent.shiftKey === true))
40
+ return;
41
+ event.preventDefault();
42
+ if (canSend)
43
+ submit();
44
+ };
45
+ }
46
+ function ComposerBox({ composer, bottomInset, words, Icon, composerClosed = false, holding = false }) {
30
47
  const secondary = useColor('ink-secondary');
31
48
  const placeholder = useColor('ink-placeholder');
32
49
  const actionInk = useColor('action-ink');
33
- const { text, picked, refused, canSend } = composer;
34
- return (_jsxs(View, { style: { paddingBottom: Math.max(bottomInset ?? 0, MIN_BOTTOM_SPACE) }, className: "gap-2 px-gutter-phone pt-2 md:mx-auto md:w-full md:max-w-reading", children: [refused ? (_jsx(Line, { role: "alert", className: "text-sm text-error-ink", children: words.attachmentRefused[refused] })) : null, picked ? (_jsxs(View, { className: "flex-row items-center gap-3 self-start rounded-control border border-line-subtle bg-surface p-2 pr-3", children: [isPhoto(picked) ? (_jsx(Image, { source: { uri: picked.uri }, resizeMode: "cover", className: "h-10 w-10 rounded-control bg-muted" })) : (_jsx(View, { className: "h-10 w-10 items-center justify-center", children: _jsx(Icon, { name: "file", color: secondary, size: 20 }) })), _jsxs(View, { className: "max-w-56", children: [_jsx(Line, { className: "text-sm text-ink", numberOfLines: 1, children: picked.name }), _jsx(Line, { className: "text-xs text-ink-secondary", children: `${isPhoto(picked) ? words.photo : words.pdf} · ${words.fileSize(picked.size)}` })] }), _jsx(Pressable, { role: "button", accessibilityLabel: `${words.remove} ${picked.name}`, hitSlop: 8, onPress: composer.remove, children: _jsx(Icon, { name: "close", color: secondary, size: 18 }) })] })) : null, _jsxs(View, { className: "flex-row items-end gap-1 rounded-[24px] border border-line-subtle bg-surface p-1.5 shadow-card", children: [composer.attach ? (_jsx(Pressable, { role: "button", accessibilityLabel: words.attach, onPress: composer.attach, className: "h-[42px] w-[42px] items-center justify-center rounded-pill active:bg-muted", children: _jsx(Icon, { name: "attach", color: secondary, size: 18 }) })) : null, _jsx(TextInput, { value: text, onChangeText: composer.onChangeText, placeholder: words.composerPlaceholder, placeholderTextColor: placeholder, accessibilityLabel: words.composerPlaceholder, multiline: true, ...Platform.select({ web: { rows: 1 }, default: {} }), className: "max-h-40 flex-1 self-center px-1 py-2 font-ui text-md leading-6 text-ink" }), _jsx(Pressable, { role: "button", accessibilityLabel: words.send, disabled: !canSend, onPress: composer.submit, className: `h-[42px] w-[42px] items-center justify-center rounded-pill bg-action active:bg-action-pressed ${canSend ? '' : 'opacity-40'}`, children: _jsx(View, { style: { transform: [{ rotate: '-90deg' }] }, children: _jsx(Icon, { name: "arrow-right", color: actionInk, size: 18 }) }) })] }), _jsx(Line, { className: "text-center text-xs text-ink-secondary", children: words.emergencyNote })] }));
50
+ const { text, picked, refused } = composer;
51
+ const canSend = composer.canSend && !composerClosed && !holding;
52
+ return (_jsxs(View, { style: { paddingBottom: Math.max(bottomInset ?? 0, MIN_BOTTOM_SPACE) }, className: "gap-2 px-gutter-phone pt-2 md:mx-auto md:w-full md:max-w-reading", children: [refused ? (_jsx(Line, { role: "alert", className: "text-sm text-error-ink", children: words.attachmentRefused[refused] })) : null, picked ? (_jsxs(View, { className: "flex-row items-center gap-3 self-start rounded-control border border-line-subtle bg-surface p-2 pr-3", children: [isPhoto(picked) ? (_jsx(Image, { source: { uri: picked.uri }, resizeMode: "cover", className: "h-10 w-10 rounded-control bg-muted" })) : (_jsx(View, { className: "h-10 w-10 items-center justify-center", children: _jsx(Icon, { name: "file", color: secondary, size: 20 }) })), _jsxs(View, { className: "max-w-56", children: [_jsx(Line, { className: "text-sm text-ink", numberOfLines: 1, children: picked.name }), _jsx(Line, { className: "text-xs text-ink-secondary", children: `${isPhoto(picked) ? words.photo : words.pdf} · ${words.fileSize(picked.size)}` })] }), _jsx(Pressable, { role: "button", accessibilityLabel: `${words.remove} ${picked.name}`, hitSlop: 8, onPress: composer.remove, children: _jsx(Icon, { name: "close", color: secondary, size: 18 }) })] })) : null, _jsxs(View, { className: "flex-row items-end gap-1 rounded-[24px] border border-line-subtle bg-surface p-1.5 shadow-card", children: [composer.attach && !composerClosed ? (_jsx(Pressable, { role: "button", accessibilityLabel: words.attach, onPress: composer.attach, className: "h-[42px] w-[42px] items-center justify-center rounded-pill active:bg-muted", children: _jsx(Icon, { name: "attach", color: secondary, size: 18 }) })) : null, _jsx(TextInput, { value: text, editable: !composerClosed, onChangeText: composer.onChangeText, placeholder: words.composerPlaceholder, placeholderTextColor: placeholder, accessibilityLabel: words.composerPlaceholder, multiline: true, ...Platform.select({ web: { rows: 1, onKeyPress: sendOnEnter(canSend, composer.submit) }, default: {} }), className: "max-h-40 flex-1 self-center px-1 py-2 font-ui text-md leading-6 text-ink" }), _jsx(Pressable, { role: "button", accessibilityLabel: words.send, disabled: !canSend, onPress: composer.submit, className: `h-[42px] w-[42px] items-center justify-center rounded-pill bg-action active:bg-action-pressed ${canSend ? '' : 'opacity-40'}`, children: _jsx(View, { style: { transform: [{ rotate: '-90deg' }] }, children: _jsx(Icon, { name: "arrow-right", color: actionInk, size: 18 }) }) })] }), words.emergencyNote ? (_jsx(Line, { className: "text-center text-xs text-ink-secondary", children: words.emergencyNote })) : null] }));
35
53
  }
36
54
  function ThreadRow({ item, words, onRetry, ...props }) {
37
55
  if (item.kind === 'notice') {
38
56
  return (_jsx(Line, { className: "self-center rounded-control border border-line-subtle px-3 py-2 text-sm text-ink-secondary", children: item.message.text }));
39
57
  }
40
- if (item.kind === 'unsent')
41
- return _jsx(Unsent, { message: item.message, words: words, onRetry: onRetry, Icon: props.Icon });
58
+ if (item.kind === 'unsent') {
59
+ return (_jsx(Unsent, { message: item.message, words: words, onRetry: onRetry, Icon: props.Icon, holding: props.holding ?? false }));
60
+ }
42
61
  const { sender, messages } = item;
43
62
  const mine = sender === 'mine';
44
63
  const name = mine ? words.you : sender === 'skip' ? words.skip : sender.name;
45
64
  // The run is in time order, so its time is its last line's.
46
65
  const sentAt = Math.max(...messages.map((m) => m.sentAt));
47
- return (_jsxs(View, { className: `max-w-[82%] gap-1 ${mine ? 'items-end self-end' : 'items-start self-start'}`, children: [messages.map((message, index) => (_jsxs(View, { className: "flex-row items-end gap-2", children: [mine ? null : index === messages.length - 1 ? (_jsx(SenderAvatar, { sender: sender, name: name, skipMark: props.skipMark })) : (_jsx(View, { className: "w-8" })), _jsxs(View, { className: `shrink gap-2 ${mine ? 'items-end' : 'items-start'}`, children: [message.attachments.map((attachment) => (_jsx(Attachment, { attachment: attachment, words: words, Icon: props.Icon }, attachment.url))), message.text ? _jsx(Bubble, { mine: mine, text: message.text }) : null] })] }, message.id))), _jsx(Line, { className: `px-1 text-xs text-ink-secondary ${mine ? '' : 'ml-10'}`, children: `${name} · ${words.messageTime(sentAt)}` })] }));
66
+ return (_jsxs(View, { className: `max-w-[82%] gap-1 ${mine ? 'items-end self-end' : 'items-start self-start'}`, children: [messages.map((message, index) => (_jsxs(View, { className: "flex-row items-end gap-2", children: [mine ? null : index === messages.length - 1 ? (_jsx(SenderAvatar, { sender: sender, name: name, skipMark: props.skipMark })) : (_jsx(View, { className: "w-8" })), _jsxs(View, { className: `shrink gap-2 ${mine ? 'items-end' : 'items-start'}`, children: [message.attachments.map((attachment) => (_jsx(Attachment, { attachment: attachment, words: words, Icon: props.Icon }, attachment.url))), message.text ? _jsx(Bubble, { mine: mine, text: message.text }) : null, props.choices && props.question?.message.id === message.id ? (_jsx(ChoiceRow, { question: props.question, choices: props.choices })) : null] })] }, message.id))), _jsx(Line, { className: `px-1 text-xs text-ink-secondary ${mine ? '' : 'ml-10'}`, children: `${name} · ${words.messageTime(sentAt)}` })] }));
67
+ }
68
+ function ChoiceRow({ question, choices }) {
69
+ return (_jsx(Choices, { items: question.items, question: question.message.text, sending: choices.sending, holding: choices.holding ?? false, failed: choices.failed === question.message.id, words: choices.words, onChoose: (answer) => {
70
+ choices.onChoose(question.message, answer);
71
+ } }));
48
72
  }
49
73
  // A failed line is a button to send it again, and its note is announced.
50
74
  const BUTTON_ROLE = 'button';
51
75
  const ALERT_ROLE = 'alert';
52
- function Unsent({ message, words, onRetry, Icon, }) {
76
+ /** The member's line while it is on its way, or after it failed, with its retry. */
77
+ export function Unsent({ message, words, onRetry, Icon, holding, }) {
53
78
  const failed = message.delivery === 'failed';
54
- return (_jsxs(Pressable, { ...(failed ? { role: BUTTON_ROLE } : {}), disabled: !failed, onPress: () => {
79
+ return (_jsxs(Pressable, { ...(failed ? { role: BUTTON_ROLE } : {}), disabled: !failed || holding, onPress: () => {
55
80
  onRetry(message.id);
56
81
  }, className: `max-w-[82%] items-end gap-1 self-end ${failed ? '' : 'opacity-70'}`, children: [message.file ? _jsx(PickedPreview, { picked: message.file, words: words, Icon: Icon }) : null, message.text ? _jsx(Bubble, { mine: true, text: message.text }) : null, _jsx(Line, { ...(failed ? { role: ALERT_ROLE } : {}), className: `px-1 text-xs ${failed ? 'text-error-ink' : 'text-ink-secondary'}`, children: `${words.you} · ${failed ? words.notSent : words.sending}` })] }));
57
82
  }