@nyaruka/temba-components 0.89.0 → 0.90.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.
Files changed (40) hide show
  1. package/.devcontainer/devcontainer.json +10 -2
  2. package/CHANGELOG.md +9 -0
  3. package/dist/temba-components.js +437 -443
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/compose/Compose.js +21 -340
  6. package/out-tsc/src/compose/Compose.js.map +1 -1
  7. package/out-tsc/src/mediapicker/MediaPicker.js +312 -0
  8. package/out-tsc/src/mediapicker/MediaPicker.js.map +1 -0
  9. package/out-tsc/src/templates/TemplateEditor.js +75 -4
  10. package/out-tsc/src/templates/TemplateEditor.js.map +1 -1
  11. package/out-tsc/src/thumbnail/Thumbnail.js +31 -29
  12. package/out-tsc/src/thumbnail/Thumbnail.js.map +1 -1
  13. package/out-tsc/src/vectoricon/VectorIcon.js +0 -1
  14. package/out-tsc/src/vectoricon/VectorIcon.js.map +1 -1
  15. package/out-tsc/src/vectoricon/index.js +1 -0
  16. package/out-tsc/src/vectoricon/index.js.map +1 -1
  17. package/out-tsc/temba-modules.js +2 -0
  18. package/out-tsc/temba-modules.js.map +1 -1
  19. package/out-tsc/test/temba-compose.test.js +11 -12
  20. package/out-tsc/test/temba-compose.test.js.map +1 -1
  21. package/package.json +1 -1
  22. package/screenshots/truth/compose/attachments-with-all-files-and-click-send.png +0 -0
  23. package/screenshots/truth/compose/attachments-with-success-files-and-click-send.png +0 -0
  24. package/screenshots/truth/compose/chatbox-no-text-attachments-with-all-files-and-click-send.png +0 -0
  25. package/screenshots/truth/compose/chatbox-no-text-attachments-with-success-files-and-click-send.png +0 -0
  26. package/screenshots/truth/compose/chatbox-with-text-attachments-no-files-and-hit-enter.png +0 -0
  27. package/screenshots/truth/compose/chatbox-with-text-attachments-with-all-files-and-click-send.png +0 -0
  28. package/screenshots/truth/compose/chatbox-with-text-attachments-with-all-files-and-hit-enter.png +0 -0
  29. package/screenshots/truth/compose/chatbox-with-text-attachments-with-success-files-and-click-send.png +0 -0
  30. package/screenshots/truth/compose/chatbox-with-text-attachments-with-success-files-and-hit-enter.png +0 -0
  31. package/screenshots/truth/templates/default.png +0 -0
  32. package/screenshots/truth/templates/french.png +0 -0
  33. package/src/compose/Compose.ts +23 -378
  34. package/src/mediapicker/MediaPicker.ts +338 -0
  35. package/src/templates/TemplateEditor.ts +81 -4
  36. package/src/thumbnail/Thumbnail.ts +43 -39
  37. package/src/vectoricon/VectorIcon.ts +0 -1
  38. package/src/vectoricon/index.ts +1 -0
  39. package/temba-modules.ts +2 -0
  40. package/test/temba-compose.test.ts +13 -53
