UNPKG

19.7 kBJavaScriptView Raw
1
2import { addEventOnce, removeEvent, addEvent } from './utils/DOM/events';
3import { BunnyDate } from './BunnyDate';
4
5export const MessengerConfig = {
6
7 // key names for localStorage
8 storageKeyCurrentUser: 'bunny.messenger.currentChannelUser',
9 storageKeyMessages: 'bunny.messenger.messages',
10 storageKeyAuthUserId: 'bunny.messenger.authUserId',
11 storageKeyCacheDate: 'bunny.messenger.dateCached',
12
13 messagesPerPage: 20,
14 checkForNewMessagesInterval: 10, //seconds
15 cacheLifetime: 3, // hours
16
17 // all elements on page with this attribute will toggle private messenger with userId on click
18 // userId must be passed as a value to this attribute
19 //
20 // to attach click event on elements inserted later use Messenger.initToggles(container)
21 // where all elements with this attribute within "container" will be initiated
22 initAttribute: 'data-messenger',
23
24 // markup and style settings
25 id: 'pm',
26 idName: 'pm_name',
27 idClose: 'pm_close',
28 idBody: 'pm_body',
29 idInput: 'pm_input',
30
31 idNotificationsBtn: 'pm_notification_btn',
32 attrUnread: 'new',
33 tagNotification: 'PMNOTIFICATION',
34
35 classActive: 'active'
36
37};
38
39export const MessengerUI = {
40
41 Config: MessengerConfig,
42
43 // Reader
44
45 getBlock() {
46 return document.getElementById(this.Config.id) || false;
47 },
48
49 getBody() {
50 return document.getElementById(this.Config.idBody) || false;
51 },
52
53 getNameBlock() {
54 return document.getElementById(this.Config.idName) || false;
55 },
56
57 getToggleButtons(container = document) {
58 return container.querySelectorAll('[' + this.Config.initAttribute + ']') || false;
59 },
60
61 getToggleButtonUserId(btn) {
62 const userId = btn.getAttribute(this.Config.initAttribute);
63 if (userId === undefined || userId === '') {
64 throw new Error(`Bunny Messenger: toggle button should have a non-empty attribute "${this.Config.initAttribute}" value representing ID of the second user`)
65 }
66 return userId;
67 },
68
69 getCloseBtn() {
70 return document.getElementById(this.Config.idClose) || false;
71 },
72
73 getInput() {
74 return document.getElementById(this.Config.idInput) || false;
75 },
76
77 getNotificationsBtn() {
78 return document.getElementById(this.Config.idNotificationsBtn) || false;
79 },
80
81 isNotificationElement(element) {
82 return element.tagName === this.Config.tagNotification;
83 },
84
85 isNotificationUnread(btn) {
86 return btn.hasAttribute(this.Config.attrUnread);
87 },
88
89 markNotificationAsRead(btn) {
90 btn.removeAttribute(this.Config.attrUnread);
91 this.decreaseNotificationCounter();
92 },
93
94
95
96
97 // Writer
98 clearBody() {
99 this.getBody().innerHTML = '';
100 },
101
102 setBlockUserName(name) {
103 this.getNameBlock().textContent = name;
104 },
105
106 appendAnswer(id, text) {
107 const msg = this.createAnswer(id, text);
108 this.getBody().appendChild(msg);
109 this.scrollToBodyBottom();
110 },
111
112 appendMessages(messages, currentUser, after = true) {
113 const f = this.createAllMessages(messages, currentUser);
114 const body = this.getBody();
115 if (after) {
116 body.appendChild(f);
117 this.scrollToBodyBottom();
118 } else {
119 const firstChild = body.firstChild;
120 const originalOffsetTop = firstChild.offsetTop;
121 body.insertBefore(f, firstChild);
122 const newOffsetTop = firstChild.offsetTop;
123 const delta = newOffsetTop - originalOffsetTop + body.scrollTop;
124 body.scrollTop = delta;
125 }
126 },
127
128 openBlockAndAppendMessages(messages, currentUser, focusInput = false) {
129 this.setBlockUserName(currentUser.name);
130 this.showBlock();
131 this.appendMessages(messages, currentUser);
132 if (focusInput) {
133 this.getInput().focus();
134 }
135 },
136
137 decreaseNotificationCounter() {
138 const btn = this.getNotificationsBtn();
139 if (btn) {
140 if (btn.dataset.count > 1) {
141 btn.dataset.count--;
142 } else {
143 delete btn.dataset.count;
144 }
145 }
146 },
147
148 increaseNotificationCounter() {
149 const btn = this.getNotificationsBtn();
150 if (btn) {
151 if (btn.dataset.count === undefined) {
152 btn.dataset.count = 1;
153 } else {
154 btn.dataset.count++;
155 }
156 }
157 },
158
159
160
161 // Updater
162
163 blinkBlock() {
164 this.getBlock().classList.add(this.Config.classActive);
165 setTimeout(() => {
166 this.getBlock().classList.remove(this.Config.classActive);
167 }, 1000);
168 },
169
170 showBlock() {
171 this.getBlock().removeAttribute('hidden');
172 },
173
174 hideBlock() {
175 this.getBlock().setAttribute('hidden', '');
176 },
177
178 scrollToBodyBottom() {
179 const body = this.getBody();
180 body.scrollTop = body.scrollHeight;
181 },
182
183
184
185
186 // Creator
187
188 createMessage(id, text, sender, image = true) {
189 const msg = document.createElement('message');
190 msg.dataset.id = id;
191 const imgWrapper = document.createElement('div');
192 if (image) {
193 const link = document.createElement('a');
194 link.setAttribute('href', sender.profileUrl);
195 const img = new Image;
196 img.src = sender.photoUrl;
197 link.appendChild(img);
198 imgWrapper.appendChild(link);
199 }
200 const p = document.createElement('p');
201 p.textContent = text;
202 p.innerHTML = p.innerHTML.replace(/\r?\n/g, '<br />');
203 msg.appendChild(imgWrapper);
204 msg.appendChild(p);
205 return msg;
206 },
207
208 createTime(time) {
209 const t = document.createElement('time');
210 t.textContent = time;
211 return t;
212 },
213
214 createAnswer(id, text) {
215 const msg = document.createElement('message');
216 msg.dataset.id = id;
217 msg.setAttribute('type', 'answer');
218 const p = document.createElement('p');
219 p.textContent = text;
220 p.innerHTML = p.innerHTML.replace(/\r?\n/g, '<br />');
221 msg.appendChild(p);
222 return msg;
223 },
224
225 createAllMessages(messages, channelCurrentUser) {
226 const f = document.createDocumentFragment();
227 let prevDateRendered = null;
228 messages.forEach(message => {
229
230 const date = BunnyDate.toEuDate(new Date(message.dateCreated));
231 if (prevDateRendered !== date) {
232 const t = this.createTime(date);
233 f.appendChild(t);
234 prevDateRendered = date;
235 }
236
237 let msg;
238 if (Server.user.id == message.senderId) {
239 msg = this.createAnswer(message.id, message.message);
240 } else {
241 msg = this.createMessage(message.id, message.message, channelCurrentUser);
242 }
243
244 f.appendChild(msg);
245 });
246 return f;
247 }
248
249};
250
251export const MessengerEvents = {
252
253 UI: MessengerUI,
254 Config: MessengerConfig,
255
256 /**
257 * @type {Number|null}
258 */
259 checkForMessagesIntervalId: null,
260
261 addMessageEventId: null,
262 blockCloseEventId: null,
263 loadOlderMessagesEventId: null,
264
265
266 addToggleButtonsEvent(handler, container = document) {
267 const btns = this.UI.getToggleButtons(container);
268 [].forEach.call(btns, btn => {
269 btn.addEventListener('click', (event) => {
270 handler(this.UI.getToggleButtonUserId(btn), event);
271 })
272 });
273 },
274
275
276
277
278
279 addBlockCloseEvent(handler) {
280 this.blockCloseEventId = addEvent(this.UI.getCloseBtn(), 'click', handler);
281 },
282
283 removeBlockCloseEvent() {
284 this.blockCloseEventId = removeEvent(this.UI.getCloseBtn(), 'click', this.blockCloseEventId);
285 },
286
287
288
289
290
291 addIntervalCheckForMessages(handler) {
292 const interval = this.Config.checkForNewMessagesInterval * 1000;
293 this.checkForMessagesIntervalId = setInterval(handler, interval);
294 },
295
296 removeIntervalCheckForMessages() {
297 clearInterval(this.checkForMessagesIntervalId);
298 this.checkForMessagesIntervalId = null;
299 },
300
301
302
303
304
305 addMessageEvent(handler) {
306 const input = this.UI.getInput();
307 this.addMessageEventId = addEventOnce(input, 'keydown', e => {
308 if (!e.shiftKey && e.keyCode === KEY_ENTER) {
309 e.preventDefault();
310 handler(input.value);
311 input.value = '';
312 }
313 }, 100)
314 },
315
316 removeMessageEvent() {
317 const input = this.UI.getInput();
318 this.addMessageEventId = removeEvent(input, 'keydown', this.addMessageEventId);
319 },
320
321
322
323
324 addLoadOlderMessagesEvent(handler) {
325 const body = this.UI.getBody();
326 this.loadOlderMessagesEventId = addEventOnce(body, 'scroll', e => {
327 if (body.scrollTop < 100) {
328 handler();
329 }
330 }, 100);
331 },
332
333 removeLoadOlderMessagesEvent() {
334 this.loadOlderMessagesEventId = removeEvent(this.UI.getBody(), 'scroll', this.scrollEventId);
335 }
336
337};
338
339export const Messenger = {
340
341 Config: MessengerConfig,
342 UI: MessengerUI,
343 Events: MessengerEvents,
344
345 Model: null,
346 curPage: 1,
347
348 checkForMessagesHandler: null,
349
350 handlers: {
351 messagesReceived: [],
352 messageSent: []
353 },
354
355 init(Model, authUserId) {
356 if (this.testInit() === false) {
357 return false;
358 }
359 this.testModel(Model);
360 this.Model = Model;
361 this.authUserId = authUserId;
362
363 this.initToggles();
364
365 this.checkChannelAuth(authUserId);
366
367 if (this.isChannelInitialized()) {
368 this.open();
369 }
370 return true;
371 },
372
373 checkChannelAuth(authUserId) {
374 const storedAuthUserId = this.getAuthUserId();
375 if (storedAuthUserId !== null && storedAuthUserId != authUserId) {
376 // different auth user ID, destroy channel
377 this.destroyChannel();
378 }
379 },
380
381 initToggles(container = document) {
382 this.Events.addToggleButtonsEvent(this.toggleChannel.bind(this), container)
383 },
384
385
386 extendConfig(Config) {
387 const NewConfig = Object.assign(this.Config, Config);
388 this.Config = NewConfig;
389 this.UI.Config = NewConfig;
390 this.Events.Config = NewConfig;
391 },
392
393 extendUI(UI) {
394 const NewUI = Object.assign(this.UI, UI);
395 this.UI = NewUI;
396 this.Events.UI = NewUI;
397 },
398
399 extendEvents(Events) {
400 const NewEvents = Object.assign(this.Events, Events);
401 this.Events = NewEvents;
402 },
403
404
405
406
407 onMessagesReceived(callback) {
408 this.handlers.messagesReceived.push(callback);
409 },
410
411 onMessageSent(callback) {
412 this.handlers.messageSent.push(callback);
413 },
414
415 testInit() {
416 return this.UI.getBlock() !== false;
417 },
418
419 testModel(Model) {
420 if (typeof Model.read !== 'function'
421 && typeof Model.check !== 'function'
422 && typeof Model.create !== 'function')
423 {
424 throw new Error('Bunny Messenger: Model passed to Messenger.init() does not have methods read(), check() and create()');
425 }
426 },
427
428
429 toggleChannel(userId, event) {
430 if (this.isChannelInitialized()) {
431 const curUser = this.getChannelCurrentUser();
432 this.close();
433 if (curUser.id != userId) {
434 this.open(userId, true);
435 }
436 } else {
437 this.open(userId, true);
438 }
439
440 const el = event.currentTarget;
441 if (this.UI.isNotificationElement(el)) {
442 if (this.UI.isNotificationUnread(el)) {
443 this.UI.markNotificationAsRead(el);
444 }
445 }
446 },
447
448 checkForNewMessages() {
449 const curUser = this.getChannelCurrentUser();
450 this.Model.check(curUser.id, this.getChannelLastMessageId()).then(res => {
451 if (res.messages.length > 0) {
452 this.handlers.messagesReceived.forEach(messagesReceived => messagesReceived(res.messages));
453 this.setChannelMessages(res.messages);
454 this.UI.appendMessages(res.messages, curUser);
455 this.UI.blinkBlock();
456 }
457 });
458 },
459
460 loadOlderMessages() {
461 const curUser = this.getChannelCurrentUser();
462 this.Model.read(curUser.id, this.curPage + 1).then(res => {
463 if (res.messages.length > 0) {
464 this.curPage = this.curPage + 1;
465 this.UI.appendMessages(res.messages.reverse(), curUser, false);
466 }
467 });
468 },
469
470
471
472 sendMessage(userId, text) {
473 this.Model.create(userId, text).then(res => {
474 this.handlers.messageSent.forEach(messageSent => messageSent(res));
475 this.setChannelMessages(res);
476 this.UI.appendAnswer(res.id, text);
477 });
478 },
479
480 sendMessageToCurrentUser(text) {
481 const curUser = this.getChannelCurrentUser();
482 this.sendMessage(curUser.id, text);
483 },
484
485
486 /**
487 * Shows PM window,
488 * Initializes new PM channel, receives last messages between both users
489 * Renders messages into window
490 *
491 * @param userId
492 * @param focusInput
493 */
494 open(userId = null, focusInput = false) {
495 this.initChannel(userId).then(messages => {
496 const channelCurrentUser = this.getChannelCurrentUser();
497 this.UI.openBlockAndAppendMessages(messages, channelCurrentUser, focusInput);
498 this.Events.addIntervalCheckForMessages(this.checkForNewMessages.bind(this));
499 this.Events.addBlockCloseEvent(this.close.bind(this));
500 this.Events.addMessageEvent(this.sendMessageToCurrentUser.bind(this));
501 this.Events.addLoadOlderMessagesEvent(this.loadOlderMessages.bind(this));
502 });
503 },
504
505 close() {
506 this.curPage = 1;
507 this.UI.hideBlock();
508 this.destroyChannel();
509 this.UI.clearBody();
510 this.Events.removeIntervalCheckForMessages();
511 this.Events.removeBlockCloseEvent();
512 this.Events.removeMessageEvent();
513 this.Events.removeLoadOlderMessagesEvent();
514 },
515
516
517
518
519 /**
520 * Initializes new private message channel
521 * when user opens private message window
522 * by clicking on Private message from PM notifications
523 * or by clicking send PM button on user profile page
524 *
525 * Stores in localStorage current user data
526 * and last 12 messages
527 * If already initialized doesn't do HTTP request again
528 *
529 * Whenever there is initialized PM channel,
530 * on each request PM window is opened
531 *
532 * Supports only one opened PM window and channel at the same time
533 * When new PM to new user is initialized - old one is destroyed / overridden
534 *
535 * @param {Number|String} userId
536 *
537 * @returns Promise
538 */
539 initChannel(userId = null) {
540 return new Promise(resolve => {
541 if (userId === null) {
542 // don't do HTTP request to same user again, get data from localStorage
543
544 // check if cache expired
545 if (this.isChannelCacheExpired()) {
546 userId = this.getChannelCurrentUser().id;
547 this._initChannelLoad(userId, resolve);
548 } else {
549 // not expired, get last messages from cache
550 const messages = this.getChannelMessages();
551 resolve(messages);
552 }
553 } else {
554 this._initChannelLoad(userId, resolve);
555 }
556 });
557 },
558
559 _initChannelLoad(userId, resolve) {
560 this.Model.read(userId).then(res => {
561 this.setAuthUserId(this.authUserId);
562 this.setChannelCurrentUser(res.user);
563 this.setChannelMessages(res.messages.reverse());
564 resolve(res.messages);
565 });
566 },
567
568 isChannelInitialized() {
569 return localStorage.getItem(this.Config.storageKeyCurrentUser) !== null;
570 },
571
572 getChannelCurrentUser() {
573 return JSON.parse(localStorage.getItem(this.Config.storageKeyCurrentUser));
574 },
575
576 getChannelMessages() {
577 return JSON.parse(localStorage.getItem(this.Config.storageKeyMessages));
578 },
579
580 getChannelLastMessageId() {
581 const messages = this.getChannelMessages();
582 return messages[messages.length - 1].id;
583 },
584
585 setAuthUserId(authUserId) {
586 return localStorage.setItem(this.Config.storageKeyAuthUserId, authUserId);
587 },
588
589 getAuthUserId() {
590 return localStorage.getItem(this.Config.storageKeyAuthUserId);
591 },
592
593 setChannelCurrentUser(user) {
594 localStorage.setItem(this.Config.storageKeyCurrentUser, JSON.stringify(user));
595 },
596
597 updateChannelDateCached() {
598 localStorage.setItem(this.Config.storageKeyCacheDate, (new Date).toISOString());
599 },
600
601 getChannelDateCached() {
602 return localStorage.getItem(this.Config.storageKeyCacheDate);
603 },
604
605 isChannelCacheExpired() {
606 const now = new Date();
607 now.setHours(now.getHours() - this.Config.cacheLifetime);
608 const dateCached = new Date(this.getChannelDateCached());
609 return now > dateCached;
610 },
611
612 _addMessageToQueue(queue, message) {
613 let newQueue = Object.create(queue);
614 if (newQueue.length >= this.Config.messagesPerPage) {
615 // queue is full, remove first item
616 newQueue.shift();
617 }
618 newQueue.push(message);
619 return newQueue;
620 },
621
622 /**
623 * Creates or adds/updates message or messages to current channel queue (cache)
624 * If queue is full, older messages are removed from queue
625 * Stores queue in localStorage
626 * Updated date cached
627 *
628 * @config Queue limit is configured in MessengerConfig.messagesPerPage
629 * @config localStorage key name configured in MessengerConfig.storageKeyMessages
630 *
631 * @param {Array|Object} messages
632 *
633 * @returns {Array} new messages queue
634 */
635 setChannelMessages(messages) {
636 let currentMessages = this.getChannelMessages();
637 if (currentMessages === null) {
638 currentMessages = [];
639 }
640
641 if (Array.isArray(messages)) {
642 messages.forEach(message => {
643 currentMessages = this._addMessageToQueue(currentMessages, message);
644 });
645 } else {
646 currentMessages = this._addMessageToQueue(currentMessages, messages);
647 }
648
649 localStorage.setItem(this.Config.storageKeyMessages, JSON.stringify(currentMessages));
650 this.updateChannelDateCached();
651 return currentMessages;
652 },
653
654 /**
655 * Destroys PM channel, clears localStorage
656 */
657 destroyChannel() {
658 localStorage.removeItem(this.Config.storageKeyCurrentUser);
659 localStorage.removeItem(this.Config.storageKeyMessages);
660 localStorage.removeItem(this.Config.storageKeyAuthUserId);
661 localStorage.removeItem(this.Config.storageKeyCacheDate);
662 }
663
664};