@nyaruka/temba-components 0.171.0 → 0.172.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/dist/temba-components.js +1015 -747
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/RapidElement.ts +83 -41
- package/src/display/Button.ts +14 -2
- package/src/display/Chat.ts +41 -22
- package/src/display/Label.ts +18 -3
- package/src/events/eventRenderers.ts +4 -0
- package/src/flow/FlowSearch.ts +12 -392
- package/src/flow/actions/send_msg.ts +2 -1
- package/src/flow/actions/set_contact_field.ts +1 -0
- package/src/flow/actions/set_contact_name.ts +1 -0
- package/src/flow/nodes/split_by_ticket.ts +1 -0
- package/src/flow/nodes/wait_for_dial.ts +1 -0
- package/src/form/RangePicker.ts +10 -2
- package/src/form/select/Select.ts +15 -2
- package/src/layout/SearchModal.ts +564 -0
- package/src/list/BroadcastList.ts +17 -6
- package/src/list/ContentList.ts +29 -13
- package/src/list/FieldList.ts +8 -1
- package/src/list/TembaMenu.ts +71 -13
- package/src/live/CampaignEvents.ts +31 -11
- package/src/live/ContactChat.ts +276 -76
- package/src/live/ContactTimeline.ts +46 -24
- package/src/live/ContactWatch.ts +166 -63
- package/src/live/Realtime.ts +137 -8
- package/src/live/SocketService.ts +115 -10
- package/src/live/TicketSearch.ts +290 -0
- package/src/live/Watchers.ts +93 -0
- package/src/simulator/Simulator.ts +59 -36
- package/src/store/Store.ts +17 -34
- package/src/styles/designTokens.ts +57 -17
- package/src/styles/pillVariants.ts +42 -10
- package/static/css/design-system.css +47 -12
- package/static/css/temba-components.css +31 -9
- package/temba-modules.ts +2 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Centrifuge, Subscription, SubscriptionState } from 'centrifuge';
|
|
2
|
+
import { Watchers } from './Watchers';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Access to our realtime messaging socket (centrifugo). The server lives
|
|
@@ -20,6 +21,11 @@ import { Centrifuge, Subscription, SubscriptionState } from 'centrifuge';
|
|
|
20
21
|
* channel - the underlying centrifugo subscription is created on first use
|
|
21
22
|
* and torn down when the last subscriber leaves. The connection itself stays
|
|
22
23
|
* open for the life of the page. Each published event arrives as raw JSON.
|
|
24
|
+
*
|
|
25
|
+
* The connection's state is readable too, so a page can tell its user when it
|
|
26
|
+
* has gone quiet rather than silently showing stale data:
|
|
27
|
+
*
|
|
28
|
+
* window.sockets.onConnectionState((state) => { ... });
|
|
23
29
|
*/
|
|
24
30
|
|
|
25
31
|
export interface SocketSubscription {
|
|
@@ -28,6 +34,19 @@ export interface SocketSubscription {
|
|
|
28
34
|
|
|
29
35
|
export type PublicationHandler = (data: any) => void;
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Where the shared connection is. Mirrors centrifugo's own client states -
|
|
39
|
+
* connecting covers the first attempt and every reconnect after a drop, so a
|
|
40
|
+
* page showing a "reconnecting" hint wants that one.
|
|
41
|
+
*/
|
|
42
|
+
export enum ConnectionState {
|
|
43
|
+
Disconnected = 'disconnected',
|
|
44
|
+
Connecting = 'connecting',
|
|
45
|
+
Connected = 'connected'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ConnectionStateHandler = (state: ConnectionState) => void;
|
|
49
|
+
|
|
31
50
|
export interface SocketProvider {
|
|
32
51
|
subscribe(
|
|
33
52
|
channel: string,
|
|
@@ -36,6 +55,10 @@ export interface SocketProvider {
|
|
|
36
55
|
): SocketSubscription;
|
|
37
56
|
|
|
38
57
|
publish(channel: string, data: any): Promise<void>;
|
|
58
|
+
|
|
59
|
+
getConnectionState(): ConnectionState;
|
|
60
|
+
|
|
61
|
+
onConnectionState(handler: ConnectionStateHandler): SocketSubscription;
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
interface ChannelEntry {
|
|
@@ -48,6 +71,13 @@ export class SocketManager implements SocketProvider {
|
|
|
48
71
|
private channels = new Map<string, ChannelEntry>();
|
|
49
72
|
private createSocket: () => Centrifuge;
|
|
50
73
|
|
|
74
|
+
// no connection exists until something asks for one, so that is where we
|
|
75
|
+
// start rather than pretending we know the server is unreachable
|
|
76
|
+
private state = ConnectionState.Disconnected;
|
|
77
|
+
private stateHandlers = new Watchers<ConnectionStateHandler>(
|
|
78
|
+
'socket connection handler'
|
|
79
|
+
);
|
|
80
|
+
|
|
51
81
|
constructor(createSocket?: () => Centrifuge) {
|
|
52
82
|
this.createSocket =
|
|
53
83
|
createSocket ||
|
|
@@ -67,33 +97,98 @@ export class SocketManager implements SocketProvider {
|
|
|
67
97
|
* and fan-out; a rejection means the publication was denied.
|
|
68
98
|
*/
|
|
69
99
|
public publish(channel: string, data: any): Promise<void> {
|
|
70
|
-
|
|
71
|
-
this.socket = this.createSocket();
|
|
72
|
-
}
|
|
100
|
+
const socket = this.ensureSocket();
|
|
73
101
|
|
|
74
102
|
// prefer the channel's live subscription when we have one
|
|
75
103
|
const entry = this.channels.get(channel);
|
|
76
104
|
const published = entry
|
|
77
105
|
? entry.sub.publish(data)
|
|
78
|
-
:
|
|
106
|
+
: socket.publish(channel, data);
|
|
79
107
|
return published.then(() => undefined);
|
|
80
108
|
}
|
|
81
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The shared connection, opened on first use. Its state is tracked from
|
|
112
|
+
* here on, seeded with whatever it is already in - creating it connects, so
|
|
113
|
+
* the first transition can happen before we are listening.
|
|
114
|
+
*/
|
|
115
|
+
private ensureSocket(): Centrifuge {
|
|
116
|
+
if (!this.socket) {
|
|
117
|
+
this.socket = this.createSocket();
|
|
118
|
+
this.socket.on('connecting', () =>
|
|
119
|
+
this.setState(ConnectionState.Connecting)
|
|
120
|
+
);
|
|
121
|
+
this.socket.on('connected', () =>
|
|
122
|
+
this.setState(ConnectionState.Connected)
|
|
123
|
+
);
|
|
124
|
+
this.socket.on('disconnected', () =>
|
|
125
|
+
this.setState(ConnectionState.Disconnected)
|
|
126
|
+
);
|
|
127
|
+
this.setState(this.socket.state as unknown as ConnectionState);
|
|
128
|
+
}
|
|
129
|
+
return this.socket;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// counts transitions, so an initial delivery can tell whether one beat it
|
|
133
|
+
// to the handler
|
|
134
|
+
private stateSeq = 0;
|
|
135
|
+
|
|
136
|
+
private setState(state: ConnectionState): void {
|
|
137
|
+
if (!state || state === this.state) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.state = state;
|
|
141
|
+
this.stateSeq++;
|
|
142
|
+
this.stateHandlers.each((handler) => handler(state));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Where the shared connection is right now. Disconnected until something
|
|
147
|
+
* subscribes or publishes, since that is what opens it.
|
|
148
|
+
*/
|
|
149
|
+
public getConnectionState(): ConnectionState {
|
|
150
|
+
return this.state;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Watches the connection. The handler is called on every transition, and
|
|
155
|
+
* once up front with the current state so a caller never has to pair this
|
|
156
|
+
* with getConnectionState to render.
|
|
157
|
+
*/
|
|
158
|
+
public onConnectionState(
|
|
159
|
+
handler: ConnectionStateHandler
|
|
160
|
+
): SocketSubscription {
|
|
161
|
+
this.stateHandlers.add(handler);
|
|
162
|
+
|
|
163
|
+
// where we are now, rather than wherever we've got to by the time the
|
|
164
|
+
// prime lands - opening the connection in this same tick transitions us
|
|
165
|
+
// and delivers that to the handler ahead of it, and saying the same thing
|
|
166
|
+
// again is the repeat this is supposed to suppress
|
|
167
|
+
const initial = this.state;
|
|
168
|
+
const seq = this.stateSeq;
|
|
169
|
+
this.stateHandlers.prime(handler, () => {
|
|
170
|
+
if (this.stateSeq === seq) {
|
|
171
|
+
handler(initial);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
unsubscribe: () => {
|
|
176
|
+
this.stateHandlers.remove(handler);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
82
181
|
public subscribe(
|
|
83
182
|
channel: string,
|
|
84
183
|
onPublication: PublicationHandler,
|
|
85
184
|
onSubscribed?: () => void
|
|
86
185
|
): SocketSubscription {
|
|
87
|
-
|
|
88
|
-
this.socket = this.createSocket();
|
|
89
|
-
}
|
|
186
|
+
const socket = this.ensureSocket();
|
|
90
187
|
|
|
91
188
|
let entry = this.channels.get(channel);
|
|
92
189
|
if (!entry) {
|
|
93
190
|
entry = {
|
|
94
|
-
sub:
|
|
95
|
-
this.socket.getSubscription(channel) ||
|
|
96
|
-
this.socket.newSubscription(channel),
|
|
191
|
+
sub: socket.getSubscription(channel) || socket.newSubscription(channel),
|
|
97
192
|
count: 0
|
|
98
193
|
};
|
|
99
194
|
this.channels.set(channel, entry);
|
|
@@ -174,6 +269,16 @@ export const publishToSocket = (channel: string, data: any): Promise<void> => {
|
|
|
174
269
|
return (provider || getManager()).publish(channel, data);
|
|
175
270
|
};
|
|
176
271
|
|
|
272
|
+
export const getSocketConnectionState = (): ConnectionState => {
|
|
273
|
+
return (provider || getManager()).getConnectionState();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export const onSocketConnectionState = (
|
|
277
|
+
handler: ConnectionStateHandler
|
|
278
|
+
): SocketSubscription => {
|
|
279
|
+
return (provider || getManager()).onConnectionState(handler);
|
|
280
|
+
};
|
|
281
|
+
|
|
177
282
|
// for tests to swap in a mock provider, returns the previous provider
|
|
178
283
|
export const setSocketProvider = (newProvider: SocketProvider) => {
|
|
179
284
|
const previous = provider;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { css, html, TemplateResult } from 'lit';
|
|
2
|
+
import { property, state } from 'lit/decorators.js';
|
|
3
|
+
import { ModalSearchResult, SearchModal } from '../layout/SearchModal';
|
|
4
|
+
import { getUrl } from '../utils';
|
|
5
|
+
|
|
6
|
+
// The matched message event as the endpoint returns it
|
|
7
|
+
export interface TicketSearchEvent {
|
|
8
|
+
uuid: string;
|
|
9
|
+
type: string;
|
|
10
|
+
created_on: string;
|
|
11
|
+
ticket_uuid?: string;
|
|
12
|
+
msg?: { text?: string };
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TicketSearchResult extends ModalSearchResult {
|
|
17
|
+
contact: { uuid: string; name: string };
|
|
18
|
+
ticket: { uuid: string; status: string };
|
|
19
|
+
event: TicketSearchEvent;
|
|
20
|
+
// The query that produced this result, so selection handlers can re-run
|
|
21
|
+
// the search within the ticket
|
|
22
|
+
query: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BADGE_COLOR = '#6b7280';
|
|
26
|
+
|
|
27
|
+
// rough number of characters that fit on a result row - the snippet is
|
|
28
|
+
// windowed around the match so the match stays visible if CSS clips the tail
|
|
29
|
+
const SNIPPET_BUDGET = 60;
|
|
30
|
+
|
|
31
|
+
// the shortest prefix we'll fall back to when a query term doesn't appear
|
|
32
|
+
// literally - short enough to cover most inflections, long enough that the
|
|
33
|
+
// highlight still points at the word the user searched for
|
|
34
|
+
const MIN_PREFIX = 4;
|
|
35
|
+
|
|
36
|
+
// strips everything but letters and numbers, so punctuation in the query
|
|
37
|
+
// doesn't keep a term from matching the message text
|
|
38
|
+
const stripNonWord = (text: string) => text.replace(/[^\p{L}\p{N}]/gu, '');
|
|
39
|
+
|
|
40
|
+
// same, but only at the ends, so the words of a multi-word query stay apart
|
|
41
|
+
const stripOuterNonWord = (text: string) =>
|
|
42
|
+
text.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
|
|
43
|
+
|
|
44
|
+
// the candidates to look for, in priority order, without duplicates
|
|
45
|
+
const withoutDupes = (candidates: string[]) =>
|
|
46
|
+
candidates.filter(
|
|
47
|
+
(candidate, index) => !!candidate && candidates.indexOf(candidate) === index
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Searches message text across all of an org's ticket chats. Searching hits
|
|
52
|
+
* an endpoint, so it waits for Enter rather than running on every keystroke.
|
|
53
|
+
*/
|
|
54
|
+
export class TicketSearch extends SearchModal<TicketSearchResult> {
|
|
55
|
+
static styles = [
|
|
56
|
+
SearchModal.styles,
|
|
57
|
+
css`
|
|
58
|
+
.result-type-badge {
|
|
59
|
+
max-width: 35%;
|
|
60
|
+
overflow: hidden;
|
|
61
|
+
text-overflow: ellipsis;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.result-text {
|
|
65
|
+
flex-grow: 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.result-date {
|
|
69
|
+
flex-shrink: 0;
|
|
70
|
+
font-size: 11px;
|
|
71
|
+
color: #9ca3af;
|
|
72
|
+
}
|
|
73
|
+
`
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
@property({ type: String })
|
|
77
|
+
endpoint = '/ticket/search/';
|
|
78
|
+
|
|
79
|
+
// searching hits an endpoint, so wait for Enter
|
|
80
|
+
searchOnEnter = true;
|
|
81
|
+
|
|
82
|
+
// how many matches the last search found in tickets this user can't access,
|
|
83
|
+
// so an empty result set can say why it's empty
|
|
84
|
+
@state()
|
|
85
|
+
private dropped = 0;
|
|
86
|
+
|
|
87
|
+
// the in-flight request, aborted when it's superseded or the modal closes
|
|
88
|
+
private abortController: AbortController = null;
|
|
89
|
+
|
|
90
|
+
protected cancelSearch(): void {
|
|
91
|
+
this.abortSearch();
|
|
92
|
+
this.dropped = 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private abortSearch(): void {
|
|
96
|
+
if (this.abortController) {
|
|
97
|
+
this.abortController.abort();
|
|
98
|
+
this.abortController = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
protected getSearchLabel(): string {
|
|
103
|
+
return 'Search tickets';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
protected renderHint(): TemplateResult {
|
|
107
|
+
return html`<div class="hint">
|
|
108
|
+
<kbd>Enter</kbd> to search <kbd>Esc</kbd> to close
|
|
109
|
+
</div>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The endpoint filters matches down to the tickets this user can access
|
|
114
|
+
* after the search itself is capped, so a query that matched plenty can
|
|
115
|
+
* still come back empty. Say so rather than claiming nothing matched.
|
|
116
|
+
*/
|
|
117
|
+
protected getNoResultsMessage(): string {
|
|
118
|
+
return this.dropped > 0
|
|
119
|
+
? 'No matches in tickets you can access'
|
|
120
|
+
: super.getNoResultsMessage();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected async performSearch(query: string): Promise<TicketSearchResult[]> {
|
|
124
|
+
this.abortSearch();
|
|
125
|
+
this.dropped = 0;
|
|
126
|
+
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
this.abortController = controller;
|
|
129
|
+
|
|
130
|
+
const joiner = this.endpoint.includes('?') ? '&' : '?';
|
|
131
|
+
const response = await getUrl(
|
|
132
|
+
`${this.endpoint}${joiner}text=${encodeURIComponent(query)}`,
|
|
133
|
+
controller
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (this.abortController === controller) {
|
|
137
|
+
this.abortController = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const json = response.json as any;
|
|
141
|
+
this.dropped = json.dropped || 0;
|
|
142
|
+
|
|
143
|
+
const results = json.results || [];
|
|
144
|
+
return results.map((result: any) => this.toSearchResult(result, query));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Locates a term in the message text, falling back to progressively
|
|
149
|
+
* shorter prefixes of it. The backend matches on analyzed text, so a query
|
|
150
|
+
* term ("helping") can match a message that only contains a stemmed or
|
|
151
|
+
* inflected form of it ("helped") - highlighting the longest prefix that
|
|
152
|
+
* does appear keeps the match visible rather than showing nothing.
|
|
153
|
+
*/
|
|
154
|
+
private findTerm(
|
|
155
|
+
lowerText: string,
|
|
156
|
+
term: string,
|
|
157
|
+
minLength: number
|
|
158
|
+
): { start: number; length: number } | null {
|
|
159
|
+
for (let length = term.length; length >= minLength; length--) {
|
|
160
|
+
const idx = lowerText.indexOf(term.slice(0, length));
|
|
161
|
+
if (idx !== -1) {
|
|
162
|
+
return { start: idx, length };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private toSearchResult(result: any, query: string): TicketSearchResult {
|
|
169
|
+
const text = result.event?.msg?.text || result.event?.text || '';
|
|
170
|
+
const lowerText = text.toLowerCase();
|
|
171
|
+
const lowerQuery = query.trim().toLowerCase();
|
|
172
|
+
|
|
173
|
+
let matchStart = -1;
|
|
174
|
+
let matchLength = 0;
|
|
175
|
+
|
|
176
|
+
// highlight the whole query if it appears verbatim (with or without any
|
|
177
|
+
// punctuation it's wrapped in), otherwise the first matching term - the
|
|
178
|
+
// backend matches terms independently, and against analyzed text, so a
|
|
179
|
+
// term is tried both as typed and stripped of its punctuation
|
|
180
|
+
const queries = withoutDupes([lowerQuery, stripOuterNonWord(lowerQuery)]);
|
|
181
|
+
for (const candidate of queries) {
|
|
182
|
+
const idx = lowerText.indexOf(candidate);
|
|
183
|
+
if (idx !== -1) {
|
|
184
|
+
matchStart = idx;
|
|
185
|
+
matchLength = candidate.length;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const terms = withoutDupes(
|
|
191
|
+
lowerQuery.split(/\s+/).reduce((all: string[], term) => {
|
|
192
|
+
all.push(term, stripNonWord(term));
|
|
193
|
+
return all;
|
|
194
|
+
}, [])
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// literal matches first, so an exact hit on a later term beats a
|
|
198
|
+
// prefix hit on an earlier one
|
|
199
|
+
for (const minLength of [Infinity, MIN_PREFIX]) {
|
|
200
|
+
if (matchStart !== -1) {
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
for (const term of terms) {
|
|
204
|
+
const match = this.findTerm(
|
|
205
|
+
lowerText,
|
|
206
|
+
term,
|
|
207
|
+
Math.min(minLength, term.length)
|
|
208
|
+
);
|
|
209
|
+
if (match) {
|
|
210
|
+
matchStart = match.start;
|
|
211
|
+
matchLength = match.length;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (matchStart === -1) {
|
|
218
|
+
matchStart = 0;
|
|
219
|
+
matchLength = 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
typeName: result.contact?.name || 'Unknown',
|
|
224
|
+
color: BADGE_COLOR,
|
|
225
|
+
fullText: text,
|
|
226
|
+
matchStart,
|
|
227
|
+
matchLength,
|
|
228
|
+
contact: result.contact,
|
|
229
|
+
ticket: result.ticket,
|
|
230
|
+
event: result.event,
|
|
231
|
+
query: query.trim()
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Unlike the base (which anchors the match near the start and lets CSS
|
|
237
|
+
* clip the tail), message snippets are windowed to roughly center the
|
|
238
|
+
* match, with ellipses marking clipped context on either side.
|
|
239
|
+
*/
|
|
240
|
+
protected renderMatchText(result: TicketSearchResult): TemplateResult {
|
|
241
|
+
const text = result.fullText.replace(/\n/g, ' ');
|
|
242
|
+
const { matchStart, matchLength } = result;
|
|
243
|
+
|
|
244
|
+
if (matchLength === 0) {
|
|
245
|
+
return html`${text}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const matchEnd = matchStart + matchLength;
|
|
249
|
+
const highlight = (start: number, end: number) =>
|
|
250
|
+
html`${start > 0 ? '…' : ''}${text.slice(
|
|
251
|
+
start,
|
|
252
|
+
matchStart
|
|
253
|
+
)}<mark>${text.slice(matchStart, matchEnd)}</mark>${text.slice(
|
|
254
|
+
matchEnd,
|
|
255
|
+
end
|
|
256
|
+
)}${end < text.length ? '…' : ''}`;
|
|
257
|
+
|
|
258
|
+
if (text.length <= SNIPPET_BUDGET) {
|
|
259
|
+
return highlight(0, text.length);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// center the match in the character budget, shifting the window back
|
|
263
|
+
// into range when the match sits near either end of the text
|
|
264
|
+
const context = Math.max(0, SNIPPET_BUDGET - matchLength);
|
|
265
|
+
let start = matchStart - Math.floor(context / 2);
|
|
266
|
+
let end = matchEnd + Math.ceil(context / 2);
|
|
267
|
+
if (start < 0) {
|
|
268
|
+
end = Math.min(text.length, end - start);
|
|
269
|
+
start = 0;
|
|
270
|
+
}
|
|
271
|
+
if (end > text.length) {
|
|
272
|
+
start = Math.max(0, start - (end - text.length));
|
|
273
|
+
end = text.length;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return highlight(start, end);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
protected renderResultContent(result: TicketSearchResult): TemplateResult {
|
|
280
|
+
return html`
|
|
281
|
+
${super.renderResultContent(result)}
|
|
282
|
+
<div class="result-date">
|
|
283
|
+
<temba-date
|
|
284
|
+
value=${result.event?.created_on}
|
|
285
|
+
display="duration"
|
|
286
|
+
></temba-date>
|
|
287
|
+
</div>
|
|
288
|
+
`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list of subscribers with safe fan-out.
|
|
3
|
+
*
|
|
4
|
+
* Every registry we keep - contact watchers, the store's asset watchers,
|
|
5
|
+
* socket connection listeners - hands events to page components we don't
|
|
6
|
+
* control, so they all need the same three things: one subscriber throwing
|
|
7
|
+
* can't cost the others their delivery, a delivery iterates a snapshot so
|
|
8
|
+
* unsubscribing mid-fan-out doesn't shift the list underneath it, and
|
|
9
|
+
* unsubscribing twice does nothing the second time.
|
|
10
|
+
*
|
|
11
|
+
* The subscriber record and how an event reaches it belong to the caller -
|
|
12
|
+
* interests, payloads and arities differ - so this owns only the list and the
|
|
13
|
+
* delivery discipline.
|
|
14
|
+
*/
|
|
15
|
+
export class Watchers<W> {
|
|
16
|
+
private watchers: W[] = [];
|
|
17
|
+
|
|
18
|
+
// names the subscriber when reporting one that threw
|
|
19
|
+
private label: string;
|
|
20
|
+
|
|
21
|
+
constructor(label: string) {
|
|
22
|
+
this.label = label;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public get size(): number {
|
|
26
|
+
return this.watchers.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public has(watcher: W): boolean {
|
|
30
|
+
return this.watchers.includes(watcher);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public some(predicate: (watcher: W) => boolean): boolean {
|
|
34
|
+
return this.watchers.some(predicate);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A snapshot, for callers that need to read across everyone registered. */
|
|
38
|
+
public all(): W[] {
|
|
39
|
+
return [...this.watchers];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public add(watcher: W): void {
|
|
43
|
+
this.watchers.push(watcher);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Removes it, reporting whether it was still registered. */
|
|
47
|
+
public remove(watcher: W): boolean {
|
|
48
|
+
const index = this.watchers.indexOf(watcher);
|
|
49
|
+
if (index < 0) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
this.watchers.splice(index, 1);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public clear(): void {
|
|
57
|
+
this.watchers.length = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One delivery, with a subscriber's failure kept to itself. */
|
|
61
|
+
public deliver(watcher: W, deliver: (watcher: W) => void): void {
|
|
62
|
+
try {
|
|
63
|
+
deliver(watcher);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`${this.label} failed`, error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Delivers to everyone registered now, or just those matching. */
|
|
70
|
+
public each(
|
|
71
|
+
deliver: (watcher: W) => void,
|
|
72
|
+
matches?: (watcher: W) => boolean
|
|
73
|
+
): void {
|
|
74
|
+
for (const watcher of this.all()) {
|
|
75
|
+
if (!matches || matches(watcher)) {
|
|
76
|
+
this.deliver(watcher, deliver);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* An initial delivery to one subscriber, off the current task so it lands
|
|
83
|
+
* the way a live one would rather than before they can use the handle they
|
|
84
|
+
* are being given. Skipped if they leave before it lands.
|
|
85
|
+
*/
|
|
86
|
+
public prime(watcher: W, deliver: (watcher: W) => void): void {
|
|
87
|
+
Promise.resolve().then(() => {
|
|
88
|
+
if (this.has(watcher)) {
|
|
89
|
+
this.deliver(watcher, deliver);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -11,28 +11,49 @@ import { CustomEventType } from '../interfaces';
|
|
|
11
11
|
import { Chat, ContactEvent, MessageType } from '../display/Chat';
|
|
12
12
|
import { Events, renderEvent } from '../events/eventRenderers';
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
]
|
|
14
|
+
export interface SampleMedia {
|
|
15
|
+
images: string[];
|
|
16
|
+
videos: string[];
|
|
17
|
+
audio: string[];
|
|
18
|
+
locations: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// the sample media the simulator attaches, hosted alongside our other
|
|
22
|
+
// simulator assets
|
|
23
|
+
const HOSTED_SAMPLES: SampleMedia = {
|
|
24
|
+
images: [
|
|
25
|
+
'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_a.jpg',
|
|
26
|
+
'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_b.jpg',
|
|
27
|
+
'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_c.jpg',
|
|
28
|
+
'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_d.jpg'
|
|
29
|
+
],
|
|
30
|
+
videos: [
|
|
31
|
+
'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_video_a.mp4'
|
|
32
|
+
],
|
|
33
|
+
audio: [
|
|
34
|
+
'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_audio_a.mp3'
|
|
35
|
+
],
|
|
36
|
+
locations: [
|
|
37
|
+
'geo:47.6062,-122.3321', // Seattle
|
|
38
|
+
'geo:-0.1807,-78.4678', // Quito
|
|
39
|
+
'geo:-2.9001,-79.0059', // Cuenca
|
|
40
|
+
'geo:-1.9536,30.0606' // Kigali
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let samples: SampleMedia = HOSTED_SAMPLES;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Points the simulator's sample attachments somewhere else, returning the
|
|
48
|
+
* previous set so a caller can put it back. For tests, which shouldn't need
|
|
49
|
+
* our asset host to be reachable to run - the simulator itself always
|
|
50
|
+
* attaches the hosted samples above.
|
|
51
|
+
*/
|
|
52
|
+
export const setSampleMedia = (media: Partial<SampleMedia>): SampleMedia => {
|
|
53
|
+
const previous = samples;
|
|
54
|
+
samples = { ...samples, ...media };
|
|
55
|
+
return previous;
|
|
56
|
+
};
|
|
36
57
|
|
|
37
58
|
const truncateUrl = (url: string, maxLen = 50): string =>
|
|
38
59
|
url && url.length > maxLen ? url.slice(0, maxLen) + '…' : url;
|
|
@@ -888,14 +909,15 @@ export class Simulator extends RapidElement {
|
|
|
888
909
|
private boundClickOutsideHandler: ((event: MouseEvent) => void) | null = null;
|
|
889
910
|
|
|
890
911
|
// attachment cycling indices - initialized randomly
|
|
891
|
-
private imageIndex = Math.floor(Math.random() *
|
|
892
|
-
private videoIndex = Math.floor(Math.random() *
|
|
893
|
-
private audioIndex = Math.floor(Math.random() *
|
|
894
|
-
private locationIndex = Math.floor(Math.random() *
|
|
912
|
+
private imageIndex = Math.floor(Math.random() * samples.images.length);
|
|
913
|
+
private videoIndex = Math.floor(Math.random() * samples.videos.length);
|
|
914
|
+
private audioIndex = Math.floor(Math.random() * samples.audio.length);
|
|
915
|
+
private locationIndex = Math.floor(Math.random() * samples.locations.length);
|
|
895
916
|
|
|
896
|
-
//
|
|
917
|
+
// starts the attachment cycling from the top, so a test gets the same
|
|
918
|
+
// sample every run rather than wherever the random start landed
|
|
897
919
|
public resetAttachmentIndices() {
|
|
898
|
-
this.imageIndex =
|
|
920
|
+
this.imageIndex = 0;
|
|
899
921
|
this.videoIndex = 0;
|
|
900
922
|
this.audioIndex = 0;
|
|
901
923
|
this.locationIndex = 0;
|
|
@@ -2176,20 +2198,21 @@ export class Simulator extends RapidElement {
|
|
|
2176
2198
|
let attachment = '';
|
|
2177
2199
|
switch (attachmentType) {
|
|
2178
2200
|
case 'image':
|
|
2179
|
-
attachment = `image/jpeg:${
|
|
2180
|
-
this.imageIndex = (this.imageIndex + 1) %
|
|
2201
|
+
attachment = `image/jpeg:${samples.images[this.imageIndex]}`;
|
|
2202
|
+
this.imageIndex = (this.imageIndex + 1) % samples.images.length;
|
|
2181
2203
|
break;
|
|
2182
2204
|
case 'video':
|
|
2183
|
-
attachment = `video/mp4:${
|
|
2184
|
-
this.videoIndex = (this.videoIndex + 1) %
|
|
2205
|
+
attachment = `video/mp4:${samples.videos[this.videoIndex]}`;
|
|
2206
|
+
this.videoIndex = (this.videoIndex + 1) % samples.videos.length;
|
|
2185
2207
|
break;
|
|
2186
2208
|
case 'audio':
|
|
2187
|
-
attachment = `audio/mp3:${
|
|
2188
|
-
this.audioIndex = (this.audioIndex + 1) %
|
|
2209
|
+
attachment = `audio/mp3:${samples.audio[this.audioIndex]}`;
|
|
2210
|
+
this.audioIndex = (this.audioIndex + 1) % samples.audio.length;
|
|
2189
2211
|
break;
|
|
2190
2212
|
case 'location':
|
|
2191
|
-
attachment =
|
|
2192
|
-
this.locationIndex =
|
|
2213
|
+
attachment = samples.locations[this.locationIndex];
|
|
2214
|
+
this.locationIndex =
|
|
2215
|
+
(this.locationIndex + 1) % samples.locations.length;
|
|
2193
2216
|
break;
|
|
2194
2217
|
}
|
|
2195
2218
|
|