Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/fixes/notification-experience-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Notification and real-time refresh after switching experiences

## Problem

`NotificationsService` is provided at the application root and replays its latest notification list. Ionic can retain the v3 page while the user visits the experience list, so returning to the dashboard does not necessarily recreate `V3Page` or rerun its initialization. Without an explicit refresh, the notification badge and list can therefore remain scoped to the previously selected project.

## Required behavior

- Notification state is scoped to the current user's `projectId`.
- Changing projects immediately clears the cached todo list and replayed event reminder.
- Todo items load before chat notifications because chat is appended to the freshly loaded todo list.
- Experience selection waits for this refresh before leaving the loading state.
- A notification refresh failure must not undo a successful experience switch. The new experience opens with an empty notification state rather than exposing notifications from the previous project.
- Responses from requests started under a previous project are ignored if they arrive after the active project changes.
- Pusher listeners are also experience-scoped. The application reuses one Pusher client, but disconnects its socket briefly on a scope change so pending private channels can be removed safely before reconnecting with the new channel set.
- The `login-refactor` baseline uses Pusher 8 with its bundled typings. Its pending-subscription behavior still requires the disconnect-before-removal sequence described below.

## Refresh entry points

- `V3Page` refreshes notifications when it is first initialized.
- `ExperiencesPage` refreshes notifications after authentication has switched to the selected experience and before navigating to the destination route.
- `ExperienceService` reinitializes web services after the selected experience's authentication response is available. `PusherService` detects scope changes using the program, project, and timeline identifiers, removes the previous listeners, refreshes authorization, and reconciles notification and chat subscriptions with the latest channel responses.
- `V3Page` also initializes web services as a fallback for authenticated login paths. App startup covers restored sessions, and direct login waits for initialization before navigating to deep links outside v3.
- Pusher initialization is single-flight. Concurrent entry points share one operation, and a scope that changes during that operation is reconciled before callers are released.
- Notification and chat discovery use independent generations. Only the latest response for the active scope may change listeners, preventing both previous-experience and same-experience request races.
- A valid empty channel response removes that listener type. Pusher leaves authorization failures in a pending state, so reconciliation disconnects before removing a pending channel; this ensures the channel is removed from Pusher's internal registry and cannot return on a later reconnect. A discovery failure preserves the last valid same-scope set, while a scope change remains empty because its previous listeners were removed before discovery.
- Pusher authorization headers are synchronized from user storage before channel subscription and connection retries. API-key rotation therefore does not require constructing another Pusher client.
- Private-channel subscription errors trigger one bounded background retry. Same-scope discovery does not cancel an outstanding retry, and simultaneous notification/chat failures are batched into one socket reconnect. Repeated failure is logged and never blocks navigation.
- Event callbacks capture their subscription scope and discard events after that scope becomes inactive.

Both notification-refresh entry points use `NotificationsService.refreshNotifications()` so the reset and request ordering remain consistent.

## Listener ownership and cleanup

- `PusherService` is the only owner of Pusher channel subscriptions. Chat pages request `refreshChatChannels()` rather than subscribing to channel names directly.
- `TabsPage` remains the adapter from real-time notification, chat, and reminder events into `NotificationsService` state.
- V3, tabs, chat-list, and chat-room consumers release their event-stream subscriptions when destroyed. Chat-room typing listeners are replaced when the active room changes.
- Logout invalidates in-flight initialization and discovery work, resets generations and retry timers, disconnects the socket, removes all local channels, clears the active scope and authorization headers, and retains only the reusable application Pusher object.

## Deferred improvements

