Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(inApp): functional dispatchEvent & setConflictHandler APIs #12231

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d5b9c14
feat: functional identifyUser
Samaritan1011001 Sep 29, 2023
19b48f7
Merge branch 'next/main' into feat/notifications/identify-user
Samaritan1011001 Sep 29, 2023
46102d8
chore: address reviews, add exports tests
Samaritan1011001 Oct 2, 2023
6660af2
Merge branch 'next/main' into feat/notifications/identify-user
Samaritan1011001 Oct 3, 2023
221f551
fix: serviceOptions were included and verified
Samaritan1011001 Oct 3, 2023
4d6b17d
Merge branch 'next/main' into feat/notifications/identify-user
Samaritan1011001 Oct 3, 2023
ad787fe
fix: add necessary types for mock data
Samaritan1011001 Oct 3, 2023
82e7bd7
wip: update types
Samaritan1011001 Oct 3, 2023
f59eb44
wip: working state
Samaritan1011001 Oct 4, 2023
b1a5b1c
Merge branch 'next/main' into feat/inapp/dispatchEvent
Samaritan1011001 Oct 6, 2023
fa744d9
feat: dispatchEvent and setConflictHandler APIs
Samaritan1011001 Oct 6, 2023
ae440ad
fix: update export snapshot for the new APIs
Samaritan1011001 Oct 6, 2023
52ac6d5
xhore: remove unwanted type and logs
Samaritan1011001 Oct 6, 2023
93b6342
chore: add license header
Samaritan1011001 Oct 6, 2023
03a9f03
Merge branch 'next/main' into feat/inapp/dispatchEvent
Samaritan1011001 Oct 9, 2023
aa94cf1
chore: update doc strings
Samaritan1011001 Oct 9, 2023
026af99
chore: update input types
Samaritan1011001 Oct 9, 2023
8003117
chore; update comments to fix lint
Samaritan1011001 Oct 9, 2023
1262d01
chore: update comments based on review
Samaritan1011001 Oct 10, 2023
f8ab74e
chore: truncate comments and typo
Samaritan1011001 Oct 10, 2023
ac593a3
Merge branch 'next/main' into feat/inapp/dispatchEvent
Samaritan1011001 Oct 10, 2023
d93725f
Merge branch 'next/main' into feat/inapp/dispatchEvent
stocaaro Oct 10, 2023
d353112
Merge branch 'next/main' into feat/inapp/dispatchEvent
jimblanc Oct 10, 2023
8f8883e
Merge branch 'next/main' into feat/inapp/dispatchEvent
jimblanc Oct 10, 2023
c51f2cc
Merge branch 'next/main' into feat/inapp/dispatchEvent
jimblanc Oct 10, 2023
eb3c496
Merge branch 'next/main' into feat/inapp/dispatchEvent
jimblanc Oct 10, 2023
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
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',
cshfang marked this conversation as resolved.
Show resolved Hide resolved
EventFilter: {
FilterType: 'SYSTEM',
Dimensions: {
Attributes: {},
Attributes: {
interests: { Values: ['test-interest'] },
},
cshfang marked this conversation as resolved.
Show resolved Hide resolved
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 () => {
cshfang marked this conversation as resolved.
Show resolved Hide resolved
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', () => {
cshfang marked this conversation as resolved.
Show resolved Hide resolved
beforeEach(() => {
mockDefaultStorage.setItem.mockClear();
mockNotifyEventListeners.mockClear();
});
test('has a default implementation', async () => {
mockProcessInAppMessages.mockReturnValueOnce(inAppMessages);
await dispatchEvent(simpleInAppMessagingEvent);
expect(mockNotifyEventListeners).toBeCalledWith(
'messageReceived',
closestExpiryMessage
);
});
cshfang marked this conversation as resolved.
Show resolved Hide resolved

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';
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.
cshfang marked this conversation as resolved.
Show resolved Hide resolved
*
* @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.
cshfang marked this conversation as resolved.
Show resolved Hide resolved
*
* @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
Loading