Skip to content

Commit

Permalink
feat(inApp): functional dispatchEvent & setConflictHandler APIs (#12231)
Browse files Browse the repository at this point in the history
* chore: truncate comments and typo

---------

Co-authored-by: Aaron S <[email protected]>
Co-authored-by: Jim Blanchard <[email protected]>
  • Loading branch information
3 people authored Oct 10, 2023
1 parent db28490 commit f5bcc3a
Show file tree
Hide file tree
Showing 19 changed files with 874 additions and 11 deletions.
4 changes: 4 additions & 0 deletions packages/aws-amplify/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ describe('aws-amplify Exports', () => {
Array [
"identifyUser",
"syncMessages",
"dispatchEvent",
"setConflictHandler",
]
`);
});
Expand All @@ -112,6 +114,8 @@ describe('aws-amplify Exports', () => {
Array [
"identifyUser",
"syncMessages",
"dispatchEvent",
"setConflictHandler",
]
`);
});
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/Platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export enum GeoAction {
export enum InAppMessagingAction {
SyncMessages = '1',
IdentifyUser = '2',
DispatchEvent = '3',
}
export enum InteractionsAction {
None = '0',
Expand Down
10 changes: 7 additions & 3 deletions packages/notifications/__mocks__/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,16 +172,20 @@ export const pinpointInAppMessage: PinpointInAppMessage = {
},
Priority: 3,
Schedule: {
EndDate: '2021-01-01T00:00:00Z',
EndDate: '2024-01-01T00:00:00Z',
EventFilter: {
FilterType: 'SYSTEM',
Dimensions: {
Attributes: {},
Attributes: {
interests: { Values: ['test-interest'] },
},
EventType: {
DimensionType: 'INCLUSIVE',
Values: ['clicked', 'swiped'],
},
Metrics: {},
Metrics: {
clicks: { ComparisonOperator: 'EQUAL', Value: 5 },
},
},
},
QuietTime: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { defaultStorage } from '@aws-amplify/core';
import { dispatchEvent } from '../../../../../src/inAppMessaging/providers/pinpoint/apis';
import { processInAppMessages } from '../../../../../src/inAppMessaging/providers/pinpoint/utils';
import {
inAppMessages,
simpleInAppMessages,
simpleInAppMessagingEvent,
} from '../../../../../__mocks__/data';
import { InAppMessagingError } from '../../../../../src/inAppMessaging/errors';
import { notifyEventListeners } from '../../../../../src/common/eventListeners';

jest.mock('@aws-amplify/core');
jest.mock('@aws-amplify/core/internals/utils');
jest.mock('../../../../../src/inAppMessaging/providers/pinpoint/utils');
jest.mock('../../../../../src/common/eventListeners');

const mockDefaultStorage = defaultStorage as jest.Mocked<typeof defaultStorage>;
const mockNotifyEventListeners = notifyEventListeners as jest.Mock;
const mockProcessInAppMessages = processInAppMessages as jest.Mock;

describe('dispatchEvent', () => {
beforeEach(() => {
mockDefaultStorage.setItem.mockClear();
mockNotifyEventListeners.mockClear();
});
test('gets in-app messages from store and notifies listeners', async () => {
const [message] = inAppMessages;
mockDefaultStorage.getItem.mockResolvedValueOnce(
JSON.stringify(simpleInAppMessages)
);
mockProcessInAppMessages.mockReturnValueOnce([message]);
await dispatchEvent(simpleInAppMessagingEvent);
expect(mockProcessInAppMessages).toBeCalledWith(
simpleInAppMessages,
simpleInAppMessagingEvent
);
expect(mockNotifyEventListeners).toBeCalledWith('messageReceived', message);
});

test('handles conflicts through default conflict handler', async () => {
mockDefaultStorage.getItem.mockResolvedValueOnce(
JSON.stringify(simpleInAppMessages)
);
mockProcessInAppMessages.mockReturnValueOnce(inAppMessages);
await dispatchEvent(simpleInAppMessagingEvent);
expect(mockProcessInAppMessages).toBeCalledWith(
simpleInAppMessages,
simpleInAppMessagingEvent
);
expect(mockNotifyEventListeners).toBeCalledWith(
'messageReceived',
inAppMessages[4]
);
});

test('does not notify listeners if no messages are returned', async () => {
mockProcessInAppMessages.mockReturnValueOnce([]);
mockDefaultStorage.getItem.mockResolvedValueOnce(
JSON.stringify(simpleInAppMessages)
);

await dispatchEvent(simpleInAppMessagingEvent);

expect(mockNotifyEventListeners).not.toBeCalled();
});

test('logs error if storage retrieval fails', async () => {
mockDefaultStorage.getItem.mockRejectedValueOnce(Error);
await expect(
dispatchEvent(simpleInAppMessagingEvent)
).rejects.toStrictEqual(expect.any(InAppMessagingError));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { defaultStorage } from '@aws-amplify/core';
import {
dispatchEvent,
setConflictHandler,
} from '../../../../../src/inAppMessaging/providers/pinpoint/apis';
import { processInAppMessages } from '../../../../../src/inAppMessaging/providers/pinpoint/utils';
import {
closestExpiryMessage,
customHandledMessage,
inAppMessages,
simpleInAppMessagingEvent,
} from '../../../../../__mocks__/data';
import { notifyEventListeners } from '../../../../../src/common/eventListeners';

jest.mock('@aws-amplify/core');
jest.mock('@aws-amplify/core/internals/utils');
jest.mock('../../../../../src/inAppMessaging/providers/pinpoint/utils');
jest.mock('../../../../../src/common/eventListeners');

const mockDefaultStorage = defaultStorage as jest.Mocked<typeof defaultStorage>;
const mockNotifyEventListeners = notifyEventListeners as jest.Mock;
const mockProcessInAppMessages = processInAppMessages as jest.Mock;

describe('Conflict handling', () => {
beforeEach(() => {
mockDefaultStorage.setItem.mockClear();
mockNotifyEventListeners.mockClear();
});
test('has a default implementation', async () => {
mockProcessInAppMessages.mockReturnValueOnce(inAppMessages);
await dispatchEvent(simpleInAppMessagingEvent);
expect(mockNotifyEventListeners).toBeCalledWith(
'messageReceived',
closestExpiryMessage
);
});

test('can be customized through setConflictHandler', async () => {
const customConflictHandler = messages =>
messages.find(message => message.id === 'custom-handled');
mockProcessInAppMessages.mockReturnValueOnce(inAppMessages);

setConflictHandler(customConflictHandler);
await dispatchEvent(simpleInAppMessagingEvent);

expect(mockNotifyEventListeners).toBeCalledWith(
'messageReceived',
customHandledMessage
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import {
pinpointInAppMessage,
simpleInAppMessagingEvent,
} from '../../../__mocks__/data';
import { processInAppMessages } from '../../../src/inAppMessaging/providers/pinpoint/utils/processInAppMessages';
import { cloneDeep } from 'lodash';
import {
isBeforeEndDate,
matchesAttributes,
matchesEventType,
matchesMetrics,
} from '../../../src/inAppMessaging/providers/pinpoint/utils/helpers';

jest.mock('@aws-amplify/core');
jest.mock('@aws-amplify/core/internals/utils');
jest.mock('../../../src/inAppMessaging/providers/pinpoint/utils/helpers');

const mockIsBeforeEndDate = isBeforeEndDate as jest.Mock;
const mockMatchesAttributes = matchesAttributes as jest.Mock;
const mockMatchesEventType = matchesEventType as jest.Mock;
const mockMatchesMetrics = matchesMetrics as jest.Mock;

// TODO(V6): Add tests for session cap etc
describe('processInAppMessages', () => {
const messages = [
cloneDeep(pinpointInAppMessage),
{ ...cloneDeep(pinpointInAppMessage), CampaignId: 'uuid-2', Priority: 3 },
{ ...cloneDeep(pinpointInAppMessage), CampaignId: 'uuid-3', Priority: 1 },
{ ...cloneDeep(pinpointInAppMessage), CampaignId: 'uuid-4', Priority: 2 },
];
beforeEach(() => {
mockMatchesEventType.mockReturnValue(true);
mockMatchesAttributes.mockReturnValue(true);
mockMatchesMetrics.mockReturnValue(true);
mockIsBeforeEndDate.mockReturnValue(true);
});

test('filters in-app messages from Pinpoint by criteria', async () => {
mockMatchesEventType.mockReturnValueOnce(false);
mockMatchesAttributes.mockReturnValueOnce(false);
mockMatchesMetrics.mockReturnValueOnce(false);
const [result] = await processInAppMessages(
messages,
simpleInAppMessagingEvent
);
expect(result.id).toBe('uuid-4');
});

test('filters in-app messages from Pinpoint by criteria', async () => {
const [result] = await processInAppMessages(
messages,
simpleInAppMessagingEvent
);
expect(result.id).toBe('uuid-3');
});
});
7 changes: 6 additions & 1 deletion packages/notifications/src/inAppMessaging/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export { identifyUser, syncMessages } from './providers/pinpoint';
export {
identifyUser,
syncMessages,
dispatchEvent,
setConflictHandler,
} from './providers/pinpoint';
7 changes: 6 additions & 1 deletion packages/notifications/src/inAppMessaging/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export { identifyUser, syncMessages } from './pinpoint/apis';
export {
identifyUser,
syncMessages,
dispatchEvent,
setConflictHandler,
} from './pinpoint/apis';
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import {
PINPOINT_KEY_PREFIX,
STORAGE_KEY_SUFFIX,
processInAppMessages,
} from '../utils';
import { InAppMessage } from '../../../types';
import flatten from 'lodash/flatten';
import { defaultStorage } from '@aws-amplify/core';
import { notifyEventListeners } from '../../../../common';
import { assertServiceError } from '../../../errors';
import { DispatchEventInput } from '../types';
import { syncMessages } from './syncMessages';
import { conflictHandler, setConflictHandler } from './setConflictHandler';

/**
* Triggers an In-App message to be displayed. Use this after your campaigns have been synced to the device using
* {@link syncMessages}. Based on the messages synced and the event passed to this API, it triggers the display
* of the In-App message that meets the criteria.
* To change the conflict handler, use the {@link setConflictHandler} API.
*
* @param DispatchEventInput The input object that holds the event to be dispatched.
*
* @throws service exceptions - Thrown when the underlying Pinpoint service returns an error.
*
* @returns A promise that will resolve when the operation is complete.
*
* @example
* ```ts
* // Sync message before disptaching an event
* await syncMessages();
*
* // Dispatch an event
* await dispatchEvent({ name: "test_event" });
* ```
*/
export async function dispatchEvent(input: DispatchEventInput): Promise<void> {
try {
const key = `${PINPOINT_KEY_PREFIX}${STORAGE_KEY_SUFFIX}`;
const cachedMessages = await defaultStorage.getItem(key);
const messages: InAppMessage[] = await processInAppMessages(
cachedMessages ? JSON.parse(cachedMessages) : [],
input
);
const flattenedMessages = flatten(messages);

if (flattenedMessages.length > 0) {
notifyEventListeners(
'messageReceived',
conflictHandler(flattenedMessages)
);
}
} catch (error) {
assertServiceError(error);
throw error;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@

export { identifyUser } from './identifyUser';
export { syncMessages } from './syncMessages';
export { dispatchEvent } from './dispatchEvent';
export { setConflictHandler } from './setConflictHandler';
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { InAppMessage } from '../../../types';
import { InAppMessageConflictHandler, SetConflictHandlerInput } from '../types';

export let conflictHandler: InAppMessageConflictHandler =
defaultConflictHandler;

/**
* Set a conflict handler that will be used to resolve conflicts that may emerge
* when matching events with synced messages.
* @remark
* The conflict handler is not persisted between sessions
* and needs to be called before dispatching an event to have any effect.
*
* @param SetConflictHandlerInput: The input object that holds the conflict handler to be used.
*
*
* @example
* ```ts
* // Sync messages before dispatching an event
* await syncMessages();
*
* // Example custom conflict handler
* const myConflictHandler = (messages) => {
* // Return a random message
* const randomIndex = Math.floor(Math.random() * messages.length);
* return messages[randomIndex];
* };
*
* // Set the conflict handler
* setConflictHandler(myConflictHandler);
*
* // Dispatch an event
* await dispatchEvent({ name: "test_event" });
* ```
*/
export function setConflictHandler(input: SetConflictHandlerInput): void {
conflictHandler = input;
}

function defaultConflictHandler(messages: InAppMessage[]): InAppMessage {
// default behavior is to return the message closest to expiry
// this function assumes that messages processed by providers already filters out expired messages
const sorted = messages.sort((a, b) => {
const endDateA = a.metadata?.endDate;
const endDateB = b.metadata?.endDate;
// if both message end dates are falsy or have the same date string, treat them as equal
if (endDateA === endDateB) {
return 0;
}
// if only message A has an end date, treat it as closer to expiry
if (endDateA && !endDateB) {
return -1;
}
// if only message B has an end date, treat it as closer to expiry
if (!endDateA && endDateB) {
return 1;
}
// otherwise, compare them
return new Date(endDateA) < new Date(endDateB) ? -1 : 1;
});
// always return the top sorted
return sorted[0];
}
Loading

0 comments on commit f5bcc3a

Please sign in to comment.