- Replace the string-keyed application event bus with typed real-time events.
- Add structured production telemetry for connection state, authorization errors, retries, and notification refresh failures.
- Revisit persistent capped retry backoff only if production telemetry shows the bounded retry is insufficient.
8 changes: 4 additions & 4 deletions projects/v3/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ describe('AppComponent', () => {
},
{
provide: SharedService,
useValue: jasmine.createSpyObj('SharedService', [
'onPageLoad',
'initWebServices',
]),
useValue: jasmine.createSpyObj('SharedService', {
onPageLoad: undefined,
initWebServices: Promise.resolve(),
}),
},
{
provide: BrowserStorageService,
Expand Down
4 changes: 3 additions & 1 deletion projects/v3/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ export class AppComponent implements OnInit, OnDestroy {
this.versionCheckService.initiateVersionCheck();
}
// initialise Pusher when app loading
this.sharedService.initWebServices();
this.sharedService.initWebServices().catch(err => {
console.error('Failed to initialise real-time services on app startup', err);
});
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ describe('AuthDirectLoginComponent', () => {
},
{
provide: SharedService,
useValue: jasmine.createSpyObj('SharedService', ['onPageLoad', 'initWebServices']),
useValue: jasmine.createSpyObj('SharedService', {
onPageLoad: undefined,
initWebServices: Promise.resolve(),
}),
},
{
provide: AuthService,
Expand Down Expand Up @@ -476,4 +479,3 @@ describe('AuthDirectLoginComponent', () => {
});
});
});

Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,10 @@ export class AuthDirectLoginComponent implements OnInit {
}
}

