@nyaruka/temba-components 0.134.5 → 0.135.9

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.
@@ -10,6 +10,7 @@ enum ThumbnailContentType {
10
10
  AUDIO = 'audio',
11
11
  VIDEO = 'video',
12
12
  DOCUMENT = 'document',
13
+ LOCATION = 'location',
13
14
  OTHER = 'other'
14
15
  }
15
16
 
@@ -122,6 +123,28 @@ export class Thumbnail extends RapidElement {
122
123
  @property({ type: String, attribute: true })
123
124
  contentType: string;
124
125
 
126
+ @property({ type: Number, attribute: false })
127
+ latitude: number;
128
+
129
+ @property({ type: Number, attribute: false })
130
+ longitude: number;
131
+
132
+ // cached tile URL for location thumbnails
133
+ @property({ type: String, attribute: false })
134
+ private tileUrl: string = '';
135
+
136
+ // convert lat/lng to tile coordinates for OSM
137
+ private latLngToTile(lat: number, lng: number, zoom: number) {
138
+ const n = Math.pow(2, zoom);
139
+ const x = Math.floor(((lng + 180) / 360) * n);
140
+ const latRad = (lat * Math.PI) / 180;
141
+ const y = Math.floor(
142
+ ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) *
143
+ n
144
+ );
145
+ return { x, y, z: zoom };
146
+ }
147
+
125
148
  protected updated(
126
149
  changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
127
150
  ): void {
@@ -162,12 +185,36 @@ export class Thumbnail extends RapidElement {
162
185
  this.contentType = ThumbnailContentType.VIDEO;
163
186
  } else if (contentType.startsWith('application')) {
164
187
  this.contentType = ThumbnailContentType.DOCUMENT;
188
+ } else if (contentType.startsWith('geo')) {
189
+ this.contentType = ThumbnailContentType.LOCATION;
190
+ // Parse coordinates from URL which is already stripped of "geo:" prefix
191
+ // Format is now just: lat,lng
192
+ const coords = this.url.match(/^([^,]+),([^,]+)/);
193
+ if (coords) {
194
+ this.latitude = parseFloat(coords[1]);
195
+ this.longitude = parseFloat(coords[2]);
196
+ }
165
197
  } else {
166
198
  this.contentType = ThumbnailContentType.OTHER;
167
199
  }
168
200
  }
169
201
  }
170
202
  }
203
+
204
+ // calculate tile URL when latitude/longitude changes
205
+ if (changes.has('latitude') || changes.has('longitude')) {
206
+ if (
207
+ this.latitude !== undefined &&
208
+ this.longitude !== undefined &&
209
+ !isNaN(this.latitude) &&
210
+ !isNaN(this.longitude)
211
+ ) {
212
+ const tile = this.latLngToTile(this.latitude, this.longitude, 13);
213
+ this.tileUrl = `https://tile.openstreetmap.org/${tile.z}/${tile.x}/${tile.y}.png`;
214
+ } else {
215
+ this.tileUrl = '';
216
+ }
217
+ }
171
218
  }
172
219
 
173
220
  public handleThumbnailClicked() {
@@ -176,6 +223,10 @@ export class Thumbnail extends RapidElement {
176
223
  const lightbox = document.querySelector('temba-lightbox') as Lightbox;
177
224
  lightbox.showElement(this);
178
225
  }, 100);
226
+ } else if (this.contentType === ThumbnailContentType.LOCATION) {
227
+ // open location in openstreetmap
228
+ const osmUrl = `https://www.openstreetmap.org/?mlat=${this.latitude}&mlon=${this.longitude}#map=15/${this.latitude}/${this.longitude}`;
229
+ window.open(osmUrl, '_blank');
179
230
  } else {
180
231
  window.open(this.url, '_blank');
181
232
  }
