@nyaruka/temba-components 0.163.0 → 0.165.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 +27 -0
- package/DEV_DATA.md +11 -11
- package/README.md +4 -4
- package/TEST_OPTIMIZATION.md +8 -11
- package/dist/locales/es.js +4 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +4 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +4 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +5266 -3031
- package/dist/temba-components.js.map +1 -1
- package/orca/setup.sh +2 -2
- package/package.json +13 -18
- package/scripts/dev-data-sync.mjs +4 -4
- package/src/Icons.ts +3 -2
- package/src/display/Chat.ts +3 -2
- package/src/display/Lightbox.ts +57 -109
- package/src/display/ProgressBar.ts +15 -2
- package/src/display/Thumbnail.ts +34 -7
- package/src/form/Compose.ts +31 -1
- package/src/form/ContactSearch.ts +229 -126
- package/src/interfaces.ts +131 -2
- package/src/layout/Card.ts +336 -0
- package/src/layout/CardLayout.ts +425 -0
- package/src/layout/CardStack.ts +131 -0
- package/src/layout/Dialog.ts +33 -24
- package/src/layout/HeaderBar.ts +41 -0
- package/src/layout/PageHeader.ts +5 -6
- package/src/layout/Resizer.ts +20 -0
- package/src/list/BroadcastList.ts +912 -0
- package/src/list/CampaignList.ts +144 -0
- package/src/list/ContactList.ts +3 -1
- package/src/list/ContentList.ts +46 -10
- package/src/list/FieldList.ts +1057 -0
- package/src/list/FlowList.ts +31 -7
- package/src/list/MsgList.ts +18 -12
- package/src/list/SortableList.ts +52 -8
- package/src/list/TembaList.ts +8 -0
- package/src/list/TembaMenu.ts +5 -38
- package/src/list/TriggerList.ts +538 -0
- package/src/live/CampaignEvents.ts +1108 -0
- package/src/live/ContactChat.ts +7 -1
- package/src/live/ContactDetails.ts +20 -14
- package/src/live/ContactFieldEditor.ts +7 -3
- package/src/live/ContactFields.ts +1 -1
- package/src/live/ContactNameFetch.ts +5 -2
- package/src/live/ContactNotepad.ts +88 -2
- package/src/live/ContactStoreElement.ts +15 -3
- package/src/live/ContactTimeline.ts +28 -7
- package/src/locales/es.ts +4 -0
- package/src/locales/fr.ts +4 -0
- package/src/locales/pt.ts +4 -0
- package/src/utils.ts +19 -0
- package/static/svg/index.svg +1 -1
- package/static/svg/packs/2-temba/star-filled.svg +3 -0
- package/static/svg/work/traced/star-filled.svg +1 -0
- package/static/svg/work/used/star-filled.svg +3 -0
- package/stress-test.js +3 -3
- package/temba-modules.ts +18 -0
- package/web-dev-server.config.mjs +54 -0
- package/web-test-runner.config.mjs +1 -1
- package/xliff/es.xlf +12 -0
- package/xliff/fr.xlf +12 -0
- package/xliff/pt.xlf +12 -0
package/orca/setup.sh
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Orca setup hook for a temba-components worktree.
|
|
4
4
|
# Symlinks shared utility files from nyaruka/utils, ensures the devcontainer
|
|
5
5
|
# is built/running (recreating it if the bind mounts are stale), and installs
|
|
6
|
-
#
|
|
6
|
+
# bun dependencies inside the worktree.
|
|
7
7
|
#
|
|
8
8
|
# Usage:
|
|
9
9
|
# ./orca/setup.sh # run directly
|
|
@@ -75,7 +75,7 @@ esac
|
|
|
75
75
|
# Install dependencies inside the worktree.
|
|
76
76
|
docker exec "$CONTAINER_NAME" bash -c '
|
|
77
77
|
cd "/workspaces/worktrees/temba-components/'"$WORKSPACE_NAME"'"
|
|
78
|
-
|
|
78
|
+
bun install
|
|
79
79
|
'
|
|
80
80
|
|
|
81
81
|
echo "Worktree '$WORKSPACE_NAME' ready for development"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyaruka/temba-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.165.0",
|
|
4
4
|
"description": "Web components to support rapidpro and related projects",
|
|
5
5
|
"author": "Nyaruka <code@nyaruka.coim>",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,25 +9,25 @@
|
|
|
9
9
|
"license": "AGPL-3.0-only",
|
|
10
10
|
"repository": "https://github.com/nyaruka/temba-components/",
|
|
11
11
|
"scripts": {
|
|
12
|
-
"start": "concurrently --kill-others --names tsc,web-dev-server \"
|
|
12
|
+
"start": "concurrently --kill-others --names tsc,web-dev-server \"bun run tsc:watch\" \"web-dev-server --app-index demo/index.html --node-resolve --watch --port 3010 --esbuild-target auto\"",
|
|
13
13
|
"tsc:watch": "tsc --watch",
|
|
14
|
-
"build": "rimraf dist &&
|
|
14
|
+
"build": "rimraf dist && bun run svg && tsc && rollup -c rollup.components.mjs ",
|
|
15
15
|
"build-wc": "rollup -c rollup.webchat.mjs",
|
|
16
|
-
"dev": "
|
|
17
|
-
"pre-commit": "
|
|
16
|
+
"dev": "bun run build && cp -R ./dist/* ../temba/rapidpro/node_modules/@nyaruka/temba-components/dist/ && cp -R ./dist/* ../floweditor/node_modules/@nyaruka/temba-components/dist/",
|
|
17
|
+
"pre-commit": "bun run locale:extract && bun run locale:build && bun run svg && git add ./src/Icons.ts ./static/svg/index.svg ./xliff ./src/locales && lint-staged",
|
|
18
18
|
"format:eslint": "eslint --ext .ts . --fix --ignore-path .gitignore --ignore-pattern \"src/locales/\"",
|
|
19
19
|
"format:prettier": "prettier \"**/*.js\" \"**/*.ts\" \"!src/locales/**\" --config .prettierrc --write --ignore-path .gitignore",
|
|
20
|
-
"format": "
|
|
20
|
+
"format": "bun run format:eslint && bun run format:prettier",
|
|
21
21
|
"test": "wtr --node-resolve",
|
|
22
22
|
"test:fast": "wtr --node-resolve --fast",
|
|
23
23
|
"test:coverage": "wtr --node-resolve --coverage",
|
|
24
24
|
"check-coverage": "node check-coverage.js",
|
|
25
|
-
"validate": "
|
|
25
|
+
"validate": "bun run format && bun run build && bun run test:coverage && bun run check-coverage",
|
|
26
26
|
"test:watch": "wtr --node-resolve --watch",
|
|
27
27
|
"stress-test": "node stress-test.js",
|
|
28
28
|
"svg": "rimraf static/svg/work && node svg.js --resolution=150 --output='./static/svg/index.svg' --usage='./src/Icons.ts'",
|
|
29
29
|
"svg-wc": "rimraf static/svg/work && node svg.js --resolution=150 --output='./static/svg/webchat.svg' --usage='./src/webchat/index.ts'",
|
|
30
|
-
"version": "
|
|
30
|
+
"version": "bun run build && auto-changelog -p && git add CHANGELOG.md",
|
|
31
31
|
"locale:extract": "lit-localize extract --config localize.config.json",
|
|
32
32
|
"locale:build": "echo 'Building localizations..' && lit-localize build --config localize.config.json > dist/locale.out 2>&1",
|
|
33
33
|
"dev-data:sync": "node scripts/dev-data-sync.mjs copy",
|
|
@@ -112,14 +112,9 @@
|
|
|
112
112
|
"typescript": "5.9.3",
|
|
113
113
|
"yargs": "^17.7.2"
|
|
114
114
|
},
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"@types/minimatch": "5.1.2",
|
|
121
|
-
"@types/chai": "4.3.20"
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
"packageManager": "pnpm@9.15.4"
|
|
115
|
+
"overrides": {
|
|
116
|
+
"@open-wc/testing-helpers": "3.0.0",
|
|
117
|
+
"@types/minimatch": "5.1.2",
|
|
118
|
+
"@types/chai": "4.3.20"
|
|
119
|
+
}
|
|
125
120
|
}
|
|
@@ -117,7 +117,7 @@ function showStatus() {
|
|
|
117
117
|
|
|
118
118
|
if (!fs.existsSync(DEV_DATA_DIR)) {
|
|
119
119
|
console.log('❌ No development data directory found');
|
|
120
|
-
console.log('💡 Run the dev server to initialize, or use "
|
|
120
|
+
console.log('💡 Run the dev server to initialize, or use "bun run dev-data:reset"');
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
|
|
@@ -175,8 +175,8 @@ switch (command) {
|
|
|
175
175
|
console.log(' reset/wipe - Wipe dev data and restore defaults');
|
|
176
176
|
console.log(' status - Show current status');
|
|
177
177
|
console.log('');
|
|
178
|
-
console.log('Usage:
|
|
179
|
-
console.log('
|
|
180
|
-
console.log('
|
|
178
|
+
console.log('Usage: bun run dev-data:copy');
|
|
179
|
+
console.log(' bun run dev-data:reset');
|
|
180
|
+
console.log(' bun run dev-data:status');
|
|
181
181
|
process.exit(1);
|
|
182
182
|
}
|
package/src/Icons.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-duplicate-enum-values */
|
|
2
|
-
// for cache busting we dynamically generate a fingerprint, use
|
|
3
|
-
export const SVG_FINGERPRINT = '
|
|
2
|
+
// for cache busting we dynamically generate a fingerprint, use bun run svg to update
|
|
3
|
+
export const SVG_FINGERPRINT = '27198f759c371df944f8ec5de0a29dd6';
|
|
4
4
|
|
|
5
5
|
// only icons below are included in the sprite sheet
|
|
6
6
|
export enum Icon {
|
|
@@ -132,6 +132,7 @@ export enum Icon {
|
|
|
132
132
|
progress_spinner = 'refresh-cw-04',
|
|
133
133
|
refresh = 'refresh-cw-05',
|
|
134
134
|
featured = 'star-01',
|
|
135
|
+
featured_filled = 'star-filled',
|
|
135
136
|
quick_replies = 'dotpoints-01',
|
|
136
137
|
recording = 'microphone-01',
|
|
137
138
|
resend = 'refresh-cw-05',
|
package/src/display/Chat.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { RapidElement } from '../RapidElement';
|
|
|
5
5
|
import { CustomEventType } from '../interfaces';
|
|
6
6
|
import { TicketEvent } from '../events';
|
|
7
7
|
import { DEFAULT_AVATAR } from '../webchat/assets';
|
|
8
|
+
import { attachmentAsString } from '../utils';
|
|
8
9
|
|
|
9
10
|
const BATCH_TIME_WINDOW = 60 * 60 * 1000;
|
|
10
11
|
const SCROLL_FETCH_BUFFER = 200; // pixels from top
|
|
@@ -1446,7 +1447,7 @@ export class Chat extends RapidElement {
|
|
|
1446
1447
|
|
|
1447
1448
|
// check if message has location attachment and text is just coordinates
|
|
1448
1449
|
const hasLocationAttachment = message.msg.attachments?.some((att) =>
|
|
1449
|
-
att.startsWith('geo:')
|
|
1450
|
+
attachmentAsString(att).startsWith('geo:')
|
|
1450
1451
|
);
|
|
1451
1452
|
const textIsCoordinates =
|
|
1452
1453
|
hasLocationAttachment &&
|
|
@@ -1510,7 +1511,7 @@ export class Chat extends RapidElement {
|
|
|
1510
1511
|
${(message.msg.attachments || []).map(
|
|
1511
1512
|
(attachment) =>
|
|
1512
1513
|
html`<temba-thumbnail
|
|
1513
|
-
attachment="${attachment}"
|
|
1514
|
+
attachment="${attachmentAsString(attachment)}"
|
|
1514
1515
|
></temba-thumbnail>`
|
|
1515
1516
|
)}
|
|
1516
1517
|
</div>
|
package/src/display/Lightbox.ts
CHANGED
|
@@ -1,59 +1,65 @@
|
|
|
1
1
|
import { css, html, PropertyValueMap } from 'lit';
|
|
2
|
-
import { property } from 'lit/decorators.js';
|
|
2
|
+
import { property, state } from 'lit/decorators.js';
|
|
3
3
|
import { RapidElement } from '../RapidElement';
|
|
4
4
|
import { getClasses } from '../utils';
|
|
5
|
-
import { styleMap } from 'lit-html/directives/style-map.js';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* A full-screen viewer for an image attachment. `showElement` is handed the
|
|
8
|
+
* element that was clicked (a raw <img> or a temba-thumbnail exposing a `url`),
|
|
9
|
+
* and we present that image centered and contained within the viewport with a
|
|
10
|
+
* short fade / scale-in. Clicking anywhere dismisses it.
|
|
11
11
|
*/
|
|
12
12
|
export class Lightbox extends RapidElement {
|
|
13
13
|
static get styles() {
|
|
14
14
|
return css`
|
|
15
15
|
:host {
|
|
16
|
-
|
|
17
|
-
position: absolute;
|
|
16
|
+
position: fixed;
|
|
18
17
|
top: 0;
|
|
19
18
|
left: 0;
|
|
19
|
+
z-index: 10000;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/* a full-viewport layer that centers the image, catches the dismiss
|
|
23
|
+
click, and lays a light translucent mask over the page behind */
|
|
24
|
+
.backdrop {
|
|
25
|
+
position: fixed;
|
|
26
|
+
inset: 0;
|
|
23
27
|
display: flex;
|
|
24
|
-
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
25
30
|
background: rgba(0, 0, 0, 0.5);
|
|
26
|
-
|
|
27
|
-
height: 100svh;
|
|
28
|
-
width: 100svw;
|
|
31
|
+
opacity: 0;
|
|
29
32
|
pointer-events: none;
|
|
33
|
+
transition: opacity var(--anim) ease;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
.zoom
|
|
36
|
+
.backdrop.zoom {
|
|
33
37
|
opacity: 1;
|
|
34
38
|
pointer-events: auto;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
img {
|
|
42
|
+
/* contain the image within the viewport at any aspect ratio, on
|
|
43
|
+
its own solid backing (so transparent images aren't see-through) */
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
max-width: 90vw;
|
|
46
|
+
max-height: 90vh;
|
|
47
|
+
object-fit: contain;
|
|
48
|
+
background: #fff;
|
|
49
|
+
padding: 6px;
|
|
50
|
+
border-radius: calc(var(--curvature) * 1.5);
|
|
51
|
+
box-shadow: 0 0 24px 6px rgba(0, 0, 0, 0.4);
|
|
52
|
+
transform: scale(0.85);
|
|
53
|
+
opacity: 0;
|
|
54
|
+
transition:
|
|
55
|
+
transform var(--anim) ease,
|
|
56
|
+
opacity var(--anim) ease;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
display:flex;
|
|
49
|
-
align-items:center;
|
|
50
|
-
color:#fff;
|
|
51
|
-
padding:0.5em;
|
|
52
|
-
border-radius:var(--curvature);
|
|
53
|
-
background:rgba(0,0,0,0.5);
|
|
59
|
+
.zoom img {
|
|
60
|
+
transform: scale(1);
|
|
61
|
+
opacity: 1;
|
|
54
62
|
}
|
|
55
|
-
|
|
56
|
-
}
|
|
57
63
|
`;
|
|
58
64
|
}
|
|
59
65
|
|
|
@@ -66,77 +72,41 @@ export class Lightbox extends RapidElement {
|
|
|
66
72
|
@property({ type: Boolean })
|
|
67
73
|
zoom = false;
|
|
68
74
|
|
|
69
|
-
@
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
private ele: HTMLElement;
|
|
73
|
-
private left: number;
|
|
74
|
-
private top: number;
|
|
75
|
-
private height: number;
|
|
76
|
-
private width: number;
|
|
77
|
-
private scale = 1;
|
|
78
|
-
private xTrans = 0;
|
|
79
|
-
private yTrans = 0;
|
|
75
|
+
@state()
|
|
76
|
+
private url = '';
|
|
80
77
|
|
|
81
78
|
protected updated(
|
|
82
79
|
changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
|
83
80
|
): void {
|
|
81
|
+
// mount first (opacity 0 / scaled down), then flip to zoom on the next
|
|
82
|
+
// tick so the transition to the visible state actually animates
|
|
84
83
|
if (changed.has('show') && this.show) {
|
|
85
84
|
window.setTimeout(() => {
|
|
86
85
|
this.zoom = true;
|
|
87
86
|
}, 0);
|
|
88
87
|
}
|
|
89
88
|
|
|
89
|
+
// once zoomed out, wait for the fade to finish before unmounting the image
|
|
90
90
|
if (changed.has('zoom') && !this.zoom && this.show) {
|
|
91
91
|
window.setTimeout(() => {
|
|
92
|
-
|
|
92
|
+
// unless a re-open during the fade-out already zoomed us back in
|
|
93
|
+
if (!this.zoom) {
|
|
94
|
+
this.show = false;
|
|
95
|
+
}
|
|
93
96
|
}, this.animationTime);
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
public showElement(ele: HTMLElement) {
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
this.
|
|
101
|
-
(this.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
this.top = bounds.top;
|
|
105
|
-
this.width = bounds.width;
|
|
106
|
-
this.height = bounds.height;
|
|
107
|
-
|
|
108
|
-
this.xTrans = 0;
|
|
109
|
-
this.yTrans = 0;
|
|
110
|
-
this.scale = 1;
|
|
111
|
-
|
|
112
|
-
let desiredWidth = this.width;
|
|
113
|
-
let desiredHeight = this.height;
|
|
114
|
-
let desiredScale = this.scale;
|
|
115
|
-
|
|
116
|
-
const maxHeight = window.innerHeight * this.zoomPct;
|
|
117
|
-
const maxWidth = window.innerWidth * this.zoomPct;
|
|
118
|
-
|
|
119
|
-
// if the width fits, constrain by height
|
|
120
|
-
if (this.width * (maxHeight / this.height) < maxWidth) {
|
|
121
|
-
desiredHeight = window.innerHeight * this.zoomPct;
|
|
122
|
-
desiredScale = desiredHeight / this.height;
|
|
123
|
-
desiredWidth = this.width * desiredScale;
|
|
101
|
+
// the clicked element is either a raw <img> or a temba-thumbnail that
|
|
102
|
+
// exposes the attachment url; take whichever gives us an image source
|
|
103
|
+
this.url = (ele as HTMLImageElement).src || (ele as any).url || '';
|
|
104
|
+
if (this.show) {
|
|
105
|
+
// already mounted (e.g. re-opened mid-dismissal) — re-zoom in place
|
|
106
|
+
this.zoom = true;
|
|
124
107
|
} else {
|
|
125
|
-
|
|
126
|
-
desiredScale = desiredWidth / this.width;
|
|
127
|
-
desiredHeight = this.height * desiredScale;
|
|
108
|
+
this.show = true; // updated() flips zoom on the next tick
|
|
128
109
|
}
|
|
129
|
-
|
|
130
|
-
const xGrowth = (desiredWidth - this.width) / 2;
|
|
131
|
-
const xDest = (window.innerWidth - desiredWidth) / 2;
|
|
132
|
-
this.xTrans = xDest - this.left + xGrowth;
|
|
133
|
-
|
|
134
|
-
const yGrowth = (desiredHeight - this.height) / 2;
|
|
135
|
-
const yDest = (window.innerHeight - desiredHeight) / 2;
|
|
136
|
-
this.yTrans = yDest - this.top + yGrowth;
|
|
137
|
-
|
|
138
|
-
this.scale = desiredScale;
|
|
139
|
-
this.show = true;
|
|
140
110
|
}
|
|
141
111
|
|
|
142
112
|
public handleClick() {
|
|
@@ -144,37 +114,15 @@ export class Lightbox extends RapidElement {
|
|
|
144
114
|
}
|
|
145
115
|
|
|
146
116
|
public render() {
|
|
147
|
-
const styles = {
|
|
148
|
-
transition: `transform ${this.animationTime}ms ease, box-shadow ${this.animationTime}ms ease`
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
if (this.show) {
|
|
152
|
-
styles['left'] = this.left + 'px';
|
|
153
|
-
styles['top'] = this.top + 'px';
|
|
154
|
-
styles['width'] = this.width + 'px';
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (this.zoom) {
|
|
158
|
-
styles['transform'] =
|
|
159
|
-
`translate(${this.xTrans}px, ${this.yTrans}px) scale(${this.scale}, ${this.scale})`;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
117
|
return html`
|
|
163
118
|
<div
|
|
164
|
-
class=${getClasses({
|
|
165
|
-
|
|
166
|
-
show: this.show,
|
|
167
|
-
zoom: this.zoom
|
|
168
|
-
})}
|
|
119
|
+
class=${getClasses({ backdrop: true, zoom: this.zoom })}
|
|
120
|
+
style="--anim: ${this.animationTime}ms"
|
|
169
121
|
@click=${this.handleClick}
|
|
170
122
|
>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
></div>
|
|
175
|
-
<div class=${getClasses({ matte: true })} style=${styleMap(styles)}>
|
|
176
|
-
${this.show ? html`${this.ele}` : null}
|
|
177
|
-
</div>
|
|
123
|
+
${this.show && this.url
|
|
124
|
+
? html`<img src=${this.url} alt="attachment" />`
|
|
125
|
+
: null}
|
|
178
126
|
</div>
|
|
179
127
|
`;
|
|
180
128
|
}
|
|
@@ -108,6 +108,12 @@ export class ProgressBar extends RapidElement {
|
|
|
108
108
|
transition: flex-basis 2s;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/* At 0% an animated meter keeps a sliver of bar so its moving
|
|
112
|
+
stripes show the work is underway rather than a dead track. */
|
|
113
|
+
.meter.zero > span {
|
|
114
|
+
min-width: 1.25em;
|
|
115
|
+
}
|
|
116
|
+
|
|
111
117
|
.meter .incomplete {
|
|
112
118
|
flex-grow: 1;
|
|
113
119
|
}
|
|
@@ -163,6 +169,11 @@ export class ProgressBar extends RapidElement {
|
|
|
163
169
|
@property({ type: Boolean })
|
|
164
170
|
showPercentage = false;
|
|
165
171
|
|
|
172
|
+
/** Suppresses the trailing percentage / countdown box outright —
|
|
173
|
+
* for compact placements where the meter alone is the display. */
|
|
174
|
+
@property({ type: Boolean })
|
|
175
|
+
hidePercentage = false;
|
|
176
|
+
|
|
166
177
|
@property({ type: String })
|
|
167
178
|
message: string;
|
|
168
179
|
|
|
@@ -194,7 +205,8 @@ export class ProgressBar extends RapidElement {
|
|
|
194
205
|
const meterClasses = [
|
|
195
206
|
'meter',
|
|
196
207
|
this.done ? 'done' : '',
|
|
197
|
-
this.animated ? '' : 'static'
|
|
208
|
+
this.animated ? '' : 'static',
|
|
209
|
+
this.animated && !this.done && this.pct === 0 ? 'zero' : ''
|
|
198
210
|
]
|
|
199
211
|
.filter(Boolean)
|
|
200
212
|
.join(' ');
|
|
@@ -208,7 +220,8 @@ export class ProgressBar extends RapidElement {
|
|
|
208
220
|
<div class="incomplete"></div>
|
|
209
221
|
</div>
|
|
210
222
|
|
|
211
|
-
${this.showPercentage || this.showEstimatedCompletion
|
|
223
|
+
${(this.showPercentage || this.showEstimatedCompletion) &&
|
|
224
|
+
!this.hidePercentage
|
|
212
225
|
? html`<div class="etc">
|
|
213
226
|
<div>
|
|
214
227
|
${this.estimatedCompletionDate &&
|
package/src/display/Thumbnail.ts
CHANGED
|
@@ -182,6 +182,11 @@ export class Thumbnail extends RapidElement {
|
|
|
182
182
|
// audio player state
|
|
183
183
|
private audio: HTMLAudioElement | null = null;
|
|
184
184
|
|
|
185
|
+
// set when an image attachment fails to load, so we fall back to an
|
|
186
|
+
// icon instead of the browser's broken-image glyph
|
|
187
|
+
@state()
|
|
188
|
+
private imageError = false;
|
|
189
|
+
|
|
185
190
|
@state()
|
|
186
191
|
private audioPlaying = false;
|
|
187
192
|
|
|
@@ -264,6 +269,8 @@ export class Thumbnail extends RapidElement {
|
|
|
264
269
|
|
|
265
270
|
// convert our attachment to a url and label
|
|
266
271
|
if (changes.has('attachment')) {
|
|
272
|
+
// a new attachment gets a fresh shot at loading its image
|
|
273
|
+
this.imageError = false;
|
|
267
274
|
if (this.attachment) {
|
|
268
275
|
const splitIndex = this.attachment.indexOf(':');
|
|
269
276
|
if (splitIndex === -1) {
|
|
@@ -337,12 +344,27 @@ export class Thumbnail extends RapidElement {
|
|
|
337
344
|
}
|
|
338
345
|
}
|
|
339
346
|
|
|
340
|
-
public handleThumbnailClicked() {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
347
|
+
public handleThumbnailClicked(e?: Event) {
|
|
348
|
+
// a thumbnail click is a self-contained action (lightbox / open / play);
|
|
349
|
+
// don't let it bubble to a clickable ancestor (e.g. a list row that would
|
|
350
|
+
// otherwise navigate away instead of showing the attachment)
|
|
351
|
+
e?.stopPropagation();
|
|
352
|
+
if (
|
|
353
|
+
this.contentType === ThumbnailContentType.IMAGE &&
|
|
354
|
+
this.preview &&
|
|
355
|
+
!this.imageError
|
|
356
|
+
) {
|
|
357
|
+
const lightbox = document.querySelector('temba-lightbox') as Lightbox;
|
|
358
|
+
if (lightbox) {
|
|
359
|
+
window.setTimeout(() => {
|
|
360
|
+
lightbox.showElement(this);
|
|
361
|
+
}, 100);
|
|
362
|
+
} else {
|
|
363
|
+
// no lightbox mounted on this page — just open the image. This must
|
|
364
|
+
// happen synchronously inside the click's user activation or popup
|
|
365
|
+
// blockers will eat it.
|
|
366
|
+
window.open(this.url, '_blank');
|
|
367
|
+
}
|
|
346
368
|
} else if (this.contentType === ThumbnailContentType.LOCATION) {
|
|
347
369
|
// open location in openstreetmap
|
|
348
370
|
const osmUrl = `https://www.openstreetmap.org/?mlat=${this.latitude}&mlon=${this.longitude}#map=15/${this.latitude}/${this.longitude}`;
|
|
@@ -372,12 +394,17 @@ export class Thumbnail extends RapidElement {
|
|
|
372
394
|
: ''}"
|
|
373
395
|
url=${this.url}
|
|
374
396
|
>
|
|
375
|
-
${this.contentType === ThumbnailContentType.IMAGE &&
|
|
397
|
+
${this.contentType === ThumbnailContentType.IMAGE &&
|
|
398
|
+
this.preview &&
|
|
399
|
+
!this.imageError
|
|
376
400
|
? html`<div class=""><div class="download" @click=${this.handleDownload.bind(
|
|
377
401
|
this
|
|
378
402
|
)}><temba-icon size="1" style="color:#fff;" name="download"></temba-icon></div><img
|
|
379
403
|
class="observe thumb ${this.contentType}"
|
|
380
404
|
src="${this.url}"
|
|
405
|
+
@error=${() => {
|
|
406
|
+
this.imageError = true;
|
|
407
|
+
}}
|
|
381
408
|
></img></div>`
|
|
382
409
|
: this.contentType === ThumbnailContentType.AUDIO
|
|
383
410
|
? html`<div class="audio-player">
|
package/src/form/Compose.ts
CHANGED
|
@@ -406,11 +406,25 @@ export class Compose extends FieldElement {
|
|
|
406
406
|
if (!this.hasAttribute('tabindex')) {
|
|
407
407
|
this.setAttribute('tabindex', '-1');
|
|
408
408
|
}
|
|
409
|
+
// both phases: capture claims a send-Enter before the rich editor's
|
|
410
|
+
// own keydown inserts a newline (which would flash before the send
|
|
411
|
+
// clears it); bubble keeps Enter-to-send working from anywhere else
|
|
412
|
+
// in the compose after inner widgets have had their shot at it
|
|
413
|
+
this.addEventListener(
|
|
414
|
+
'keydown',
|
|
415
|
+
this.handleHostKeyDown as EventListener,
|
|
416
|
+
true
|
|
417
|
+
);
|
|
409
418
|
this.addEventListener('keydown', this.handleHostKeyDown as EventListener);
|
|
410
419
|
}
|
|
411
420
|
|
|
412
421
|
disconnectedCallback() {
|
|
413
422
|
super.disconnectedCallback();
|
|
423
|
+
this.removeEventListener(
|
|
424
|
+
'keydown',
|
|
425
|
+
this.handleHostKeyDown as EventListener,
|
|
426
|
+
true
|
|
427
|
+
);
|
|
414
428
|
this.removeEventListener(
|
|
415
429
|
'keydown',
|
|
416
430
|
this.handleHostKeyDown as EventListener
|
|
@@ -449,10 +463,23 @@ export class Compose extends FieldElement {
|
|
|
449
463
|
}
|
|
450
464
|
|
|
451
465
|
if (evt.key === 'Enter' && !evt.shiftKey) {
|
|
466
|
+
// an Enter confirming an IME composition belongs to the editor
|
|
467
|
+
if (evt.isComposing || evt.keyCode === 229) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
452
470
|
if (this.showShortcuts) {
|
|
453
471
|
return;
|
|
454
472
|
}
|
|
455
473
|
const editor = this.getMessageEditor();
|
|
474
|
+
// during capture only editor presses are claimed — anything else
|
|
475
|
+
// (quick replies, attachments) waits for the bubble so the widget
|
|
476
|
+
// it targets keeps first claim on the event
|
|
477
|
+
if (
|
|
478
|
+
evt.eventPhase === Event.CAPTURING_PHASE &&
|
|
479
|
+
!(editor && evt.composedPath().includes(editor))
|
|
480
|
+
) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
456
483
|
if (editor) {
|
|
457
484
|
const richEdit = editor.getRichEditor();
|
|
458
485
|
if (richEdit && richEdit.hasVisibleOptions()) {
|
|
@@ -460,7 +487,10 @@ export class Compose extends FieldElement {
|
|
|
460
487
|
}
|
|
461
488
|
}
|
|
462
489
|
evt.preventDefault();
|
|
463
|
-
|
|
490
|
+
// immediate: the handler is registered for both phases, and an
|
|
491
|
+
// at-target event would otherwise reach the second registration
|
|
492
|
+
// and send twice
|
|
493
|
+
evt.stopImmediatePropagation();
|
|
464
494
|
this.triggerSend();
|
|
465
495
|
}
|
|
466
496
|
};
|