private _saveOrRedirect(route: Array<String | number | object>, options?: {
private async _saveOrRedirect(route: Array<String | number | object>, options?: {
save?: boolean;
experience?: any;
}): void | Promise<boolean> {
}): Promise<boolean | void> {
const currentLocation = window.location.href;
const locale = options?.experience?.locale;
if (currentLocation.indexOf('localhost') === -1 && locale && currentLocation.indexOf(locale) === -1) {
Expand All @@ -237,7 +237,11 @@ export class AuthDirectLoginComponent implements OnInit {
* When user use deep link to login to app. user not going through switcher service.
* So pusher initialise not calling after user login using using deep link.
*/
this.sharedService.initWebServices();
try {
await this.sharedService.initWebServices();
} catch (err) {
console.error('Failed to initialise real-time services for direct login', err);
}
return this.navigate(route);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CUSTOM_ELEMENTS_SCHEMA, EventEmitter } from '@angular/core';
import { waitForAsync, ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ChatListComponent } from './chat-list.component';
import { ChatChannel, ChatService } from '@v3/services/chat.service';
Expand All @@ -14,19 +14,6 @@ import { FastFeedbackService } from '@v3/services/fast-feedback.service';
import { TestUtils } from '@testingv3/utils';
import { mockChats } from '@testingv3/fixtures';

const mockPusherChannels = {
data: {
channels: [
{
pusherChannel: 'sdb746-93r7dc-5f44eb4f'
},
{
pusherChannel: 'kb5gt-9nfbj-5f45eb4g'
}
]
}
};

describe('ChatListComponent', () => {
let component: ChatListComponent;
let fixture: ComponentFixture<ChatListComponent>;
Expand All @@ -35,8 +22,6 @@ describe('ChatListComponent', () => {
let storageSpy: jasmine.SpyObj<BrowserStorageService>;
let pusherSpy: jasmine.SpyObj<PusherService>;
let routerSpy: jasmine.SpyObj<Router>;
let routeStub: Partial<ActivatedRoute>;
let fastFeedbackSpy: jasmine.SpyObj<FastFeedbackService>;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
Expand All @@ -52,7 +37,6 @@ describe('ChatListComponent', () => {
provide: ChatService,
useValue: jasmine.createSpyObj('ChatService', {
'getChatList': of(mockChats.data.channels),
'getPusherChannels': of(true),
})
},
{
Expand All @@ -61,7 +45,9 @@ describe('ChatListComponent', () => {
},
{
provide: PusherService,
useValue: jasmine.createSpyObj('PusherService', ['subscribeChannel'])
useValue: jasmine.createSpyObj('PusherService', {
refreshChatChannels: Promise.resolve(),
})
},
{
provide: Router,
Expand Down Expand Up @@ -91,13 +77,11 @@ describe('ChatListComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ChatListComponent);
component = fixture.componentInstance;
routeStub = TestBed.inject(ActivatedRoute);
routerSpy = TestBed.inject(Router) as jasmine.SpyObj<Router>;
chatSeviceSpy = TestBed.inject(ChatService) as jasmine.SpyObj<ChatService>;
utils = TestBed.inject(UtilsService) as jasmine.SpyObj<UtilsService>;
storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj<BrowserStorageService>;
pusherSpy = TestBed.inject(PusherService) as jasmine.SpyObj<PusherService>;
fastFeedbackSpy = TestBed.inject(FastFeedbackService) as jasmine.SpyObj<FastFeedbackService>;
});

it('should create', () => {
Expand All @@ -114,18 +98,23 @@ describe('ChatListComponent', () => {
utils.broadcastEvent('chat:info-update', {});
expect(chatSeviceSpy.getChatList.calls.count()).toBe(1);
});

it('should stop handling chat events after destruction', () => {
component.ngOnDestroy();

utils.broadcastEvent('chat:new-message', {});

expect(chatSeviceSpy.getChatList).not.toHaveBeenCalled();
});
});

describe('when testing onEnter()', () => {
it('should get correct chat list and pusher channels', () => {
it('should get the chat list and request exact Pusher reconciliation', () => {
chatSeviceSpy.getChatList.and.returnValue(of(mockChats.data.channels));
chatSeviceSpy.getPusherChannels.and.returnValue(of(mockPusherChannels.data.channels));
component.onEnter();
expect(component.chatList).toBeDefined();
expect(chatSeviceSpy.getChatList.calls.count()).toBe(1);
expect(chatSeviceSpy.getPusherChannels.calls.count()).toBe(1);
expect(pusherSpy.subscribeChannel).toHaveBeenCalledWith('chat', 'sdb746-93r7dc-5f44eb4f');
expect(pusherSpy.subscribeChannel).toHaveBeenCalledWith('chat', 'kb5gt-9nfbj-5f45eb4g');
expect(pusherSpy.refreshChatChannels).toHaveBeenCalled();
});
});

Expand Down
67 changes: 37 additions & 30 deletions projects/v3/src/app/pages/chat/chat-list/chat-list.component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ChangeDetectorRef, Component, Output, EventEmitter, NgZone, Input } from '@angular/core';
import { ChangeDetectorRef, Component, Output, EventEmitter, NgZone, Input, OnDestroy } from '@angular/core';
import { Router, NavigationExtras } from '@angular/router';
import { BrowserStorageService } from '@v3/services/storage.service';
import { UtilsService } from '@v3/services/utils.service';
import { ChatService, ChatChannel } from '@v3/services/chat.service';
import { PusherService } from '@v3/services/pusher.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

/**
* this is an app chat list component
Expand All @@ -14,13 +16,14 @@ import { PusherService } from '@v3/services/pusher.service';
templateUrl: 'chat-list.component.html',
styleUrls: ['chat-list.component.scss']
})
export class ChatListComponent {
export class ChatListComponent implements OnDestroy {
@Output() navigate = new EventEmitter();
@Output() chatListReady = new EventEmitter();
@Input() currentChat: ChatChannel;
chatList: ChatChannel[];
loadingChatList = true;
isMobile: boolean = false;
private readonly destroy$ = new Subject<void>();

constructor(
public utils: UtilsService,
Expand All @@ -32,12 +35,22 @@ export class ChatListComponent {
private pusherService: PusherService
) {
this.isMobile = this.utils.isMobile();
this.utils.getEvent('chat:new-message').subscribe(event => this._loadChatData());
this.utils.getEvent('chat:delete-message').subscribe(event => this._loadChatData());
this.utils.getEvent('chat:edit-message').subscribe(event => this._loadChatData());
this.utils.getEvent('chat:info-update').subscribe(event => this._loadChatData());
this.utils.getEvent('chat:new-message')
.pipe(takeUntil(this.destroy$))
.subscribe(() => this._loadChatData());
this.utils.getEvent('chat:delete-message')
.pipe(takeUntil(this.destroy$))
.subscribe(() => this._loadChatData());
this.utils.getEvent('chat:edit-message')
.pipe(takeUntil(this.destroy$))
.subscribe(() => this._loadChatData());
this.utils.getEvent('chat:info-update')
.pipe(takeUntil(this.destroy$))
.subscribe(() => this._loadChatData());
if (!this.isMobile) {
this.utils.getEvent('chat-badge-update').subscribe(event => {
this.utils.getEvent('chat-badge-update')
.pipe(takeUntil(this.destroy$))
.subscribe(event => {
const chatIndex = this.chatList.findIndex(data => data.uuid === event.channelUuid);
if (chatIndex > -1) {
setTimeout(() => {
Expand All @@ -60,10 +73,17 @@ export class ChatListComponent {
*/
onEnter() {
this._initialise();
this._checkAndSubscribePusherChannels();
this.pusherService.refreshChatChannels().catch(err => {
console.error('Failed to refresh chat Pusher channels', err);
});
this._loadChatData();
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

/**
* This is an _initialise method
* @returns nothing
Expand All @@ -78,29 +98,16 @@ export class ChatListComponent {
* @returns nothing
*/
private _loadChatData(): void {
this.chatService.getChatList().subscribe(chats => {
this.ngZone.run(() => {
this.chatList = chats;
this.loadingChatList = false;
this.cdr.markForCheck();
this.chatService.getChatList()
.pipe(takeUntil(this.destroy$))
.subscribe(chats => {
this.ngZone.run(() => {
this.chatList = chats;
this.loadingChatList = false;
this.cdr.markForCheck();
});
this.chatListReady.emit(this.chatList);
});
this.chatListReady.emit(this.chatList);
});
}

/**
* This method pusher service to subscribe to chat pusher channels
* - first it call chat service to get pusher channels.
* - then it call pusher service 'subscribeChannel' method to subscribe.
* - in pusher service it chaeck if we alrady subscribe or not.
* if not it will subscribe to the pusher channel.
*/
private _checkAndSubscribePusherChannels() {
this.chatService.getPusherChannels().subscribe(pusherChannels => {
pusherChannels.forEach(channel => {
this.pusherService.subscribeChannel('chat', channel.pusherChannel);
});
});
}

goToChatRoom(chat: ChatChannel, keyboardEvent?: KeyboardEvent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ChatService, ChatChannel, Message, MessageListResult, ChannelMembers, F
import { ChatPreviewComponent } from '../chat-preview/chat-preview.component';
import { ChatInfoComponent } from '../chat-info/chat-info.component';
import { EditMessagePopupComponent } from '../edit-message-popup/edit-message-popup.component';
import { Subject, timer } from 'rxjs';
import { Subject, Subscription, timer } from 'rxjs';
import { debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators';
import { QuillModules } from 'ngx-quill';
import { UppyFileData, UppyUploaderResponse, UppyUploaderService } from '../../../components/uppy-uploader/uppy-uploader.service';
Expand Down Expand Up @@ -147,6 +147,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit {

private destroy$ = new Subject<void>();
private scrollSubject = new Subject<void>();
private typingSubscription: Subscription;

constructor(
private chatService: ChatService,
Expand Down Expand Up @@ -284,6 +285,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit {
}

ngOnDestroy() {
this.typingSubscription?.unsubscribe();
this.destroy$.next();
this.destroy$.complete();
}
Expand Down Expand Up @@ -315,8 +317,8 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit {
this.chatChannel = this.storage.getCurrentChatChannel();
}
this.channelUuid = this.chatChannel.uuid;
// subscribe to typing event
this.utils
this.typingSubscription?.unsubscribe();
this.typingSubscription = this.utils
.getEvent("typing-" + this.chatChannel.pusherChannel)
.pipe(takeUntil(this.destroy$))
.subscribe((event) => this._showTyping(event));
Expand Down
Loading
Loading