Skip to main content

SPIKE: notifi-sdk-ts consolidation

Background

Currently, The way the following 3 packages interact with each other is like the flowchart below, which is complicated and hard to maintain.

  1. notifi-react-hook
  2. notifi-frontend-client
  3. notifi-react-card

Two key points:

  1. The notifi-react-hook is difficult to be consumed as a standalone package since it is tightly coupled with the 'notifi-react-card'.

  2. The notifi-react-card has many complex logic to handle 'Service logic' inside its useSubscribe hook.

Goal

To solve the problem, consolidating notifi-sdk-ts is needed so that the packages' interaction should become simple like below:

Actions

ActionDescriptionpoint
Consolidate notifi-react-hook (useNotifiClient) and notifi-frontend-client (NotifiFrontendClient)See breakdown below3
Extend FrontendClient to fully support all available chains and event typesSee breakdown below3
Move the logic of useNotifiSubscribe (notifi-react-card) to notifi-frontend-clientSee breakdown below5

Implementation Details

1. Consolidate notifi-react-hooks and notifi-frontend-client

Since notifi-react-card will finally use FrontendClient(notifi-frontend-client) to manipulate service instead of using useNotifiClient (notifi-react-hooks), making sure the FrontendClient is able to cover all the functionalities of useNotifiClient is the first step.

Step1: Implement getter to get the userState

The user state useNotifiSubscribe and useNotifiClient below need to be handled somehow.

  • isAuthenticated
  • isInitialized
  • expiry
  • isTokenExpired

Need to implement a getter in FrontendClient

packages/notifi-frontend-client/lib/client/FrontendClient.ts
export class NotifiFrontendClient {
// ...
private _userState: UserState | null = null;
get userState(): UserState | null {
return this._userState;
}
// ...
async initialize(): Promise<UserState> {
// ...
this._service.setJwt(authorization.token);
const userState = {
status: "authenticated",
authorization,
roles: roles ?? [],
};
this._userState = userState;
return userState;
return {
status: "authenticated",
authorization,
roles: roles ?? [],
};
}
}

So we can replace the previous isInitialized with the following example code:

// ...
const { isInitialized } = useNotifiSubscribe({ targetGroupName: "Default" });
const { userState } = client;
const isInitialized = !!userState;
// ...

Step#2: Consolidate useNotifiClient hook and NotifiFrontendClient object

The following now methods will need to be implemented in FrontendClient

  • userState
  • getConversationMessages
  • sendConversationMessages
  • createSupportConversation

See detailed info below

useNotifiClient hook v.s. NotifiFrontendClient object
Info
  1. createAlert is used updateAlertInternal. After making sure the the source is valid, the createAlert will be called. It is included in ensureAlert in frontendClient. So createAlert can be deprecated.

  2. Used in updateAlertInternal, and the updateAlertInternal is used in subscribe. And in subscribe, it iterates through all existing alerts to make sure if the alert to subscribe is valid. It is the same as ensureAlert. So createSource can be deprecated.

  3. In hook implementation (useNotifiClient), we firstly ensure every single source (utils/ensureSource). And then go for ensuring sourceGroup (utils/ensureSourceGroup). But in frontendClient, we only need to use ensureSourceAndFilters(frontend-client/ensureSource.ts). ensureSourceGroup can be deprecated.

  4. fetchData in useNotifiClient is used in useNotifiSubscribe hook to render the client info shown in the cards. We will need to implement a new method in FrontendClient to replace this one.

  5. Used inside subscribeWallet method in useNotifiSubscribe. It allows user to subscribe multiWallet if the dapp tenant enable multiWallet subscription. NotifiFrontendClient needs to impl new subscribeWallet method to replace this one as well as the subscribeWallet in useNotifiSubscribe hook.

  6. Used inside useItercomChat hook. Might need to simply impl getConversationMessages & sendConversationMessages stateless method in FrontendClient. (TBD)

  7. Used inside updateAlertInternal which is used in subscribe method in useNotifiSubscribe. subscribe method can be replaced with FrontendClient.ensureAlert method. So updateAlert can be deprecated.

  8. Used inside IntercomCard as a button handler. Might need to simply impl createSupportConversation stateless method in FrontendClient. (TBD)

  9. Used in subscribe method in useNotifiSubscribe. subscribe method can be replaced with FrontendClient.ensureAlert method. So createDiscordTarget can be deprecated.

  10. Used in resendEmailVerificationLink method in useNotifiSubscribe. NotifiFrontendClient has the sendEmailTargetVerification method which will cover both resendEmailVerificationLink in useNotifiSubscribe and sendEmailTargetVerification in useNotifiClient hook.

