@joinskips/brand 9.0.0-develop.147 → 9.0.0-develop.148
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/chat/helpers/cable.d.ts +34 -0
- package/chat/helpers/cable.js +88 -0
- package/chat/helpers/cableFrames.d.ts +40 -0
- package/chat/helpers/cableFrames.js +70 -0
- package/chat/helpers/index.d.ts +3 -0
- package/chat/helpers/index.js +3 -0
- package/chat/helpers/skipsMessage.d.ts +52 -0
- package/chat/helpers/skipsMessage.js +129 -0
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ChatEvent } from './cableFrames.ts';
|
|
2
|
+
export declare const STALE_MS = 10000;
|
|
3
|
+
export declare const FIRST_RETRY_MS = 1000;
|
|
4
|
+
export declare const MAX_RETRY_MS = 30000;
|
|
5
|
+
export type CableState = 'connecting' | 'live' | 'down';
|
|
6
|
+
export interface CableOptions {
|
|
7
|
+
careTeamName: string;
|
|
8
|
+
createSocket: (url: string) => Socket;
|
|
9
|
+
now: () => number;
|
|
10
|
+
}
|
|
11
|
+
export interface CableHandlers {
|
|
12
|
+
onEvent: (event: ChatEvent) => void;
|
|
13
|
+
onState: (state: CableState) => void;
|
|
14
|
+
onKeysRefused: () => void;
|
|
15
|
+
}
|
|
16
|
+
/** The socket address for a Skips App origin, such as https://app.joinskips.com. */
|
|
17
|
+
export declare const cableUrl: (origin: string) => string;
|
|
18
|
+
/** The wait before the nth reconnect, doubling up to MAX_RETRY_MS. */
|
|
19
|
+
export declare const retryDelay: (attempt: number) => number;
|
|
20
|
+
export interface SocketMessage {
|
|
21
|
+
data: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface Socket extends Pick<WebSocket, 'send' | 'close'> {
|
|
24
|
+
onopen: ((event: unknown) => void) | null;
|
|
25
|
+
onmessage: ((event: SocketMessage) => void) | null;
|
|
26
|
+
onclose: ((event: unknown) => void) | null;
|
|
27
|
+
onerror: ((event: unknown) => void) | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Opens the feed for the member's keys, the Skips App's pubsub tokens, on sockets the app makes with createSocket.
|
|
31
|
+
* now is the app's clock, which the stale check reads.
|
|
32
|
+
* Returns a function that closes it for good.
|
|
33
|
+
*/
|
|
34
|
+
export declare function openCable(url: string, keys: string[], handlers: CableHandlers, { careTeamName, createSocket, now }: CableOptions): () => void;
|
|
@@ -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
|
+
}
|
package/chat/helpers/index.d.ts
CHANGED
|
@@ -4,4 +4,7 @@ export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, pendingId, withFailed, witho
|
|
|
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';
|
package/chat/helpers/index.js
CHANGED
|
@@ -4,4 +4,7 @@ export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, pendingId, withFailed, witho
|
|
|
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";
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinskips/brand",
|
|
3
|
-
"version": "9.0.0-develop.
|
|
3
|
+
"version": "9.0.0-develop.148",
|
|
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",
|