@arthakosh/chat-widget 0.3.12 → 0.3.15
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/esm2022/lib/chat-widget-notification/chat-widget-notification.component.mjs +5 -10
- package/esm2022/lib/chat-widget.service.mjs +15 -7
- package/esm2022/lib/chat-window/chat-window.component.mjs +3 -12
- package/esm2022/lib/core/services/chat.service.mjs +21 -45
- package/esm2022/lib/core/services/socket.service.mjs +44 -17
- package/fesm2022/arthakosh-chat-widget.mjs +78 -81
- package/fesm2022/arthakosh-chat-widget.mjs.map +1 -1
- package/lib/chat-widget-notification/chat-widget-notification.component.d.ts +1 -3
- package/lib/chat-widget.service.d.ts +5 -2
- package/lib/chat-window/chat-window.component.d.ts +0 -1
- package/lib/core/services/chat.service.d.ts +0 -3
- package/lib/core/services/socket.service.d.ts +19 -5
- package/package.json +1 -1
|
@@ -34,11 +34,10 @@ class SocketService {
|
|
|
34
34
|
constructor(config) {
|
|
35
35
|
this.config = config;
|
|
36
36
|
this.listeners = new Map();
|
|
37
|
+
this.connect();
|
|
37
38
|
}
|
|
38
39
|
connect() {
|
|
39
|
-
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
40
|
+
// this.socket = new WebSocket('wss://coreuatarthkosh.sarjak.com/ws/');
|
|
42
41
|
this.socket = new WebSocket(`${this.config.wsBaseUrl}/ws/`);
|
|
43
42
|
this.socket.onopen = () => {
|
|
44
43
|
console.log('[WS] Connected');
|
|
@@ -53,15 +52,25 @@ class SocketService {
|
|
|
53
52
|
};
|
|
54
53
|
this.socket.onclose = () => {
|
|
55
54
|
console.warn('[WS] Disconnected – reconnecting...');
|
|
56
|
-
|
|
55
|
+
setTimeout(() => this.connect(), 2000);
|
|
57
56
|
};
|
|
58
57
|
}
|
|
58
|
+
emit(event, data) {
|
|
59
|
+
const payload = { event, data };
|
|
60
|
+
this.socket.send(JSON.stringify(payload));
|
|
61
|
+
}
|
|
59
62
|
joinRoom(roomId) {
|
|
60
|
-
this.emit('joinRoom',
|
|
63
|
+
this.emit('joinRoom', roomId);
|
|
64
|
+
}
|
|
65
|
+
joinUserChannel(userId) {
|
|
66
|
+
this.emit('joinUserChannel', userId);
|
|
67
|
+
}
|
|
68
|
+
sendMessage(data) {
|
|
69
|
+
this.emit('sendMessage', data);
|
|
70
|
+
}
|
|
71
|
+
messageSeen(data) {
|
|
72
|
+
this.emit('messageSeen', data);
|
|
61
73
|
}
|
|
62
|
-
// --------------------------------------------------
|
|
63
|
-
// 🔔 Event subscription (PRIMARY API)
|
|
64
|
-
// --------------------------------------------------
|
|
65
74
|
on(event) {
|
|
66
75
|
return new Observable(sub => {
|
|
67
76
|
const handler = (data) => sub.next(data);
|
|
@@ -75,13 +84,31 @@ class SocketService {
|
|
|
75
84
|
};
|
|
76
85
|
});
|
|
77
86
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
onNewMessage(cb) {
|
|
88
|
+
this.addListener('newMessage', cb);
|
|
89
|
+
}
|
|
90
|
+
offNewMessage(cb) {
|
|
91
|
+
this.removeListener('newMessage', cb);
|
|
92
|
+
}
|
|
93
|
+
onMessageSeen(cb) {
|
|
94
|
+
this.addListener('messageSeen', cb);
|
|
95
|
+
}
|
|
96
|
+
offMessageSeen(cb) {
|
|
97
|
+
this.removeListener('messageSeen', cb);
|
|
98
|
+
}
|
|
99
|
+
addListener(event, cb) {
|
|
100
|
+
if (!this.listeners.has(event)) {
|
|
101
|
+
this.listeners.set(event, []);
|
|
102
|
+
}
|
|
103
|
+
this.listeners.get(event).push(cb);
|
|
104
|
+
}
|
|
105
|
+
removeListener(event, cb) {
|
|
106
|
+
if (!cb) {
|
|
107
|
+
this.listeners.delete(event);
|
|
83
108
|
return;
|
|
84
|
-
|
|
109
|
+
}
|
|
110
|
+
const arr = this.listeners.get(event) || [];
|
|
111
|
+
this.listeners.set(event, arr.filter(h => h !== cb));
|
|
85
112
|
}
|
|
86
113
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SocketService, deps: [{ token: CHAT_CONFIG }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
87
114
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SocketService, providedIn: 'root' }); }
|
|
@@ -173,22 +200,27 @@ class ChatService {
|
|
|
173
200
|
}
|
|
174
201
|
listenForNotifications() {
|
|
175
202
|
console.log('Listening for notifications');
|
|
176
|
-
// 🔔
|
|
177
|
-
this.socket.on('
|
|
178
|
-
|
|
203
|
+
// 🔔 ADDED TO ROOM — only added user
|
|
204
|
+
this.socket.on('addedToRoom').subscribe((msg) => {
|
|
205
|
+
console.log('addedToRoom', msg);
|
|
206
|
+
if (msg.addedUserId !== this.currentUser().userId)
|
|
207
|
+
return;
|
|
179
208
|
this.upsertNotification({
|
|
180
|
-
roomId,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
? `${n.payload.senderName}: New message`
|
|
184
|
-
: undefined,
|
|
185
|
-
unreadDelta: n.unreadDelta ?? 1,
|
|
209
|
+
roomId: msg.chatRoomId,
|
|
210
|
+
roomName: msg.roomName,
|
|
211
|
+
type: 'added'
|
|
186
212
|
});
|
|
187
213
|
});
|
|
188
|
-
// 🔔
|
|
189
|
-
this.socket.on('newMessage').subscribe(msg => {
|
|
190
|
-
|
|
191
|
-
|
|
214
|
+
// 🔔 NEW MESSAGE — everyone EXCEPT sender
|
|
215
|
+
this.socket.on('newMessage').subscribe((msg) => {
|
|
216
|
+
console.log('newMessage', msg);
|
|
217
|
+
if (msg.senderId === this.currentUser().userId)
|
|
218
|
+
return;
|
|
219
|
+
this.upsertNotification({
|
|
220
|
+
roomId: msg.chatRoomId,
|
|
221
|
+
type: 'message',
|
|
222
|
+
lastMessage: msg.content
|
|
223
|
+
});
|
|
192
224
|
});
|
|
193
225
|
}
|
|
194
226
|
upsertNotification(data) {
|
|
@@ -198,7 +230,7 @@ class ChatService {
|
|
|
198
230
|
const n = list[index];
|
|
199
231
|
list[index] = {
|
|
200
232
|
...n,
|
|
201
|
-
unreadCount: n.unreadCount +
|
|
233
|
+
unreadCount: n.unreadCount + 1,
|
|
202
234
|
lastMessage: data.lastMessage ?? n.lastMessage,
|
|
203
235
|
timestamp: Date.now()
|
|
204
236
|
};
|
|
@@ -210,7 +242,7 @@ class ChatService {
|
|
|
210
242
|
roomId: data.roomId,
|
|
211
243
|
roomName: data.roomName,
|
|
212
244
|
type: data.type,
|
|
213
|
-
unreadCount:
|
|
245
|
+
unreadCount: 1,
|
|
214
246
|
lastMessage: data.lastMessage,
|
|
215
247
|
timestamp: Date.now()
|
|
216
248
|
}
|
|
@@ -231,35 +263,6 @@ class ChatService {
|
|
|
231
263
|
this.hasNewNotification.set(false);
|
|
232
264
|
}, 1200);
|
|
233
265
|
}
|
|
234
|
-
getAllNotifications(loggedInUserId) {
|
|
235
|
-
return this.http.get(`${this.baseUrl}/api/chat/notifications/get_all/${loggedInUserId}/`);
|
|
236
|
-
}
|
|
237
|
-
hydrateNotifications(userId) {
|
|
238
|
-
return this.getAllNotifications(userId).subscribe(rows => {
|
|
239
|
-
const grouped = new Map();
|
|
240
|
-
rows.forEach(n => {
|
|
241
|
-
const roomId = n.entityId;
|
|
242
|
-
if (!grouped.has(roomId)) {
|
|
243
|
-
grouped.set(roomId, {
|
|
244
|
-
roomId,
|
|
245
|
-
unreadCount: 1,
|
|
246
|
-
lastMessage: n.title,
|
|
247
|
-
timestamp: Date.now(),
|
|
248
|
-
type: n.eventType,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
else {
|
|
252
|
-
grouped.get(roomId).unreadCount += 1;
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
this.notifications.set([...grouped.values()]);
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
markRoomAsRead(roomId) {
|
|
259
|
-
this.notifications.update(list => list.map(n => n.roomId === roomId
|
|
260
|
-
? { ...n, unreadCount: 0 }
|
|
261
|
-
: n));
|
|
262
|
-
}
|
|
263
266
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ChatService, deps: [{ token: i1.HttpClient }, { token: CHAT_CONFIG }, { token: SocketService }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
264
267
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ChatService, providedIn: 'root' }); }
|
|
265
268
|
}
|
|
@@ -297,7 +300,6 @@ class ChatWindowComponent {
|
|
|
297
300
|
}
|
|
298
301
|
ngOnInit() {
|
|
299
302
|
this.socket.joinRoom(this.chatRoomId);
|
|
300
|
-
// this.socket.joinUserChannel(this.chatService.currentUser().userId);
|
|
301
303
|
this.loadUsers();
|
|
302
304
|
this.loadMessages();
|
|
303
305
|
this.listenSocket();
|
|
@@ -372,14 +374,6 @@ class ChatWindowComponent {
|
|
|
372
374
|
}).subscribe(() => {
|
|
373
375
|
this.newMessage = '';
|
|
374
376
|
this.replyTo = null;
|
|
375
|
-
this.getUserNotifications();
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
getUserNotifications() {
|
|
379
|
-
this.chatService.getAllNotifications(this.chatService.currentUser().userId)
|
|
380
|
-
.subscribe((res) => {
|
|
381
|
-
console.log('getUserNotifications', res);
|
|
382
|
-
this.chatService.notifications.set(res);
|
|
383
377
|
});
|
|
384
378
|
}
|
|
385
379
|
addUserToRoom() {
|
|
@@ -407,8 +401,8 @@ class ChatWindowComponent {
|
|
|
407
401
|
return msg.id;
|
|
408
402
|
}
|
|
409
403
|
ngOnDestroy() {
|
|
410
|
-
|
|
411
|
-
|
|
404
|
+
this.socket.offNewMessage();
|
|
405
|
+
this.socket.offMessageSeen();
|
|
412
406
|
this.observer?.disconnect();
|
|
413
407
|
}
|
|
414
408
|
ngAfterViewInit() {
|
|
@@ -655,13 +649,9 @@ class ChatWidgetNotificationComponent {
|
|
|
655
649
|
constructor() {
|
|
656
650
|
this.open = false;
|
|
657
651
|
this.chatService = inject(ChatService);
|
|
658
|
-
this.
|
|
652
|
+
this.notifications = this.chatService.notifications;
|
|
659
653
|
this.hasNew = this.chatService.hasNewNotification;
|
|
660
|
-
this.totalCount = computed(() => this.
|
|
661
|
-
}
|
|
662
|
-
ngOnInit() {
|
|
663
|
-
this.socketService.connect();
|
|
664
|
-
this.chatService.hydrateNotifications(this.chatService.currentUser().userId);
|
|
654
|
+
this.totalCount = computed(() => this.notifications().reduce((a, b) => a + b.unreadCount, 0));
|
|
665
655
|
}
|
|
666
656
|
toggle() {
|
|
667
657
|
this.open = !this.open;
|
|
@@ -672,14 +662,14 @@ class ChatWidgetNotificationComponent {
|
|
|
672
662
|
this.open = false;
|
|
673
663
|
}
|
|
674
664
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ChatWidgetNotificationComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
675
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: ChatWidgetNotificationComponent, isStandalone: true, selector: "chat-widget-notification", ngImport: i0, template: "<div (click)=\"toggle()\" class=\"notif-icon\">\r\n <i class=\"pi pi-bell\" [class.pulse]=\"hasNew()\"></i>\r\n\r\n @if (totalCount() > 0) {\r\n <span class=\"dot\"></span>\r\n <span class=\"badge\">{{ totalCount() }}</span>\r\n }\r\n</div>\r\n\r\n@if (open) {\r\n <div class=\"notif-panel\">\r\n<!-- <div class=\"notif-header\">Notifications</div>-->\r\n\r\n <div class=\"notif-list\">\r\n @for (n of
|
|
665
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: ChatWidgetNotificationComponent, isStandalone: true, selector: "chat-widget-notification", ngImport: i0, template: "<div (click)=\"toggle()\" class=\"notif-icon\">\r\n <i class=\"pi pi-bell\" [class.pulse]=\"hasNew()\"></i>\r\n\r\n @if (totalCount() > 0) {\r\n <span class=\"dot\"></span>\r\n <span class=\"badge\">{{ totalCount() }}</span>\r\n }\r\n</div>\r\n\r\n@if (open) {\r\n <div class=\"notif-panel\">\r\n<!-- <div class=\"notif-header\">Notifications</div>-->\r\n\r\n <div class=\"notif-list\">\r\n @for (n of notifications(); track n.roomId) {\r\n <div class=\"notif-item\" (click)=\"openRoom(n)\">\r\n <div class=\"notif-left\">\r\n <i class=\"pi pi-comments\"></i>\r\n </div>\r\n\r\n <div class=\"notif-body\">\r\n <div class=\"notif-title\">{{ n.roomName }}</div>\r\n <div class=\"notif-text\">\r\n {{ n.lastMessage || 'You were added to this room' }}\r\n </div>\r\n </div>\r\n\r\n <span class=\"count\">{{ n.unreadCount }}</span>\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n}\r\n", styles: [".notif-icon{position:relative;cursor:pointer;display:flex;align-items:center;justify-content:center}.notif-icon i{font-size:1.4rem;color:#444}.pulse{animation:pulse 1.2s ease-out}@keyframes pulse{0%{transform:scale(1)}40%{transform:scale(1.2)}to{transform:scale(1)}}.dot{position:absolute;top:2px;right:4px;width:8px;height:8px;background:#e53935;border-radius:50%}.badge{position:absolute;top:-6px;right:-10px;background:#e53935;color:#fff;font-size:.65rem;min-width:18px;height:18px;padding:0 5px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-weight:600}.notif-panel{position:absolute;top:42px;right:0;width:320px;background:#fff;border-radius:8px;box-shadow:0 12px 28px #00000026;z-index:9999;overflow:hidden;animation:fadeIn .15s ease-out}@keyframes fadeIn{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.notif-header{padding:12px 16px;font-weight:600;font-size:.9rem;border-bottom:1px solid #eee}.notif-list{max-height:360px;overflow-y:auto}.notif-item{display:flex;align-items:center;padding:12px 14px;cursor:pointer;transition:background .15s ease}.notif-item:hover{background:#f5f7fa}.notif-left{width:36px;height:36px;border-radius:50%;background:#e3f2fd;color:#1976d2;display:flex;align-items:center;justify-content:center;margin-right:12px}.notif-body{flex:1;min-width:0}.notif-title{font-size:.85rem;font-weight:600;color:#222;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.notif-text{font-size:.75rem;color:#666;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.count{background:#1976d2;color:#fff;font-size:.65rem;min-width:20px;height:20px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-weight:600}.notif-item{background:#f0f6ff}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }] }); }
|
|
676
666
|
}
|
|
677
667
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ChatWidgetNotificationComponent, decorators: [{
|
|
678
668
|
type: Component,
|
|
679
669
|
args: [{ selector: 'chat-widget-notification', standalone: true, imports: [
|
|
680
670
|
CommonModule,
|
|
681
671
|
FormsModule
|
|
682
|
-
], template: "<div (click)=\"toggle()\" class=\"notif-icon\">\r\n <i class=\"pi pi-bell\" [class.pulse]=\"hasNew()\"></i>\r\n\r\n @if (totalCount() > 0) {\r\n <span class=\"dot\"></span>\r\n <span class=\"badge\">{{ totalCount() }}</span>\r\n }\r\n</div>\r\n\r\n@if (open) {\r\n <div class=\"notif-panel\">\r\n<!-- <div class=\"notif-header\">Notifications</div>-->\r\n\r\n <div class=\"notif-list\">\r\n @for (n of
|
|
672
|
+
], template: "<div (click)=\"toggle()\" class=\"notif-icon\">\r\n <i class=\"pi pi-bell\" [class.pulse]=\"hasNew()\"></i>\r\n\r\n @if (totalCount() > 0) {\r\n <span class=\"dot\"></span>\r\n <span class=\"badge\">{{ totalCount() }}</span>\r\n }\r\n</div>\r\n\r\n@if (open) {\r\n <div class=\"notif-panel\">\r\n<!-- <div class=\"notif-header\">Notifications</div>-->\r\n\r\n <div class=\"notif-list\">\r\n @for (n of notifications(); track n.roomId) {\r\n <div class=\"notif-item\" (click)=\"openRoom(n)\">\r\n <div class=\"notif-left\">\r\n <i class=\"pi pi-comments\"></i>\r\n </div>\r\n\r\n <div class=\"notif-body\">\r\n <div class=\"notif-title\">{{ n.roomName }}</div>\r\n <div class=\"notif-text\">\r\n {{ n.lastMessage || 'You were added to this room' }}\r\n </div>\r\n </div>\r\n\r\n <span class=\"count\">{{ n.unreadCount }}</span>\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n}\r\n", styles: [".notif-icon{position:relative;cursor:pointer;display:flex;align-items:center;justify-content:center}.notif-icon i{font-size:1.4rem;color:#444}.pulse{animation:pulse 1.2s ease-out}@keyframes pulse{0%{transform:scale(1)}40%{transform:scale(1.2)}to{transform:scale(1)}}.dot{position:absolute;top:2px;right:4px;width:8px;height:8px;background:#e53935;border-radius:50%}.badge{position:absolute;top:-6px;right:-10px;background:#e53935;color:#fff;font-size:.65rem;min-width:18px;height:18px;padding:0 5px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-weight:600}.notif-panel{position:absolute;top:42px;right:0;width:320px;background:#fff;border-radius:8px;box-shadow:0 12px 28px #00000026;z-index:9999;overflow:hidden;animation:fadeIn .15s ease-out}@keyframes fadeIn{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.notif-header{padding:12px 16px;font-weight:600;font-size:.9rem;border-bottom:1px solid #eee}.notif-list{max-height:360px;overflow-y:auto}.notif-item{display:flex;align-items:center;padding:12px 14px;cursor:pointer;transition:background .15s ease}.notif-item:hover{background:#f5f7fa}.notif-left{width:36px;height:36px;border-radius:50%;background:#e3f2fd;color:#1976d2;display:flex;align-items:center;justify-content:center;margin-right:12px}.notif-body{flex:1;min-width:0}.notif-title{font-size:.85rem;font-weight:600;color:#222;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.notif-text{font-size:.75rem;color:#666;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.count{background:#1976d2;color:#fff;font-size:.65rem;min-width:20px;height:20px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-weight:600}.notif-item{background:#f0f6ff}\n"] }]
|
|
683
673
|
}], ctorParameters: () => [] });
|
|
684
674
|
|
|
685
675
|
class ChatWidgetModule {
|
|
@@ -722,8 +712,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
722
712
|
}] });
|
|
723
713
|
|
|
724
714
|
class ChatWidgetService {
|
|
725
|
-
constructor(
|
|
726
|
-
this.chatService =
|
|
715
|
+
constructor() {
|
|
716
|
+
this.chatService = inject(ChatService);
|
|
717
|
+
this.socketService = inject(SocketService);
|
|
727
718
|
}
|
|
728
719
|
initUser(user) {
|
|
729
720
|
this.chatService.currentUser.set({
|
|
@@ -732,13 +723,19 @@ class ChatWidgetService {
|
|
|
732
723
|
name: user.name
|
|
733
724
|
});
|
|
734
725
|
}
|
|
735
|
-
|
|
726
|
+
joinUserChannel(userId) {
|
|
727
|
+
this.socketService.joinUserChannel(userId);
|
|
728
|
+
this.socketService.on('notificationCreated').subscribe(msg => {
|
|
729
|
+
console.log('joinUserChannel', msg);
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ChatWidgetService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
736
733
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ChatWidgetService, providedIn: 'root' }); }
|
|
737
734
|
}
|
|
738
735
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ChatWidgetService, decorators: [{
|
|
739
736
|
type: Injectable,
|
|
740
737
|
args: [{ providedIn: 'root' }]
|
|
741
|
-
}], ctorParameters: () => [
|
|
738
|
+
}], ctorParameters: () => [] });
|
|
742
739
|
|
|
743
740
|
/*
|
|
744
741
|
* Public API Surface of chat-widget
|