diff --git a/.travis.yml b/.travis.yml index ce93082..e983a0f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -30,7 +30,7 @@ script: # FIX (Tests): node_modules/@angular/core/src/render3/ng_dev_mode.d.ts(9,11): error TS2451: Cannot redeclare block-scoped variable 'ngDevMode'. - rm -f node_modules/@angular/core/src/render3/ng_dev_mode.d.ts - - ng test --code-coverage --watch=false + - ng test --sourcemaps=false --code-coverage --watch=false after_success: - ./node_modules/.bin/codecov diff --git a/README.md b/README.md index c9a98b3..3214a9b 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ __Additional Settings__ * [linkfyEnabled]{boolean}: Transforms links within the messages to valid HTML links. Default is true. * [audioEnabled]{boolean}: Enables audio notifications on received messages. Default is true. * [audioSource]{string}: WAV source of the audio notification. Default is a RAW github WAV content from ng-chat repository. +* [persistWindowsState]{boolean}: Saves the state of current open windows on the local storage. Default is true. #### Implement your ChatAdapter: diff --git a/demo/aspnetcore_signalr/package.json b/demo/aspnetcore_signalr/package.json index 2435c44..66bf8ea 100644 --- a/demo/aspnetcore_signalr/package.json +++ b/demo/aspnetcore_signalr/package.json @@ -29,7 +29,7 @@ "bootstrap": "^3.3.7", "core-js": "^2.4.1", "jquery": "3.2.1", - "ng-chat": "1.0.4", + "ng-chat": "1.0.5", "ng2-loading-bar": "0.0.6", "reflect-metadata": "^0.1.10", "rxjs": "5.4.2", diff --git a/demo/offline_bot/package.json b/demo/offline_bot/package.json index c6dd7d2..b60284e 100644 --- a/demo/offline_bot/package.json +++ b/demo/offline_bot/package.json @@ -24,7 +24,7 @@ "core-js": "^2.5.3", "rxjs": "^5.5.6", "zone.js": "^0.8.20", - "ng-chat": "1.0.4" + "ng-chat": "1.0.5" }, "devDependencies": { "@angular/cli": "^1.6.4", diff --git a/src/ng-chat/assets/ng-chat.component.default.css b/src/ng-chat/assets/ng-chat.component.default.css index 3ca38e8..bb07b3d 100644 --- a/src/ng-chat/assets/ng-chat.component.default.css +++ b/src/ng-chat/assets/ng-chat.component.default.css @@ -67,6 +67,11 @@ max-width: 85%; float:left; } + .ng-chat-title > .ng-chat-user-status + { + float: left; + margin-left: 5px; + } #ng-chat-search_friend { display: block; @@ -129,27 +134,30 @@ #ng-chat-users li > .ng-chat-user-status { float: right; - border-radius: 25px; - width: 8px; - height: 8px; - margin-top:10px; - } - #ng-chat-users li > .ng-chat-user-status.online - { - background-color: #92A400; - } - #ng-chat-users li > .ng-chat-user-status.busy - { - background-color: #F91C1E; - } - #ng-chat-users li > .ng-chat-user-status.away - { - background-color: #F7D21B; - } - #ng-chat-users li > .ng-chat-user-status.offline - { - background-color: #BABABA; } +.ng-chat-user-status +{ + border-radius: 25px; + width: 8px; + height: 8px; + margin-top:10px; +} +.ng-chat-user-status.online +{ + background-color: #92A400; +} +.ng-chat-user-status.busy +{ + background-color: #F91C1E; +} +.ng-chat-user-status.away +{ + background-color: #F7D21B; +} +.ng-chat-user-status.offline +{ + background-color: #BABABA; +} .ng-chat-unread-messages-count { background-color: #E3E3E3; @@ -242,4 +250,4 @@ border: 3px solid #E3E3E3; margin-top: 0; margin-bottom: 5px; - } \ No newline at end of file + } diff --git a/src/ng-chat/ng-chat.component.html b/src/ng-chat/ng-chat.component.html index ba20119..8aefcc0 100644 --- a/src/ng-chat/ng-chat.component.html +++ b/src/ng-chat/ng-chat.component.html @@ -23,6 +23,7 @@ {{window.chattingTo.displayName}} + {{unreadMessagesTotal(window)}} X @@ -32,6 +33,7 @@ {{window.chattingTo.displayName}} + {{unreadMessagesTotal(window)}} X diff --git a/src/ng-chat/ng-chat.component.ts b/src/ng-chat/ng-chat.component.ts index c1b9a57..50806d6 100644 --- a/src/ng-chat/ng-chat.component.ts +++ b/src/ng-chat/ng-chat.component.ts @@ -58,12 +58,17 @@ export class NgChat implements OnInit { @Input() // TODO: This might need a better content strategy public audioSource: string = 'https://raw.githubusercontent.com/rpaschoal/ng-chat/master/src/ng-chat/assets/notification.wav'; + @Input() + public persistWindowsState: boolean = true; + private audioFile: HTMLAudioElement; public searchInput: string = ''; private users: User[]; + private localStorageKey: string = "ng-chat-users"; + get filteredUsers(): User[] { if (this.searchInput.length > 0){ @@ -125,13 +130,13 @@ export class NgChat implements OnInit { // Loading current users list if (this.pollFriendsList){ // Setting a long poll interval to update the friends list - this.fetchFriendsList(); - setInterval(() => this.fetchFriendsList(), this.pollingInterval); + this.fetchFriendsList(true); + setInterval(() => this.fetchFriendsList(false), this.pollingInterval); } else { // Since polling was disabled, a friends list update mechanism will have to be implemented in the ChatAdapter. - this.fetchFriendsList(); + this.fetchFriendsList(true); } this.bufferAudioFile(); @@ -152,12 +157,17 @@ export class NgChat implements OnInit { } // Sends a request to load the friends list - private fetchFriendsList(): void + private fetchFriendsList(isBootstrapping: boolean): void { this.adapter.listFriends() .map((users: User[]) => { this.users = users; - }).subscribe(); + }).subscribe(() => { + if (isBootstrapping) + { + this.restoreWindowsState(); + } + }); } // Updates the friends list via the event handler @@ -221,6 +231,8 @@ export class NgChat implements OnInit { this.windows.pop(); } + this.updateWindowsState(this.windows); + return [newChatWindow, true]; } else @@ -270,6 +282,45 @@ export class NgChat implements OnInit { } } + // Saves current windows state into local storage if persistence is enabled + private updateWindowsState(windows: Window[]): void + { + if (this.persistWindowsState) + { + let usersIds = windows.map((w) => { + return w.chattingTo.id; + }); + + localStorage.setItem(this.localStorageKey, JSON.stringify(usersIds)); + } + } + + private restoreWindowsState(): void + { + try + { + if (this.persistWindowsState) + { + let stringfiedUserIds = localStorage.getItem(this.localStorageKey); + + if (stringfiedUserIds && stringfiedUserIds.length > 0) + { + let userIds = JSON.parse(stringfiedUserIds); + + let usersToRestore = this.users.filter(u => userIds.indexOf(u.id) >= 0); + + usersToRestore.forEach((user) => { + this.openChatWindow(user); + }); + } + } + } + catch (ex) + { + console.log(`An error occurred while restoring ng-chat windows state. Details: ${ex}`); + } + } + // Returns the total unread messages from a chat window. TODO: Could use some Angular pipes in the future unreadMessagesTotal(window: Window): string { @@ -321,6 +372,8 @@ export class NgChat implements OnInit { let index = this.windows.indexOf(window); this.windows.splice(index, 1); + + this.updateWindowsState(this.windows); } // Toggle friends list visibility diff --git a/src/package.json b/src/package.json index 1c79934..0be4146 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "ng-chat", - "version": "1.0.4", + "version": "1.0.5", "peerDependencies": { "@angular/common": "*", "@angular/core": "*", diff --git a/src/spec/ng-chat.component.spec.ts b/src/spec/ng-chat.component.spec.ts index ac0ea4e..bb1826c 100644 --- a/src/spec/ng-chat.component.spec.ts +++ b/src/spec/ng-chat.component.spec.ts @@ -59,6 +59,10 @@ describe('NgChat', () => { expect(this.subject.audioSource).not.toBeUndefined(); }); + it('Persistent windows state must be enabled by default', () => { + expect(this.subject.persistWindowsState).toBeTruthy(); + }); + it('Exercise users filter', () => { this.subject.users = [{ id: 1, @@ -114,11 +118,29 @@ describe('NgChat', () => { it('Must invoke adapter on fetchFriendsList', () => { spyOn(MockableAdapter.prototype, 'listFriends').and.returnValue(Observable.of([])); - this.subject.fetchFriendsList(); + this.subject.fetchFriendsList(false); expect(MockableAdapter.prototype.listFriends).toHaveBeenCalledTimes(1); }); + it('Must invoke restore windows state on fetchFriendsList when bootstrapping', () => { + spyOn(MockableAdapter.prototype, 'listFriends').and.returnValue(Observable.of([])); + spyOn(this.subject, 'restoreWindowsState'); + + this.subject.fetchFriendsList(true); + + expect(this.subject.restoreWindowsState).toHaveBeenCalledTimes(1); + }); + + it('Must not invoke restore windows state on fetchFriendsList when not bootstrapping', () => { + spyOn(MockableAdapter.prototype, 'listFriends').and.returnValue(Observable.of([])); + spyOn(this.subject, 'restoreWindowsState'); + + this.subject.fetchFriendsList(false); + + expect(this.subject.restoreWindowsState).not.toHaveBeenCalled(); + }); + it('Must update users property when onFriendsListChanged is invoked', () => { expect(this.subject.users).toBeUndefined(); @@ -262,4 +284,88 @@ describe('NgChat', () => { expect(this.subject.emitMessageSound).toHaveBeenCalledTimes(1); }); + + it('Should not use local storage persistency if persistWindowsState is disabled', () => { + let windows = [new Window()]; + + this.subject.persistWindowsState = false; + + spyOn(localStorage, 'setItem'); + + this.subject.updateWindowsState(windows); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('Update windows state exercise', () => { + let persistedValue = null; + + spyOn(localStorage, 'setItem').and.callFake((key, value) =>{ + persistedValue = value; + }); + + let firstUser = new User(); + let secondUser = new User(); + + firstUser.id = 88; + secondUser.id = 99; + + let firstWindow = new Window(); + let secondWindow = new Window(); + + firstWindow.chattingTo = firstUser; + secondWindow.chattingTo = secondUser; + + let windows = [firstWindow, secondWindow]; + + this.subject.updateWindowsState(windows); + + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + expect(persistedValue).toBe(JSON.stringify([88, 99])); + }); + + it('Should not restore windows state from local storage if persistWindowsState is disabled', () => { + this.subject.persistWindowsState = false; + + spyOn(this.subject, 'openChatWindow'); + + this.subject.restoreWindowsState(); + + expect(this.subject.openChatWindow).not.toHaveBeenCalled(); + }); + + it('Restore windows state exercise', () => { + let firstUser = new User(); + let secondUser = new User(); + + firstUser.id = 88; + secondUser.id = 99; + + localStorage.setItem(this.subject.localStorageKey, JSON.stringify([firstUser.id, secondUser.id])); + + this.subject.users = [firstUser, secondUser]; + let pushedUsers = []; + + spyOn(this.subject, 'openChatWindow').and.callFake((user) => { + pushedUsers.push(user); + }); + + this.subject.restoreWindowsState(); + + expect(this.subject.openChatWindow).toHaveBeenCalledTimes(2); + expect(pushedUsers).not.toBeNull(); + expect(pushedUsers.length).toBe(2); + expect(pushedUsers[0]).toBe(firstUser); + expect(pushedUsers[1]).toBe(secondUser); + }); + + it('Must invoke window state update when closing a chat window', () => { + this.subject.windows = [new Window()]; + + spyOn(this.subject, 'updateWindowsState'); + + let result = this.subject.onCloseChatWindow(this.subject.windows[0]); + + expect(this.subject.updateWindowsState).toHaveBeenCalledTimes(1); + }); });