@@ -203,14 +254,22 @@ export class Thumbnail extends RapidElement {
203
254
  class="observe thumb ${this.contentType}"
204
255
  src="${this.url}"
205
256
  ></img></div>`
206
- : html`<div
207
- style="padding:1em; background:rgba(0,0,0,.05);border-radius:var(--curvature);"
208
- >
209
- <temba-icon
210
- size="1.5"
211
- name="${ThumbnailIcons[this.contentType]}"
212
- ></temba-icon>
213
- </div>`}
257
+ : html`
258
+ ${this.contentType === ThumbnailContentType.LOCATION
259
+ ? html`<img
260
+ style="height:125px;margin-bottom:-3px;border-radius:var(--curvature);"
261
+ src="${this.tileUrl}"
262
+ alt="Location preview"
263
+ />`
264
+ : html`<div
265
+ style="padding:1em; background:rgba(0,0,0,.05);border-radius:var(--curvature);"
266
+ >
267
+ <temba-icon
268
+ size="1.5"
269
+ name="${ThumbnailIcons[this.contentType]}"
270
+ ></temba-icon>
271
+ </div>`}
272
+ `}
214
273
  </div>
215
274
  `;
216
275
  }
@@ -827,7 +827,7 @@ export class ContactChat extends ContactStoreElement {
827
827
  };
828
828
  break;
829
829
  default:
830
- console.error('Unknown event type', event);
830
+ // console.error('Unknown event type', event);
831
831
  }
832
832
  }
833
833
 
