diff --git a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts index 8f235b7cc..6867e0633 100644 --- a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts +++ b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts @@ -38,7 +38,7 @@ describe('SubscriptionServer', () => { }) it('should accept an invite to a shared subscription', async () => { - httpService.get = jest.fn().mockReturnValue({ + httpService.post = jest.fn().mockReturnValue({ data: { success: true }, } as jest.Mocked) diff --git a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts index 23552df0f..23abcf5d7 100644 --- a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts +++ b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts @@ -17,7 +17,7 @@ export class SubscriptionServer implements SubscriptionServerInterface { constructor(private httpService: HttpServiceInterface) {} async acceptInvite(params: SubscriptionInviteAcceptRequestParams): Promise { - const response = await this.httpService.get(Paths.v1.acceptInvite(params.inviteUuid), params) + const response = await this.httpService.post(Paths.v1.acceptInvite(params.inviteUuid), params) return response as SubscriptionInviteAcceptResponse } diff --git a/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts b/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts index c01ab70cd..ce9df96d8 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts @@ -5,4 +5,5 @@ export interface SubscriptionClientInterface { listSubscriptionInvitations(): Promise inviteToSubscription(inviteeEmail: string): Promise cancelInvitation(inviteUuid: Uuid): Promise + acceptInvitation(inviteUuid: Uuid): Promise<{ success: true } | { success: false; message: string }> } diff --git a/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts b/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts index 302a1c6b2..568e0cdd7 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts @@ -12,6 +12,7 @@ describe('SubscriptionManager', () => { beforeEach(() => { subscriptionApiService = {} as jest.Mocked subscriptionApiService.cancelInvite = jest.fn() + subscriptionApiService.acceptInvite = jest.fn() subscriptionApiService.invite = jest.fn() subscriptionApiService.listInvites = jest.fn() @@ -80,4 +81,27 @@ describe('SubscriptionManager', () => { expect(await createManager().listSubscriptionInvitations()).toEqual([]) }) + + it('should accept invite to a shared subscription', async () => { + subscriptionApiService.acceptInvite = jest.fn().mockReturnValue({ data: { success: true } }) + + expect(await createManager().acceptInvitation('1-2-3')).toEqual({ success: true }) + }) + + it('should not accept invite if the api fails to do so', async () => { + subscriptionApiService.acceptInvite = jest.fn().mockReturnValue({ data: { error: { message: 'foobar' } } }) + + expect(await createManager().acceptInvitation('1-2-3')).toEqual({ success: false, message: 'foobar' }) + }) + + it('should not accept invite if the api throws an error', async () => { + subscriptionApiService.acceptInvite = jest.fn().mockImplementation(() => { + throw new Error('Oops') + }) + + expect(await createManager().acceptInvitation('1-2-3')).toEqual({ + success: false, + message: 'Could not accept invitation.', + }) + }) }) diff --git a/packages/services/src/Domain/Subscription/SubscriptionManager.ts b/packages/services/src/Domain/Subscription/SubscriptionManager.ts index e3742d55d..a98bc082b 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionManager.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionManager.ts @@ -13,6 +13,20 @@ export class SubscriptionManager extends AbstractService implements Subscription super(internalEventBus) } + async acceptInvitation(inviteUuid: string): Promise<{ success: true } | { success: false; message: string }> { + try { + const result = await this.subscriptionApiService.acceptInvite(inviteUuid) + + if (result.data.error) { + return { success: false, message: result.data.error.message } + } + + return result.data + } catch (error) { + return { success: false, message: 'Could not accept invitation.' } + } + } + async listSubscriptionInvitations(): Promise { try { const response = await this.subscriptionApiService.listInvites() diff --git a/packages/ui-services/src/Route/RouteService.ts b/packages/ui-services/src/Route/RouteService.ts index 3c4492044..652b7c8c3 100644 --- a/packages/ui-services/src/Route/RouteService.ts +++ b/packages/ui-services/src/Route/RouteService.ts @@ -5,6 +5,7 @@ import { InternalEventBusInterface, } from '@standardnotes/services' +import { RootQueryParam } from './RootQueryParam' import { RouteParser } from './RouteParser' import { RouteParserInterface } from './RouteParserInterface' import { RouteServiceEvent } from './RouteServiceEvent' @@ -30,13 +31,13 @@ export class RouteService this.unsubApp() } - public getRoute(): RouteParserInterface { + getRoute(): RouteParserInterface { return new RouteParser(window.location.href) } - public removeSettingsFromURLQueryParameters() { + removeQueryParameterFromURL(param: RootQueryParam): void { const urlSearchParams = new URLSearchParams(window.location.search) - urlSearchParams.delete('settings') + urlSearchParams.delete(param) const newUrl = `${window.location.origin}${window.location.pathname}${urlSearchParams.toString()}` window.history.replaceState(null, document.title, newUrl) diff --git a/packages/ui-services/src/Route/RouteServiceInterface.ts b/packages/ui-services/src/Route/RouteServiceInterface.ts index 7a66ff1db..3b6ade3ff 100644 --- a/packages/ui-services/src/Route/RouteServiceInterface.ts +++ b/packages/ui-services/src/Route/RouteServiceInterface.ts @@ -1,7 +1,8 @@ +import { RootQueryParam } from './RootQueryParam' import { RouteParserInterface } from './RouteParserInterface' export interface RouteServiceInterface { deinit(): void getRoute(): RouteParserInterface - removeSettingsFromURLQueryParameters(): void + removeQueryParameterFromURL(param: RootQueryParam): void } diff --git a/packages/ui-services/src/Toast/ToastService.ts b/packages/ui-services/src/Toast/ToastService.ts new file mode 100644 index 000000000..20f1e4d87 --- /dev/null +++ b/packages/ui-services/src/Toast/ToastService.ts @@ -0,0 +1,12 @@ +import { addToast, ToastType } from '@standardnotes/toast' + +import { ToastServiceInterface } from './ToastServiceInterface' + +export class ToastService implements ToastServiceInterface { + showToast(type: ToastType, message: string): void { + addToast({ + type: type, + message, + }) + } +} diff --git a/packages/ui-services/src/Toast/ToastServiceInterface.ts b/packages/ui-services/src/Toast/ToastServiceInterface.ts new file mode 100644 index 000000000..dbdf56464 --- /dev/null +++ b/packages/ui-services/src/Toast/ToastServiceInterface.ts @@ -0,0 +1,5 @@ +import { ToastType } from '@standardnotes/toast' + +export interface ToastServiceInterface { + showToast(type: ToastType, message: string): void +} diff --git a/packages/ui-services/src/index.ts b/packages/ui-services/src/index.ts index 5202f2d9f..034127bab 100644 --- a/packages/ui-services/src/index.ts +++ b/packages/ui-services/src/index.ts @@ -8,7 +8,9 @@ export * from './Route/Params/OnboardingParams' export * from './Route/Params/PurchaseParams' export * from './Route/Params/SettingsParams' export * from './Route/Params/SubscriptionInviteParams' +export * from './Route/RootQueryParam' export * from './Route/RouteParser' +export * from './Route/RouteParserInterface' export * from './Route/RouteType' export * from './Route/RouteService' export * from './Route/RouteServiceInterface' @@ -16,3 +18,5 @@ export * from './Route/RouteServiceEvent' export * from './Security/AutolockService' export * from './Storage/LocalStorage' export * from './Theme/ThemeManager' +export * from './Toast/ToastService' +export * from './Toast/ToastServiceInterface' diff --git a/packages/web/src/javascripts/Controllers/PreferencesController.ts b/packages/web/src/javascripts/Controllers/PreferencesController.ts index 187c779e6..4e0c823ac 100644 --- a/packages/web/src/javascripts/Controllers/PreferencesController.ts +++ b/packages/web/src/javascripts/Controllers/PreferencesController.ts @@ -1,6 +1,6 @@ import { InternalEventBus } from '@standardnotes/snjs' import { action, computed, makeObservable, observable } from 'mobx' -import { PreferenceId } from '@standardnotes/ui-services' +import { PreferenceId, RootQueryParam } from '@standardnotes/ui-services' import { AbstractViewController } from './Abstract/AbstractViewController' import { WebApplication } from '@/Application/Application' @@ -34,7 +34,7 @@ export class PreferencesController extends AbstractViewController { closePreferences = (): void => { this._open = false this.currentPane = DEFAULT_PANE - this.application.routeService.removeSettingsFromURLQueryParameters() + this.application.routeService.removeQueryParameterFromURL(RootQueryParam.Settings) } get isOpen(): boolean { diff --git a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts index 214bc8816..b7381bad0 100644 --- a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts +++ b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts @@ -1,5 +1,5 @@ import { PaneController } from './PaneController' -import { storage, StorageKey } from '@standardnotes/ui-services' +import { storage, StorageKey, ToastService, ToastServiceInterface } from '@standardnotes/ui-services' import { WebApplication } from '@/Application/Application' import { AccountMenuController } from '@/Controllers/AccountMenu/AccountMenuController' import { destroyAllObjectProperties } from '@/Utils' @@ -71,6 +71,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface { private subscriptionManager: SubscriptionClientInterface private persistenceService: PersistenceService private applicationEventObserver: EventObserverInterface + private toastService: ToastServiceInterface constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) { this.eventBus = new InternalEventBus() @@ -146,6 +147,8 @@ export class ViewControllerManager implements InternalEventHandlerInterface { this.historyModalController = new HistoryModalController(this.application, this.eventBus) + this.toastService = new ToastService() + this.applicationEventObserver = new ApplicationEventObserver( application.routeService, this.purchaseFlowController, @@ -154,6 +157,8 @@ export class ViewControllerManager implements InternalEventHandlerInterface { this.syncStatusController, application.sync, application.sessions, + application.subscriptions, + this.toastService, ) this.addAppEventObserver() diff --git a/packages/web/src/javascripts/Event/ApplicationEventObserver.spec.ts b/packages/web/src/javascripts/Event/ApplicationEventObserver.spec.ts index cd0c3e3ca..a61d31e09 100644 --- a/packages/web/src/javascripts/Event/ApplicationEventObserver.spec.ts +++ b/packages/web/src/javascripts/Event/ApplicationEventObserver.spec.ts @@ -2,17 +2,30 @@ * @jest-environment jsdom */ -import { RouteServiceInterface, RouteType } from '@standardnotes/ui-services' -import { ApplicationEvent, SessionsClientInterface, SyncClientInterface, SyncOpStatus, User } from '@standardnotes/snjs' +import { + RootQueryParam, + RouteServiceInterface, + RouteParserInterface, + RouteType, + ToastServiceInterface, +} from '@standardnotes/ui-services' +import { ToastType } from '@standardnotes/toast' +import { + ApplicationEvent, + SessionsClientInterface, + SubscriptionClientInterface, + SyncClientInterface, + SyncOpStatus, + User, +} from '@standardnotes/snjs' import { AccountMenuController } from '@/Controllers/AccountMenu/AccountMenuController' import { PreferencesController } from '@/Controllers/PreferencesController' import { PurchaseFlowController } from '@/Controllers/PurchaseFlow/PurchaseFlowController' import { SyncStatusController } from '@/Controllers/SyncStatusController' +import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane' import { ApplicationEventObserver } from './ApplicationEventObserver' -import { RouteParserInterface } from '@standardnotes/ui-services/dist/Route/RouteParserInterface' -import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane' describe('ApplicationEventObserver', () => { let routeService: RouteServiceInterface @@ -22,6 +35,8 @@ describe('ApplicationEventObserver', () => { let syncStatusController: SyncStatusController let syncClient: SyncClientInterface let sessionManager: SessionsClientInterface + let subscriptionManager: SubscriptionClientInterface + let toastService: ToastServiceInterface const createObserver = () => new ApplicationEventObserver( @@ -32,6 +47,8 @@ describe('ApplicationEventObserver', () => { syncStatusController, syncClient, sessionManager, + subscriptionManager, + toastService, ) beforeEach(() => { @@ -39,6 +56,7 @@ describe('ApplicationEventObserver', () => { routeService.getRoute = jest.fn().mockReturnValue({ type: RouteType.None, } as jest.Mocked) + routeService.removeQueryParameterFromURL = jest.fn() purchaseFlowController = {} as jest.Mocked purchaseFlowController.openPurchaseFlow = jest.fn() @@ -59,6 +77,12 @@ describe('ApplicationEventObserver', () => { sessionManager = {} as jest.Mocked sessionManager.getUser = jest.fn().mockReturnValue({} as jest.Mocked) + + subscriptionManager = {} as jest.Mocked + subscriptionManager.acceptInvitation = jest.fn() + + toastService = {} as jest.Mocked + toastService.showToast = jest.fn() }) describe('Upon Application Launched', () => { @@ -102,6 +126,59 @@ describe('ApplicationEventObserver', () => { expect(preferencesController.openPreferences).not.toHaveBeenCalled() expect(preferencesController.setCurrentPane).not.toHaveBeenCalled() }) + + it('should open up sign in if user is not logged in and to accept subscription invitation', async () => { + sessionManager.getUser = jest.fn().mockReturnValue(undefined) + routeService.getRoute = jest.fn().mockReturnValue({ + type: RouteType.AcceptSubscriptionInvite, + subscriptionInviteParams: { + inviteUuid: '1-2-3', + }, + } as jest.Mocked) + + await createObserver().handle(ApplicationEvent.Launched) + + expect(accountMenuController.setShow).toHaveBeenCalledWith(true) + expect(accountMenuController.setCurrentPane).toHaveBeenCalledWith(AccountMenuPane.SignIn) + expect(subscriptionManager.acceptInvitation).not.toHaveBeenCalled() + expect(toastService.showToast).not.toHaveBeenCalled() + }) + + it('should accept subscription invitation if user is logged in', async () => { + subscriptionManager.acceptInvitation = jest.fn().mockReturnValue({ success: true }) + routeService.getRoute = jest.fn().mockReturnValue({ + type: RouteType.AcceptSubscriptionInvite, + subscriptionInviteParams: { + inviteUuid: '1-2-3', + }, + } as jest.Mocked) + + await createObserver().handle(ApplicationEvent.Launched) + + expect(subscriptionManager.acceptInvitation).toHaveBeenCalledWith('1-2-3') + expect(toastService.showToast).toHaveBeenCalledWith( + ToastType.Success, + 'Successfully joined a shared subscription', + ) + expect(routeService.removeQueryParameterFromURL).toHaveBeenCalledWith(RootQueryParam.AcceptSubscriptionInvite) + }) + + it('should show accept subscription invitation failure if user is logged in and accepting fails', async () => { + subscriptionManager.acceptInvitation = jest.fn().mockReturnValue({ success: false, message: 'Oops!' }) + + routeService.getRoute = jest.fn().mockReturnValue({ + type: RouteType.AcceptSubscriptionInvite, + subscriptionInviteParams: { + inviteUuid: '1-2-3', + }, + } as jest.Mocked) + + await createObserver().handle(ApplicationEvent.Launched) + + expect(subscriptionManager.acceptInvitation).toHaveBeenCalledWith('1-2-3') + expect(toastService.showToast).toHaveBeenCalledWith(ToastType.Error, 'Oops!') + expect(routeService.removeQueryParameterFromURL).toHaveBeenCalledWith(RootQueryParam.AcceptSubscriptionInvite) + }) }) describe('Upon Signing In', () => { @@ -118,6 +195,25 @@ describe('ApplicationEventObserver', () => { expect(preferencesController.openPreferences).toHaveBeenCalled() expect(preferencesController.setCurrentPane).toHaveBeenCalledWith('general') }) + + it('should accept subscription invitation', async () => { + subscriptionManager.acceptInvitation = jest.fn().mockReturnValue({ success: true }) + routeService.getRoute = jest.fn().mockReturnValue({ + type: RouteType.AcceptSubscriptionInvite, + subscriptionInviteParams: { + inviteUuid: '1-2-3', + }, + } as jest.Mocked) + + await createObserver().handle(ApplicationEvent.SignedIn) + + expect(subscriptionManager.acceptInvitation).toHaveBeenCalledWith('1-2-3') + expect(toastService.showToast).toHaveBeenCalledWith( + ToastType.Success, + 'Successfully joined a shared subscription', + ) + expect(routeService.removeQueryParameterFromURL).toHaveBeenCalledWith(RootQueryParam.AcceptSubscriptionInvite) + }) }) describe('Upon Sync Status Changing', () => { diff --git a/packages/web/src/javascripts/Event/ApplicationEventObserver.ts b/packages/web/src/javascripts/Event/ApplicationEventObserver.ts index dbe7b42be..831604547 100644 --- a/packages/web/src/javascripts/Event/ApplicationEventObserver.ts +++ b/packages/web/src/javascripts/Event/ApplicationEventObserver.ts @@ -1,5 +1,11 @@ -import { RouteServiceInterface, RouteType } from '@standardnotes/ui-services' -import { ApplicationEvent, SessionsClientInterface, SyncClientInterface } from '@standardnotes/snjs' +import { RootQueryParam, RouteParserInterface, RouteServiceInterface, RouteType, ToastServiceInterface } from '@standardnotes/ui-services' +import { + ApplicationEvent, + SessionsClientInterface, + SubscriptionClientInterface, + SyncClientInterface, +} from '@standardnotes/snjs' +import { ToastType } from '@standardnotes/toast' import { PurchaseFlowController } from '@/Controllers/PurchaseFlow/PurchaseFlowController' import { AccountMenuController } from '@/Controllers/AccountMenu/AccountMenuController' @@ -18,6 +24,8 @@ export class ApplicationEventObserver implements EventObserverInterface { private syncStatusController: SyncStatusController, private syncClient: SyncClientInterface, private sessionManager: SessionsClientInterface, + private subscriptionManager: SubscriptionClientInterface, + private toastService: ToastServiceInterface, ) {} async handle(event: ApplicationEvent): Promise { @@ -33,8 +41,7 @@ export class ApplicationEventObserver implements EventObserverInterface { case RouteType.Settings: { const user = this.sessionManager.getUser() if (user === undefined) { - this.accountMenuController.setShow(true) - this.accountMenuController.setCurrentPane(AccountMenuPane.SignIn) + this.promptUserSignIn() break } @@ -43,6 +50,17 @@ export class ApplicationEventObserver implements EventObserverInterface { this.preferencesController.setCurrentPane(route.settingsParams.panel) break } + case RouteType.AcceptSubscriptionInvite: { + const user = this.sessionManager.getUser() + if (user === undefined) { + this.promptUserSignIn() + + break + } + await this.acceptSubscriptionInvitation(route) + + break + } } } break @@ -54,6 +72,10 @@ export class ApplicationEventObserver implements EventObserverInterface { this.preferencesController.openPreferences() this.preferencesController.setCurrentPane(route.settingsParams.panel) + break + case RouteType.AcceptSubscriptionInvite: + await this.acceptSubscriptionInvitation(route) + break } } @@ -63,4 +85,20 @@ export class ApplicationEventObserver implements EventObserverInterface { break } } + + private promptUserSignIn(): void { + this.accountMenuController.setShow(true) + this.accountMenuController.setCurrentPane(AccountMenuPane.SignIn) + } + + private async acceptSubscriptionInvitation(route: RouteParserInterface): Promise { + const acceptResult = await this.subscriptionManager.acceptInvitation(route.subscriptionInviteParams.inviteUuid) + + const toastType = acceptResult.success ? ToastType.Success : ToastType.Error + const toastMessage = acceptResult.success ? 'Successfully joined a shared subscription' : acceptResult.message + + this.toastService.showToast(toastType, toastMessage) + + this.routeService.removeQueryParameterFromURL(RootQueryParam.AcceptSubscriptionInvite) + } }