Step#3: Consolidate useNotifiSubscribe hook and NotifiFrontendClient object

We need to extract the logic of useNotifiSubscribe hook in notifi-react-card to NotifiFrontendClient object in notifi-frontend-client package.

So the 2nd step will be implementing the following new methods into FrontendClient

  • subscribeWallet
  • updateWallets

See detailed info below

useNotifiSubscribe hook v.s. NotifiFrontendClient object
FilterOptions.ts
tip

It is copy-paste from core, consider remove

  • ./packages/notifi-frontend-client/lib/models/FilterOptions.ts
  • packages/notifi-core/lib/NotifiClient.ts

2. Extend FrontendClient to fully support all available chains and event types

Step#1: Make all supported event available in notifi-frontend-client

Now, notifi-frontend-client only supports:

export type EventTypeItem =
| DirectPushEventTypeItem
| BroadcastEventTypeItem
| LabelEventTypeItem
| PriceChangeEventTypeItem
| CustomTopicTypeItem; // Including HealthCheckEventTypeItem

But in notifi-react-card, we have:

export type EventTypeItem =
| DirectPushEventTypeItem
| BroadcastEventTypeItem
| HealthCheckEventTypeItem
| LabelEventTypeItem
| TradingPairEventTypeItem
| WalletBalanceEventTypeItem
| PriceChangeEventTypeItem
| CustomTopicTypeItem
| XMTPTopicTypeItem;
  • WalletBalanceEventTypeItem
  • TradingPairEventTypeItem
  • XMTPTopicTypeItem;
tip

Related files

  • packages/notifi-frontend-client/lib/models/SubscriptionCardConfig.ts --> Add new XXXEventTypeItem into EventTypeItem
  • packages/notifi-frontend-client/lib/client/ensureSource.ts
    • Add new ensureXXXSource(s) method and implement into ensureSources used in ensureSourceAndFilters.
    • Add new getXXXSourceFilter method and implement into ensureSourceAndFilters
  • packages/notifi-frontend-client/lib/client/NotifiFrontendClient.ts

Step#2 Make all supported chains available in notifi-frontend-client

Currently, we only have APTOS, EVM and SOLANA supported in notifi-frontend-client.

We need to add the following 4 supported chains.

  • ACALA
  • SUI
  • NEAR
  • INJECTIVE
  1. Add new NotifiXXXConfiguration type
./packages/notifi-frontend-client/configuration/NotifiFrontendConfiguration.ts
export type NotifiFrontendConfiguration = NotifiSolanaConfiguration | NotifiAptosConfiguration;

export type NotifiAptosConfiguration = Readonly<{
walletBlockchain: 'APTOS';
authenticationKey: string;
accountAddress: string;
}> &
NotifiEnvironmentConfiguration;

export const newAptosConfig =
// ...

export type NotifiSolanaConfiguration = Readonly<{
walletBlockchain: "SOLANA";
walletPublicKey: string;
}> &
NotifiEnvironmentConfiguration;

export const newSolanaConfig =
// ...
// Need to add the reset of the chains
  1. Implement signedMessage in NotifiFrontendClient for associated chains
./packages/notifi-frontend-client/lib/client/NotifiFrontendClient.ts

private async _signMessage({
signMessageParams,
timestamp,
}: Readonly<{
signMessageParams: SignMessageParams;
timestamp: number;
}>): Promise<string> {
switch (signMessageParams.walletBlockchain) {
case 'ETHEREUM':
case 'POLYGON':
case 'ARBITRUM':
case 'AVALANCHE':
case 'BINANCE':
case 'OPTIMISM': {
// ...
}
case 'SOLANA': {
// ...
}
case 'APTOS': {
// ...
}
/*
** TODO: Need to implement the rest of the chains
** 1. ACALA
** 2. SUI
** 3. NEAR
** 4. INJECTIVE
*/
default:
// Need implementation for other blockchains
return 'Chain not yet supported';
}
}

  1. Storage prefix setting (TBC)