package/src/utils.ts CHANGED
@@ -84,19 +84,19 @@ export const getHeaders = (headers: any = {}) => {
84
84
 
85
85
  const fetchHeaders: any = csrf ? { 'X-CSRFToken': csrf } : {};
86
86
 
87
- // include the current org id
88
- const org_id = (window as any).org_id;
89
- if (org_id) {
90
- fetchHeaders['X-Temba-Org'] = org_id;
87
+ // include the current workspace identifier
88
+ const workspaceUUID = (window as any).workspace?.uuid;
89
+ if (workspaceUUID) {
90
+ fetchHeaders['X-Temba-Workspace'] = workspaceUUID;
91
91
  }
92
92
 
93
93
  // mark us as ajax
94
94
  fetchHeaders['X-Requested-With'] = 'XMLHttpRequest';
95
95
 
96
96
  Object.keys(headers).forEach((key) => {
97
- // if we are adding a service org, we omit temba-org
97
+ // if we are requesting to service, we omit current workspace identifier
98
98
  if (key === 'X-Temba-Service-Org') {
99
- delete fetchHeaders['X-Temba-Org'];
99
+ delete fetchHeaders['X-Temba-Workspace'];
100
100
  }
101
101
 
102
102
  fetchHeaders[key] = headers[key];
@@ -0,0 +1,150 @@
1
+ import { assert, expect } from '@open-wc/testing';
2
+ import { Thumbnail } from '../src/display/Thumbnail';
3
+ import { getComponent } from './utils.test';
4
+
5
+ const TAG = 'temba-thumbnail';
6
+ const getThumbnail = async (attrs: any = {}) => {
7
+ return (await getComponent(TAG, attrs)) as Thumbnail;
8
+ };
9
+
10
+ describe('temba-thumbnail', () => {
11
+ it('can be created', async () => {
12
+ const thumbnail = await getThumbnail();
13
+ assert.instanceOf(thumbnail, Thumbnail);
14
+ });
15
+
16
+ it('renders location thumbnail with map tile', async () => {
17
+ // test that a location attachment properly uses latLngToTile in render
18
+ const thumbnail = await getThumbnail({
19
+ attachment: 'geo:40.7128,-74.0060'
20
+ });
21
+ await thumbnail.updateComplete;
22
+ expect(thumbnail.latitude).to.equal(40.7128);
23
+ expect(thumbnail.longitude).to.equal(-74.006);
24
+ expect(thumbnail.contentType).to.equal('location');
25
+
26
+ // verify the rendered image contains the correct tile URL
27
+ const img = thumbnail.shadowRoot.querySelector('img');
28
+ expect(img).to.exist;
29
+ expect(img.src).to.include('tile.openstreetmap.org');
30
+ expect(img.src).to.include('/13/'); // zoom level
31
+ });
32
+
33
+ describe('latLngToTile', () => {
34
+ it('converts coordinates at 0,0', async () => {
35
+ const thumbnail = await getThumbnail();
36
+
37
+ // test at zoom level 0
38
+ const tile0 = (thumbnail as any).latLngToTile(0, 0, 0);
39
+ expect(tile0.x).to.equal(0);
40
+ expect(tile0.y).to.equal(0);
41
+ expect(tile0.z).to.equal(0);
42
+
43
+ // test at zoom level 10
44
+ const tile10 = (thumbnail as any).latLngToTile(0, 0, 10);
45
+ expect(tile10.x).to.equal(512);
46
+ expect(tile10.y).to.equal(512);
47
+ expect(tile10.z).to.equal(10);
48
+ });
49
+
50
+ it('converts positive coordinates', async () => {
51
+ const thumbnail = await getThumbnail();
52
+
53
+ // test London coordinates at zoom 13
54
+ const londonTile = (thumbnail as any).latLngToTile(51.5074, -0.1278, 13);
55
+ expect(londonTile.x).to.equal(4093);
56
+ expect(londonTile.y).to.equal(2724);
57
+ expect(londonTile.z).to.equal(13);
58
+ });
59
+
60
+ it('converts negative coordinates', async () => {
61
+ const thumbnail = await getThumbnail();
62
+
63
+ // test Sydney coordinates (negative latitude) at zoom 13
64
+ const sydneyTile = (thumbnail as any).latLngToTile(
65
+ -33.8688,
66
+ 151.2093,
67
+ 13
68
+ );
69
+ expect(sydneyTile.x).to.equal(7536);
70
+ expect(sydneyTile.y).to.equal(4915);
71
+ expect(sydneyTile.z).to.equal(13);
72
+ });
73
+
74
+ it('converts coordinates near the North Pole', async () => {
75
+ const thumbnail = await getThumbnail();
76
+
77
+ // test near North Pole (85.05 is practical limit for Web Mercator)
78
+ const northPoleTile = (thumbnail as any).latLngToTile(85, 0, 5);
79
+ expect(northPoleTile.x).to.equal(16);
80
+ expect(northPoleTile.y).to.equal(0);
81
+ expect(northPoleTile.z).to.equal(5);
82
+ });
83
+
84
+ it('converts coordinates near the South Pole', async () => {
85
+ const thumbnail = await getThumbnail();
86
+
87
+ // test near South Pole (-85.05 is practical limit for Web Mercator)
88
+ const southPoleTile = (thumbnail as any).latLngToTile(-85, 0, 5);
89
+ expect(southPoleTile.x).to.equal(16);
90
+ expect(southPoleTile.y).to.equal(31);
91
+ expect(southPoleTile.z).to.equal(5);
92
+ });
93
+
94
+ it('converts coordinates near the Date Line (positive)', async () => {
95
+ const thumbnail = await getThumbnail();
96
+
97
+ // test near Date Line (179.9 longitude)
98
+ const dateLineTile = (thumbnail as any).latLngToTile(0, 179.9, 10);
99
+ expect(dateLineTile.x).to.equal(1023);
100
+ expect(dateLineTile.y).to.equal(512);
101
+ expect(dateLineTile.z).to.equal(10);
102
+ });
103
+
104
+ it('converts coordinates near the Date Line (negative)', async () => {
105
+ const thumbnail = await getThumbnail();
106
+
107
+ // test near Date Line (-179.9 longitude)
108
+ const dateLineTile = (thumbnail as any).latLngToTile(0, -179.9, 10);
109
+ expect(dateLineTile.x).to.equal(0);
110
+ expect(dateLineTile.y).to.equal(512);
111
+ expect(dateLineTile.z).to.equal(10);
112
+ });
113
+
114
+ it('works correctly with zoom level 1', async () => {
115
+ const thumbnail = await getThumbnail();
116
+
117
+ const tile = (thumbnail as any).latLngToTile(40.7128, -74.006, 1);
118
+ expect(tile.x).to.equal(0);
119
+ expect(tile.y).to.equal(0);
120
+ expect(tile.z).to.equal(1);
121
+ });
122
+
123
+ it('works correctly with zoom level 5', async () => {
124
+ const thumbnail = await getThumbnail();
125
+
126
+ const tile = (thumbnail as any).latLngToTile(40.7128, -74.006, 5);
127
+ expect(tile.x).to.equal(9);
128
+ expect(tile.y).to.equal(12);
129
+ expect(tile.z).to.equal(5);
130
+ });
131
+
132
+ it('works correctly with zoom level 15', async () => {
133
+ const thumbnail = await getThumbnail();
134
+
135
+ const tile = (thumbnail as any).latLngToTile(40.7128, -74.006, 15);
136
+ expect(tile.x).to.equal(9647);
137
+ expect(tile.y).to.equal(12320);
138
+ expect(tile.z).to.equal(15);
139
+ });
140
+
141
+ it('works correctly with zoom level 18 (maximum)', async () => {
142
+ const thumbnail = await getThumbnail();
143
+
144
+ const tile = (thumbnail as any).latLngToTile(40.7128, -74.006, 18);
145
+ expect(tile.x).to.equal(77182);
146
+ expect(tile.y).to.equal(98561);
147
+ expect(tile.z).to.equal(18);
148
+ });
149
+ });
150
+ });
@@ -189,10 +189,15 @@ describe('utils/index', () => {
189
189
  expect(headers['X-CSRFToken']).to.equal('form-csrf-token');
190
190
  });
191
191
 
192
- it('includes org id when available', () => {
193
- (window as any).org_id = '123';
192
+ it('includes org uuid when available', () => {
193
+ (window as any).workspace = {
194
+ uuid: '3a7e2f10-b0b3-413d-bd84-626e147d5ed2',
195
+ anon: false
196
+ };
194
197
  const headers = getHeaders();
195
- expect(headers['X-Temba-Org']).to.equal('123');
198
+ expect(headers['X-Temba-Workspace']).to.equal(
199
+ '3a7e2f10-b0b3-413d-bd84-626e147d5ed2'
200
+ );
196
201
  });
197
202
 
198
203
  it('merges provided headers', () => {
@@ -201,10 +206,13 @@ describe('utils/index', () => {
201
206
  expect(headers['X-Requested-With']).to.equal('XMLHttpRequest');
202
207
  });
203
208
 
204
- it('removes X-Temba-Org when X-Temba-Service-Org is provided', () => {
205
- (window as any).org_id = '123';
209
+ it('removes X-Temba-Workspace when X-Temba-Service-Org is provided', () => {
210
+ (window as any).workspace = {
211
+ uuid: '3a7e2f10-b0b3-413d-bd84-626e147d5ed2',
212
+ anon: false
213
+ };
206
214
  const headers = getHeaders({ 'X-Temba-Service-Org': 'service-org' });
207
- expect(headers['X-Temba-Org']).to.be.undefined;
215
+ expect(headers['X-Temba-Workspace']).to.be.undefined;
208
216
  expect(headers['X-Temba-Service-Org']).to.equal('service-org');
209
217
  });
210
218
  });
@@ -105,6 +105,10 @@
105
105
  "uuid": "8a81e9e0-10a0-4319-9b00-ce723cfa8303",
106
106
  "name": "SMS Channel"
107
107
  }
108
+ },
109
+ "_deleted": {
110
+ "created_on": "2025-09-23T20:40:27.239433+00:00",
111
+ "user": { "name": "Rowan Seymour", "uuid": "rowan-user-uuid" }
108
112
  }
109
113
  },
110
114
  {