@@ -0,0 +1,338 @@
1
+ import { TemplateResult, css, html } from 'lit';
2
+ import { RapidElement } from '../RapidElement';
3
+ import { property } from 'lit/decorators.js';
4
+ import { Attachment } from '../interfaces';
5
+ import { Icon } from '../vectoricon';
6
+ import {
7
+ DEFAULT_MEDIA_ENDPOINT,
8
+ WebResponse,
9
+ getClasses,
10
+ isImageAttachment,
11
+ postFormData
12
+ } from '../utils';
13
+
14
+ const verifyAccept = (type: string, accept: string): boolean => {
15
+ if (accept) {
16
+ const allowed = accept.split(',').map((x) => x.trim());
17
+ return (
18
+ allowed.includes(type) || allowed.includes(type.split('/')[0] + '/*')
19
+ );
20
+ }
21
+ return true;
22
+ };
23
+
24
+ export class MediaPicker extends RapidElement {
25
+ static get styles() {
26
+ return css`
27
+ .drop-mask {
28
+ border-radius: var(--curvature-widget);
29
+ transition: opacity ease-in-out var(--transition-speed);
30
+ }
31
+
32
+ .highlight .drop-mask {
33
+ background: rgba(210, 243, 184, 0.8);
34
+ }
35
+
36
+ .drop-mask > div {
37
+ margin: auto;
38
+ border-radius: var(--curvature-widget);
39
+ font-weight: 400;
40
+ color: rgba(0, 0, 0, 0.5);
41
+ }
42
+
43
+ .attachments {
44
+ }
45
+
46
+ .attachments-list {
47
+ display: flex;
48
+ flex-direction: row;
49
+ flex-wrap: wrap;
50
+ padding: 0.2em;
51
+ }
52
+
53
+ .attachment-item {
54
+ padding: 0.4em;
55
+ padding-top: 1em;
56
+ }
57
+
58
+ .attachment-item.error {
59
+ background: #fff;
60
+ color: rgba(250, 0, 0, 0.75);
61
+ padding: 0.2em;
62
+ margin: 0.3em 0.5em;
63
+ border-radius: var(--curvature);
64
+ display: block;
65
+ }
66
+
67
+ .remove-item {
68
+ --icon-color: #ccc;
69
+ background: #fff;
70
+ border-radius: 99%;
71
+ transition: transform 200ms linear;
72
+ transform: scale(0);
73
+ display: block;
74
+ margin-bottom: -24px;
75
+ margin-left: 10px;
76
+ width: 1em;
77
+ height: 1em;
78
+ }
79
+
80
+ .attachment-item:hover .remove-item {
81
+ transform: scale(1);
82
+ }
83
+
84
+ .remove-item:hover {
85
+ --icon-color: #333;
86
+ cursor: pointer;
87
+ }
88
+
89
+ .attachment-name {
90
+ align-self: center;
91
+ font-size: 12px;
92
+ padding: 2px 8px;
93
+ }
94
+
95
+ #upload-input {
96
+ display: none;
97
+ }
98
+
99
+ .upload-label {
100
+ display: flex;
101
+ align-items: center;
102
+ }
103
+
104
+ .upload-icon {
105
+ color: rgb(102, 102, 102);
106
+ }
107
+
108
+ .add-attachment {
109
+ padding: 1em;
110
+ background-color: rgba(0, 0, 0, 0.05);
111
+ border-radius: var(--curvature);
112
+ color: #aaa;
113
+ margin: 0.5em;
114
+ }
115
+
116
+ .add-attachment:hover {
117
+ background-color: rgba(0, 0, 0, 0.07);
118
+ cursor: pointer;
119
+ }
120
+ `;
121
+ }
122
+
123
+ @property({ type: String, attribute: false })
124
+ endpoint = DEFAULT_MEDIA_ENDPOINT;
125
+
126
+ @property({ type: Boolean })
127
+ pendingDrop: boolean;
128
+
129
+ @property({ type: String })
130
+ icon = Icon.add;
131
+
132
+ @property({ type: String })
133
+ accept = ''; //e.g. ".xls,.xlsx"
134
+
135
+ @property({ type: Number })
136
+ max = 3;
137
+
138
+ @property({ type: Array })
139
+ attachments: Attachment[] = [];
140
+
141
+ @property({ type: Boolean, attribute: false })
142
+ uploading: boolean;
143
+
144
+ public updated(changes: Map<string, any>): void {
145
+ super.updated(changes);
146
+ if (changes.has('attachments')) {
147
+ // wait one cycle to fire change for tests
148
+ setTimeout(() => {
149
+ this.dispatchEvent(new Event('change'));
150
+ }, 0);
151
+ }
152
+ }
153
+
154
+ private getAcceptableFiles(evt: DragEvent): File[] {
155
+ const dt = evt.dataTransfer;
156
+ if (dt) {
157
+ const files = [...dt.files];
158
+ return files.filter((file) => verifyAccept(file.type, this.accept));
159
+ }
160
+ }
161
+
162
+ private handleDragEnter(evt: DragEvent): void {
163
+ this.highlight(evt);
164
+ }
165
+
166
+ private handleDragOver(evt: DragEvent): void {
167
+ this.highlight(evt);
168
+ }
169
+
170
+ private handleDragLeave(evt: DragEvent): void {
171
+ this.unhighlight(evt);
172
+ }
173
+
174
+ private handleDrop(evt: DragEvent): void {
175
+ this.unhighlight(evt);
176
+ if (this.canAcceptAttachments()) {
177
+ this.uploadFiles(this.getAcceptableFiles(evt));
178
+ }
179
+ }
180
+
181
+ public canAcceptAttachments() {
182
+ return this.attachments.length < this.max;
183
+ }
184
+
185
+ private highlight(evt: DragEvent): void {
186
+ evt.preventDefault();
187
+ evt.stopPropagation();
188
+ if (this.canAcceptAttachments()) {
189
+ this.pendingDrop = true;
190
+ }
191
+ }
192
+
193
+ private unhighlight(evt: DragEvent): void {
194
+ evt.preventDefault();
195
+ evt.stopPropagation();
196
+ this.pendingDrop = false;
197
+ }
198
+
199
+ private addCurrentAttachment(attachmentToAdd: any) {
200
+ this.attachments.push(attachmentToAdd);
201
+ this.requestUpdate('attachments');
202
+ }
203
+
204
+ private removeCurrentAttachment(attachmentToRemove: any) {
205
+ this.attachments = this.attachments.filter(
206
+ (currentAttachment) => currentAttachment !== attachmentToRemove
207
+ );
208
+ this.requestUpdate('attachments');
209
+ }
210
+
211
+ private handleRemoveFileClicked(evt: Event): void {
212
+ const target = evt.target as HTMLDivElement;
213
+ const currentAttachmentToRemove = this.attachments.find(
214
+ ({ url }) => url === target.id
215
+ );
216
+ if (currentAttachmentToRemove) {
217
+ this.removeCurrentAttachment(currentAttachmentToRemove);
218
+ }
219
+ }
220
+
221
+ private handleUploadFileInputChanged(evt: Event): void {
222
+ const target = evt.target as HTMLInputElement;
223
+ const files = target.files;
224
+ this.uploadFiles([...files]);
225
+ }
226
+
227
+ public uploadFiles(files: File[]): void {
228
+ let filesToUpload = [];
229
+
230
+ //remove duplicate files that have already been uploaded
231
+ filesToUpload = files.filter((file) => {
232
+ // check our file type against accepts
233
+ if (this.accept) {
234
+ if (!verifyAccept(file.type, this.accept)) {
235
+ return false;
236
+ }
237
+ }
238
+
239
+ const index = this.attachments.findIndex(
240
+ (value) => value.filename === file.name && value.size === file.size
241
+ );
242
+ if (index === -1) {
243
+ return file;
244
+ }
245
+ });
246
+
247
+ filesToUpload.map((fileToUpload) => {
248
+ this.uploadFile(fileToUpload);
249
+ });
250
+ }
251
+
252
+ private uploadFile(file: File): void {
253
+ this.uploading = true;
254
+
255
+ const url = this.endpoint;
256
+ const payload = new FormData();
257
+ payload.append('file', file);
258
+ postFormData(url, payload)
259
+ .then((response: WebResponse) => {
260
+ if (this.attachments.length < this.max) {
261
+ const attachment = response.json as Attachment;
262
+ if (attachment) {
263
+ this.addCurrentAttachment(attachment);
264
+ }
265
+ }
266
+ })
267
+ .catch((error: WebResponse) => {
268
+ let uploadError = '';
269
+ if (error.status === 400) {
270
+ uploadError = error.json.file[0];
271
+ } else {
272
+ uploadError = 'Server failure';
273
+ }
274
+ console.error(uploadError);
275
+ })
276
+ .finally(() => {
277
+ this.uploading = false;
278
+ });
279
+ }
280
+
281
+ private renderUploader(): TemplateResult {
282
+ if (this.uploading) {
283
+ return html`<temba-loading units="3" size="12"></temba-loading>`;
284
+ } else {
285
+ return this.attachments.length < this.max
286
+ ? html`<input
287
+ type="file"
288
+ id="upload-input"
289
+ ?multiple=${this.max > 1}
290
+ accept="${this.accept}"
291
+ @change="${this.handleUploadFileInputChanged}"
292
+ />
293
+ <label
294
+ id="upload-label"
295
+ class="actions-left upload-label"
296
+ for="upload-input"
297
+ >
298
+ <div class="add-attachment">
299
+ <temba-icon name="${this.icon}" size="1.5"></temba-icon>
300
+ </div>
301
+ </label>`
302
+ : null;
303
+ }
304
+ }
305
+
306
+ public render(): TemplateResult {
307
+ return html` <div
308
+ class=${getClasses({ container: true, highlight: this.pendingDrop })}
309
+ @dragenter="${this.handleDragEnter}"
310
+ @dragover="${this.handleDragOver}"
311
+ @dragleave="${this.handleDragLeave}"
312
+ @drop="${this.handleDrop}"
313
+ >
314
+ <div class="drop-mask">
315
+ <div class="attachments-list">
316
+ ${this.attachments.map((validAttachment) => {
317
+ return html`<div class="attachment-item">
318
+ <temba-icon
319
+ class="remove-item"
320
+ @click="${this.handleRemoveFileClicked}"
321
+ id="${validAttachment.url}"
322
+ name="${Icon.delete_small}"
323
+ ></temba-icon>
324
+ ${isImageAttachment(validAttachment)
325
+ ? html`<temba-thumbnail
326
+ url="${validAttachment.url}"
327
+ ></temba-thumbnail>`
328
+ : html`<temba-thumbnail
329
+ label="${validAttachment.content_type.split('/')[1]}"
330
+ ></temba-thumbnail>`}
331
+ </div>`;
332
+ })}
333
+ ${this.renderUploader()}
334
+ </div>
335
+ </div>
336
+ </div>`;
337
+ }
338
+ }
@@ -2,12 +2,14 @@ import { property } from 'lit/decorators.js';
2
2
  import { FormElement } from '../FormElement';