danger

There is conflict between notifi-frontend-client and notifi-react-hooks on the storage prefix. TBC

  • notifi-react-hooks: There s no blockchain specific prefix. all set as
${jwtPrefix}:${dappAddress}:${walletPublicKey}
  • notifi-frontend-client: Blockchain specific prefix is set. Like
// For APTOS
${jwtPrefix}:${dappAddress}:${accountAddress}:${walletPublicKey}
// Rest
${jwtPrefix}:${dappAddress}:${walletPublicKey}
  1. Add newbclientFactory for newly added chains
./packages/notifi-frontend-client/lib/client/clientFactory.ts
// ...
export const newXXXClient = (config: NotifiXXXConfiguration): NotifiFrontendClient => {
const storage = newNotifiStorage(config);
const service = newNotifiService(config);
return new NotifiFrontendClient(config, storage, service);
};

3. Move the logic of useNotifiSubscribe (notifi-react-card) to notifi-frontend-client

Step#1 Replace the useNotifiClient dependency with FrontendClient object.

packages/notifi-react-card/lib/context/NotifiClientContext.tsx
export const NotifiClientContextProvider: React.FC<NotifiParams> = ({
children,
...params
}: React.PropsWithChildren<NotifiParams>) => {
const client = useNotifiClient(params);
const config: NotifiFrontendConfiguration = "conditional check walletBlcokchain"; // use newXXXconfig to create config
const storage = useMemo(()=> newNotifiStorage(config), [config]);
const service = useMemo (()=> newNotifiService(config), [config]);
const client = useMemo(() => new FrontendClient(params, storage, service), [ storage, service]));
return (
<NotifiClientContext.Provider value={{ client, params }}>
{children}
</NotifiClientContext.Provider>
);
};

Step#2 Use client to replace the methods imported from useNotifiSubscribe

We need to replace the methods imported from useNotifiSubscribe with FrontendClient object provided by context. The following components are using useNotifiSubscribe:

  • packages/notifi-react-card/lib/components/intercom/IntercomCard.tsx
packages/notifi-react-card/lib/components/intercom/IntercomCard.tsx
const { instantSubscribe, isAuthenticated, isInitialized } = useNotifiSubscribe({
targetGroupName: "Intercom",
});
const { client } = useNotifiClientContext();
const { userState, ensureAlert } = client;
const isInitialized = !!userState;
const isAuthenticated = client.userState?.status === "authenticated";

  • packages/notifi-react-card/lib/components/subscription/EventTypeDirectPushRow.tsx
  • packages/notifi-react-card/lib/components/subscription/EventTypeCustomHealthCheckRow.tsx
  • packages/notifi-react-card/lib/components/subscription/EventTypeCustomToggleRow.tsx
  • packages/notifi-react-card/lib/components/subscription/EventTypeHealthCheckRow.tsx
  • packages/notifi-react-card/lib/components/subscription/EventTypePriceChangeRow.tsx
  • packages/notifi-react-card/lib/components/subscription/EventTypeTradingPairsRow.tsx
  • packages/notifi-react-card/lib/components/subscription/EventTypeWalletBalanceRow.tsx
  • packages/notifi-react-card/lib/components/subscription/EventTypeXMTPRow.tsx
  • packages/notifi-react-card/lib/components/subscription/EventTypeBroadcastRow.tsx
packages/notifi-react-card/lib/components/subscription/EventTypeBroadcastRow.tsx
const { instantSubscribe } = useNotifiSubscribe({
targetGroupName: "Default",
});
const { client } = useNotifiClientContext();
const { ensureAlert } = client;
  • packages/notifi-react-card/lib/components/subscription/NotifiSubscribeButton.tsx
packages/notifi-react-card/lib/components/subscription/NotifiSubscribeButton.tsx
const { isInitialized, subscribe, updateTargetGroups } = useNotifiSubscribe({
targetGroupName: "Default",
});
const { client } = useNotifiClientContext();
const { ensureAlert, ensureTargetGroup, userState } = client;
const isInitialized = !!userState;
  • packages/notifi-react-card/lib/components/subscription/NotifiSubscriptionCardContainer.tsx
packages/notifi-react-card/lib/components/subscription/NotifiSubscriptionCardContainer.tsx
const { isInitialized } = useNotifiSubscribe({ targetGroupName: "Default" });
const { client } = useNotifiClientContext();
const { userState } = client;
const isInitialized = !!userState;
  • packages/notifi-react-card/lib/components/subscription/SubscriptionCardV1.tsx
packages/notifi-react-card/lib/components/subscription/SubscriptionCardV1.tsx
const { isInitialized, isTokenExpired } = useNotifiSubscribe({
targetGroupName: "Default",
});
const { client } = useNotifiClientContext();
const { userState } = client;
const isInitialized = !!userState;
const isTokenExpired = userState?.status === "expired";
  • packages/notifi-react-card/lib/components/subscription/subscription-card-views/ExpiredTokenViewCard.tsx
packages/notifi-react-card/lib/components/subscription/subscription-card-views/ExpiredTokenViewCard.tsx
const { logIn } = useNotifiSubscribe({ targetGroupName: "Default" });
const { client } = useNotifiClientContext();
const { logIn } = client;
  • packages/notifi-react-card/lib/components/subscription/subscription-card-views/VerifyWalletView.tsx
packages/notifi-react-card/lib/components/subscription/subscription-card-views/VerifyWalletView.tsx
const { subscribe, updateWallets } = useNotifiSubscribe({
targetGroupName: "Default",
});
const { client } = useNotifiClientContext();
const { ensureAlert, login, fetchData } = client;
tip

updateWallets is trying to call login and ensureSourceGroup methods. Then finally fetchData to get updated data

-packages/notifi-react-card/lib/components/WalletList/ConnectWalletRow.tsx

packages/notifi-react-card/lib/components/WalletList/ConnectWalletRow.tsx
const { subscribeWallet } = useNotifiSubscribe({
targetGroupName: "Default",
});
const { client } = useNotifiClientContext();
const { subscribeWallet } = client;

Step#3 Ensure all direct client usage in notifi-react-card can be normally used.

  1. packages/notifi-react-card/lib/components/intercom/IntercomCard.tsx
packages/notifi-react-card/lib/components/intercom/IntercomCard.tsx
const { client } = useNotifiClientContext();

const result = await client.createSupportConversation();
  1. packages/notifi-react-card/lib/components/subscription/EventTypeDirectPushRow.tsx
packages/notifi-react-card/lib/components/subscription/subscription-card-views/HistoryCardView.tsx
const { client } = useNotifiClientContext();
const result = await client.getNotificationHistory({
first,
after,
});
  1. packages/notifi-react-card/lib/hooks/useIntercomCard.ts
packages/notifi-react-card/lib/hooks/useIntercomCard.ts
const { client } = useNotifiClientContext();
client.fetchSubscriptionCard({
type: "INTERCOM_CARD",
id: cardId,
});
  1. packages/notifi-react-card/lib/hooks/useIntercomChat.ts
packages/notifi-react-card/lib/hooks/useIntercomChat.ts
const { client } = useNotifiClientContext();
client.getConversationMessages({
first: 50,
getConversationMessagesInput: { conversationId },
});
  1. packages/notifi-react-card/lib/hooks/useSubscriptionCard.ts
packages/notifi-react-card/lib/hooks/useSubscriptionCard.ts
const { client } = useNotifiClientContext();
client.fetchSubscriptionCard(input);

References

  1. type changes from using notifi-core to notifi-graphql:

Below is an example of type difference between react-frontend-client and react-hook.

packages/notifi-frontend-client/lib/client/NotifiFrontendClient.ts
  async completeLoginViaTransaction({
walletBlockchain,
walletAddress,
transactionSignature,
// The input and output are from notifi-graphql
}: CompleteLoginProps): Promise<Types.CompleteLogInByTransactionMutation>
// ...