3
3
  import { TemplateResult, html, css, PropertyValueMap, LitElement } from 'lit';
4
4
  import { CustomEventType } from '../interfaces';
5
+ import { MediaPicker } from '../mediapicker/MediaPicker';
5
6
 
6
7
  interface Component {
7
8
  name: string;
8
9
  type: string;
9
10
  content: string;
10
11
  variables: { [key: string]: number };
12
+ params: [{ type: string }];
11
13
  }
12
14
 
13
15
  interface Translation {
@@ -41,6 +43,11 @@ export class TemplateEditor extends FormElement {
41
43
  padding: 1em;
42
44
  margin-top: 1em;
43
45
  }
46
+
47
+ .content {
48
+ margin-bottom: 1em;
49
+ }
50
+
44
51
  .picker {
45
52
  margin-bottom: 0.5em;
46
53
  display: block;
@@ -69,7 +76,6 @@ export class TemplateEditor extends FormElement {
69
76
  }
70
77
 
71
78
  .button-wrapper {
72
- margin-top: 1em;
73
79
  background: #f9f9f9;
74
80
  border-radius: var(--curvature);
75
81
  padding: 0.5em;
@@ -125,6 +131,9 @@ export class TemplateEditor extends FormElement {
125
131
  border: 1px solid var(--color-widget-border);
126
132
  padding: 1em;
127
133
  line-height: 2.2em;
134
+ max-height: 50vh;
135
+ overflow-y: auto;
136
+ overflow-x: hidden;
128
137
  }
129
138
  `;
130
139
  }
@@ -199,10 +208,31 @@ export class TemplateEditor extends FormElement {
199
208
  });
200
209
  }
201
210
 
211
+ private handleAttachmentsChanged(event: CustomEvent) {
212
+ const media = event.target as MediaPicker;
213
+ const index = parseInt(media.getAttribute('index'));
214
+
215
+ if (media.attachments.length === 0) {
216
+ this.variables[index] = '';
217
+ } else {
218
+ const attachment = media.attachments[0];
219
+ if (attachment.url && attachment.content_type) {
220
+ this.variables[index] = `${attachment.content_type}:${attachment.url}`;
221
+ } else {
222
+ this.variables[index] = ``;
223
+ }
224
+ }
225
+ this.fireContentChange();
226
+ }
227
+
202
228
  private handleVariableChanged(event: CustomEvent) {
203
229
  const target = event.target as HTMLInputElement;
204
230
  const variableIndex = parseInt(target.getAttribute('index'));
205
231
  this.variables[variableIndex] = target.value;
232
+ this.fireContentChange();
233
+ }
234
+
235
+ private fireContentChange() {
206
236
  this.fireCustomEvent(CustomEventType.ContentChanged, {
207
237
  template: this.selectedTemplate,
208
238
  translation: this.translation,
@@ -216,9 +246,11 @@ export class TemplateEditor extends FormElement {
216
246
  `{{(${Object.keys(component.variables || []).join('|')})}}`,
217
247
  'g'
218
248
  );
219
- const parts = component.content.split(variableRegex);
249
+
250
+ let variables = null;
251
+ const parts = component.content?.split(variableRegex) || [];
220
252
  if (parts.length > 0) {
221
- const variables = parts.map((part, index) => {
253
+ variables = parts.map((part, index) => {
222
254
  if (index % 2 === 0) {
223
255
  return html`<span class="text">${part}</span>`;
224
256
  }
@@ -235,8 +267,53 @@ export class TemplateEditor extends FormElement {
235
267
  placeholder="{{${part}}}"
236
268
  ></temba-completion>`;
237
269
  });
238
- return html`<div class="content">${variables}</div>`;
270
+ } else {
271
+ // no content, let's do params intead
272
+ variables = component.params.map((param) => {
273
+ if (
274
+ param.type === 'image' ||
275
+ param.type === 'document' ||
276
+ param.type === 'audio' ||
277
+ param.type === 'video'
278
+ ) {
279
+ const index = Object.values(component.variables)[0];
280
+ let attachments = [];
281
+ if (this.variables[index]) {
282
+ const parts = this.variables[index].split(':');
283
+ attachments = [{ url: parts[1], content_type: parts[0] }];
284
+ }
285
+
286
+ return html`<div
287
+ style="
288
+ display: flex;
289
+ align-items: center;
290
+ border-radius: var(--curvature);
291
+ ${attachments.length === 0
292
+ ? `background-color:rgba(255,0,0,.07);`
293
+ : ``}
294
+ "
295
+ >
296
+ <temba-media-picker
297
+ accept="${param.type === 'document'
298
+ ? 'application/pdf'
299
+ : param.type + '/*'}"
300
+ max="1"
301
+ index=${index}
302
+ icon="attachment_${param.type}"
303
+ attachments=${JSON.stringify(attachments)}
304
+ @change=${this.handleAttachmentsChanged.bind(this)}
305
+ ></temba-media-picker>
306
+ <div>
307
+ ${attachments.length == 0
308
+ ? html`Attach ${param.type} to continue`
309
+ : ''}
310
+ </div>
311
+ </div>`;
312
+ }
313
+ });
239
314
  }