type CompleteLoginProps = Omit<
Types.CompleteLogInByTransactionInput,
'dappAddress' | 'randomUuid'
>;
packages/notifi-graphql/lib/gql/generated.ts
export type CompleteLogInByTransactionInput = {
/** The dapp id for this tenant */
dappAddress: Scalars["String"];
/** Random client generated UUID used in hash generation of nonce+uuid */
randomUuid: Scalars["String"];
/** Timestamp in seconds since Unix epoch. Required for Aptos chain. This will be the timestamp on the transaction. */
timestamp?: InputMaybe<Scalars["Long"]>;
/** Transaction containing the Base64(SHA256(hash(nonce+uuid))) printed to 'Notifi Auth: <value>' */
transactionSignature: Scalars["String"];
/** Address of wallet attempting to log in with */
walletAddress: Scalars["String"];
/** Blockchain of the wallet */
walletBlockchain: WalletBlockchain;
/** Public key of wallet attempting to log in with. Required for Aptos chain. */
walletPublicKey?: InputMaybe<Scalars["String"]>;
};

export type CompleteLogInByTransactionMutation = {
__typename?: "NotifiMutation";
completeLogInByTransaction?:
| {
__typename?: "User";
email?: string | undefined;
emailConfirmed: boolean;
roles?: Array<string | undefined> | undefined;
authorization?: { __typename?: "Authorization"; token: string; expiry: string } | undefined;
}
| undefined;
};
packages/notifi-react-hooks/lib/hooks/useNotifiClient.ts
const completeLoginViaTransaction = useCallback(
async (
// The input and output are from notifi-core
input: CompleteLoginViaTransactionInput
): Promise<CompleteLoginViaTransactionResult>
// ...
packages/notifi-core/lib/NotifiClient.ts
export type CompleteLoginViaTransactionInput = Readonly<{
transactionSignature: string;
}>;

export type CompleteLoginViaTransactionResult = Readonly<User>;

export type User = Readonly<{
email: string | null;
emailConfirmed: boolean;
authorization: Authorization | null;
roles: ReadonlyArray<string> | null;
}>;
Question: storage modules
  • packages/notifi-react-hooks/lib/utils/storage.ts
  • packages/notifi-frontend-client/lib/storage/NotifiFrontendStorage.ts
caution

Questions

  • What is oldValue for?
  • Why we have to reset the key to newKey with expired authorization when the oldKey is not null?
packages/notifi-react-hooks/lib/utils/storage.ts
// ...
const oldKey = `${jwtPrefix}:${dappAddress}:${walletPublicKey}`;
const newKey = `${jwtPrefix}:${dappAddress}:${walletPublicKey}:authorization`;
const getAuthorization = async () => {
const oldValue = await localforage.getItem<string>(oldKey);
if (oldValue !== null) {
const expiry = new Date();
expiry.setMinutes(expiry.getMinutes() - 1); // Assume expired
const migrated: Authorization = {
token: oldValue,
expiry: expiry.toISOString(),
};

await localforage.removeItem(oldKey);
await localforage.setItem(newKey, migrated);
}

return await localforage.getItem<Authorization>(newKey);
};
  • It seems like the oldKey logic does not appear in frontend-client.
packages/notifi-frontend-client/lib/storage/NotifiFrontendStorage.ts
export const createLocalForageStorageDriver = (config: NotifiFrontendConfiguration): StorageDriver => {
let keyPrefix = `${getEnvPrefix(config.env)}:${config.tenantId}`;
switch (config.walletBlockchain) {
case "SOLANA": {
keyPrefix += `:${config.walletPublicKey}`;
break;
}
case "APTOS": {
keyPrefix += `:${config.accountAddress}:${config.authenticationKey}`;
break;
}
}
// ...
};

Note: The duplicated code also happens in /notifi-frontend-client/lib/storage/InMemoryStorageDriver.ts and /notifi-frontend-client/lib/storage/LocalForageStorageDriver.ts

  • Why does frontend-client has iInMemoryStorageDriver? and what is it for
utils modules mapping (hooks and frontendClient)
- `packages/notifi-react-hooks/lib/utils` - `packages/notifi-frontend-client/lib/client`
info
  • alertUtils is totally not used --> deprecate.
  • fetchDataImpl only used in hooks doing internal data fetching. In frontendClient, the source or target not exist, error will be directly thrown. --> deprecate.
SubscriptionCardConfig.ts
tip

react-card will make use of the SubscriptionCardConfig from frontend-client --> deprecate the one in react-card.

  • packages/notifi-react-card/lib/hooks/SubscriptionCardConfig.ts
  • packages/notifi-frontend-client/lib/models/SubscriptionCardConfig.ts