315
+
316
+ return html`<div class="content">${variables}</div> `;
240
317
  }
241
318
 
242
319
  public renderComponents(components: Component[]): TemplateResult {
@@ -9,7 +9,7 @@ export class Thumbnail extends RapidElement {
9
9
  static get styles() {
10
10
  return css`
11
11
  :host {
12
- display: inline-block;
12
+ display: inline;
13
13
  }
14
14
 
15
15
  .zooming.wrapper {
@@ -54,6 +54,26 @@ export class Thumbnail extends RapidElement {
54
54
 
55
55
  public render() {
56
56
  if (this.zooming) {
57
+ const styles = {
58
+ backgroundColor: '#fafafa',
59
+ backgroundSize: 'contain',
60
+ backgroundPosition: 'center',
61
+ backgroundRepeat: 'no-repeat',
62
+ maxHeight: 'var(--thumb-size, 4em)',
63
+ height: 'var(--thumb-size, 4em)',
64
+ width: 'var(--thumb-size, 4em)',
65
+ borderRadius: '0',
66
+ display: 'flex',
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ fontWeight: '400',
70
+ color: '#bbb'
71
+ };
72
+
73
+ if (this.url) {
74
+ styles['backgroundImage'] = `url(${this.url})`;
75
+ }
76
+
57
77
  return html`
58
78
  <div
59
79
  class="${getClasses({ wrapper: true })}"
@@ -63,30 +83,29 @@ export class Thumbnail extends RapidElement {
63
83
  boxShadow: 'var(--widget-box-shadow)'
64
84
  })}
65
85
  >
66
- <div
67
- class="thumb"
68
- style=${styleMap({
69
- backgroundImage: `url(${this.url})`,
70
- backgroundSize: 'contain',
71
- backgroundPosition: 'center',
72
- backgroundRepeat: 'no-repeat',
73
- maxHeight: 'var(--thumb-size, 4em)',
74
- height: 'var(--thumb-size, 4em)',
75
- width: 'var(--thumb-size, 4em)',
76
- borderRadius: '0',
77
-
78
- display: 'flex',
79
- alignItems: 'center',
80
- justifyContent: 'center',
81
- fontWeight: '400',
82
- color: '#bbb'
83
- })}
84
- >
85
- ${this.label}
86
- </div>
86
+ <div class="thumb" style=${styleMap(styles)}>${this.label}</div>
87
87
  </div>
88
88
  `;
89
89
  } else {
90
+ const styles = {
91
+ backgroundColor: '#fafafa',
92
+ backgroundSize: 'cover',
93
+ backgroundPosition: 'center',
94
+ maxHeight: 'var(--thumb-size, 4em)',
95
+ height: 'var(--thumb-size, 4em)',
96
+ width: 'var(--thumb-size, 4em)',
97
+ borderRadius: 'var(--curvature)',
98
+ display: 'flex',
99
+ alignItems: 'center',
100
+ justifyContent: 'center',
101
+ fontWeight: '400',
102
+ color: '#bbb'
103
+ };
104
+
105
+ if (this.url) {
106
+ styles['backgroundImage'] = `url(${this.url})`;
107
+ }
108
+
90
109
  return html`
91
110
  <div class="${getClasses({ wrapper: true })}" style=${styleMap({
92
111
  padding: 'var(--thumb-padding, 0.4em)',
@@ -94,24 +113,9 @@ export class Thumbnail extends RapidElement {
94
113
  borderRadius: 'var(--curvature)',
95
114
  boxShadow: 'var(--widget-box-shadow)'
96
115
  })}">
97
-
98
- <div class="thumb" style=${styleMap({
99
- backgroundImage: `url(${this.url})`,
100
- backgroundSize: 'cover',
101
- backgroundPosition: 'center',
102
- maxHeight: 'var(--thumb-size, 4em)',
103
- height: 'var(--thumb-size, 4em)',
104
- width: 'var(--thumb-size, 4em)',
105
- borderRadius: 'var(--curvature)',
106
-
107
- display: 'flex',
108
- alignItems: 'center',
109
- justifyContent: 'center',
110
- fontWeight: '400',
111
- color: '#bbb'
112
- })}>
116
+ <div class="thumb" style=${styleMap(styles)}>
113
117
  ${this.label}
114
- </div>
118
+ </div>
115
119
  </div>
116
120
  `;
117
121
  }
@@ -50,7 +50,6 @@ export class VectorIcon extends LitElement {
50
50
  static get styles() {
51
51
  return css`
52
52
  :host {
53
- display: flex-inline;
54
53
  align-items: center;
55
54
  align-self: center;
56
55
  }
@@ -186,6 +186,7 @@ export enum Icon {
186
186
  user = 'users-01',
187
187
  users = 'users-01',
188
188
  user_beta = 'shield-zap',
189
+ video = 'video-recorder',
189
190
  webhook = 'link-external-01',
190
191
  wit = 'wit',
191
192
  workspace = 'folder',
package/temba-modules.ts CHANGED
@@ -56,6 +56,7 @@ import { Mask } from './src/mask/Mask';
56
56
  import { TembaUser } from './src/user/TembaUser';
57
57
  import { TemplateEditor } from './src/templates/TemplateEditor';
58
58
  import { Toast } from './src/toast/Toast';
59
+ import { MediaPicker } from './src/mediapicker/MediaPicker';
59
60
 
60
61
  export function addCustomElement(name: string, comp: any) {
61
62
  if (!window.customElements.get(name)) {
@@ -122,3 +123,4 @@ addCustomElement('temba-mask', Mask);
122
123
  addCustomElement('temba-user', TembaUser);
123
124
  addCustomElement('temba-template-editor', TemplateEditor);
124
125
  addCustomElement('temba-toast', Toast);
126
+ addCustomElement('temba-media-picker', MediaPicker);