Product Update

Facebook Icon Twitter Icon Linkedin Icon

AnyMind Group

Facebook Icon Twitter Icon Linkedin Icon

[Tech Blog] How we built AnyChat's Front-End

AnyChat is a newly released Conversational Commerce Platform by AnyMind Group on 3 March 2022. With that said, today we are here to share some insights on how we build AnyChat from Front-End prospective.

Some Backgrounds…

  • Language: React + TypeScript
  • Build System: Nx + CircleCI
  • Authentication: Auth0
  • Data Fetching: GraphQL + Apollo Client
  • Router: React Location
  • State Management: Zustand(Immer) + Apollo Client
  • Form Management: React Hook Form + Vest (validation)
  • Rich Text Editor: Draft.js
  • Styling: tailwindcss + Sass
  • Headless UI Library: Radix UI + Headless UI
  • Testing: Jest + React Testing Library
  • Documentation: Storybook

Why Nx?

As AnyChat needs to integrate with multiple third-party platforms like Auth0, LINE, Shopify and more to come (Instagram, WhatsApp etc..). Some of the platforms require to publish an isolated app for connections. To manage different apps in single codebase. Nx provides first-class monorepo support like caching, code sharing, code boundary and more. Also, it allows us to visualised what will be affected by its affected command in commits to commits, which can prevent unexpected changes.

We have four apps so far, console , login , reset-password and liff-app.

Like auth-feature-login, is only used by login. auth-shared is used by both reset-password and login. shared-ui is used by all apps. In this way, we can setup a good code boundary and allows the developer to aware the relation of each libraries.

Dependency graph

Dependency graph

Auth0 makes authentication and authorization easy and safe

Auth0 is an identity management platform which mainly provides identity authentication and authorization services.

It provides different kinds of SDK base on your stacks and languages. Auth0 is suitable for some of following scenarios:

  • You would like your app could accept different login connections like Google, Facebook …etc
  • You have many apps and you would like to centralized the user management and authorized application
  • You prefer not spend too much effort on handling things such like login, logout, reset password…etc.

There are still many advantages like security and modern industry standard, the things we mentioned up there are just some of them.

Connect the project with Auth0

Let’s have a look how to connect with Auth0.


{
import { Auth0Provider } from '@auth0/auth0-react'

<Auth0Provider
  domain='YOUR_APP_DOMAIN' // Get it from Auth0 console
  clientId='YOUR_CLIENT_ID' // Get it from Auth0 console
  redirectUri={window.location.origin}
>
   ...
</Auth0Provider>
}

import { useEffect } from 'react'
import { useAuth0 } from '@auth0/auth0-react'

const ProtectedRoute = () => {
  const {
    user,
    isAuthenticated,
    isLoading,
    loginWithRedirect,
    logout,
    getIdTokenClaims,
  } = useAuth0()
  
  useEffect(() => {
    if(!isAuthenticated) {
      loginWithRedirect();
    }
  }, [isAuthenticated, loginWithRedirect])

  ...
}

1. On index.tsx, wrap your root component with an Auth0Provider that you can import from the auth0 react SDK.

domain and clientId: The values of these properties correspond to the “Domain” and “Client ID” values present under the “Settings” of the single-page application that you registered with Auth0.

redirectUri: The URL to where you’d like to redirect your users after they authenticate with Auth0.

2. Create ProtectedRoute.tsx, wrap the routes that need to be authenticated. Check the isAuthenticated boolean flag, if the user is not authenticated, we will call the method loginWithRedirect to redirect the user to login page.

Customize Login / Reset-Password Page on Auth0
To use customize version UI of login or reset-password, Auth0 allows us to upload a html file. To access the auth0 setting during runtime, there are some configurations need to be added inside our index.html

Login


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>AnyChat</title>
    <base href="/" />

    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <title>AnyChat</title>
    <script>
      var config = JSON.parse(
        decodeURIComponent(escape(window.atob('@@config@@')))
      );

      window.__AUTH0_CONFIG__ = config;
    </script>
  </head>

  <body>
    <div id="root"></div>
  </body>
</html>

The @@config@@ construct is a placeholder that will be replaced by the runtime.

The config object contains the details of auth0 settings that allows you to login a user.

Reset Password


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <base href="/" />

    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <meta name="theme-color" content="#000000" />
    <title>AnyChat</title>
    <script>
      const config = {
        email: '{{email | escape}}',
        csrf_token: '{{csrf_token}}',
        ticket: '{{ticket}}',
        password_policy: '{{password_policy}}',
        password_complexity_options: JSON.parse(
          '{{password_complexity_options}}'
        ),
      };

      window.__RESET_PASSWORD_CONFIG__ = config;
    </script>
  </head>

  <body>
    <div id="root"></div>
  </body>
</html>

Similar to login, reset password will also get the config during runtime. We could use the config object to identify the user and make followup action base on it.

We love GraphQL

In AnyMind, most of our products use GraphQL for data fetching, of course AnyChat is not an exception. Like we mentioned above, we use Apollo Client to consume the graph. It also comes with different tools to make our life easier, like the devtools and code generator.

Using GraphQL Code Generator
The code generator can generate various of types, react hooks base on the schema, operations and configuration. It saves our time to write the boilerplate code and keep us in sync with the remote schema. Run graphql-codegen codegen.yml, then it will generate files we defined under the generates section.


overwrite: true
schema:
  - {{graphql_remote_endpoint}}
  - './src/graphql/_client/extend.graphql'
documents: "src/graphql/**"
generates:
  src/generated/graphql.ts:
    plugins:
      - 'typescript'
      - 'typescript-operations'
      - 'typescript-react-apollo'
    config:
      enumsAsConst: true
      skipTypeNameForRoot: true
      immutableTypes: true
      exportFragmentSpreadSubTypes: true
  ./graphql.schema.json:
    plugins:
      - 'introspection'
  src/generated/possibleTypes.ts:
    plugins:
      - 'fragment-matcher'

Sample code generator configuration

Optimist UI with Apollo Client
When a user tries to send a chat message, we wants to update the UI before the server response. To do so, we rely on using Apollo Cache and adding client-specific field to our schema.

Demo


export const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      BotTextMessageEvent: {
        fields: {
          // This field is client only.
          messageState: {
            read(messageState: MessageState = 'SENT', { args }) {
              return messageState;
            },
          },
        },
      },
      ChatEvents: {
        keyFields: ['chatId'],
        merge(existing, incoming, { args, mergeObjects }) {
          return mergeObjects(existing, incoming);
        },
        fields: {
          chatEvents: {
            merge(
              existing: Reference[] = [],
              incoming: Reference[] = [],
              { variables, readField, mergeObjects, cache }
            ) {
              const merged: Reference[] = [];

              const messageIdToIndex: Record =
                Object.create(null);

              existing.forEach((message) => {
                const id = readField('id', message);
                const messageState = readField(
                  'messageState',
                  message
                );
                if (id) {
                  if (
                    !Object.prototype.hasOwnProperty.call(
                      messageIdToIndex,
                      id
                    ) &&
                    messageState !== 'WAIT_FOR_DISCARD'
                  ) {
                    const count = merged.push(message);
                    messageIdToIndex[id] = count - 1;
                  }
                }
              });

              const olderMessages: Reference[] = [];

              incoming.forEach((message) => {
                const id = readField('id', message);

                if (id) {
                  const index = messageIdToIndex[id];
                  if (typeof index === 'number') {
                    merged[index] = mergeObjects(merged[index], message);
                  } else {
                    if (variables?.olderThan) {
                      olderMessages.push(message);
                    } else {
                      merged.push(message);
                    }
                  }
                }
              });

              return [...olderMessages, ...merged];
            },
          },
        },
      },
    },
  }),
});

enum MessageState {
  SENDING
  SENT
  FAIL
  WAIT_FOR_DISCARD
}

type BotTextMessageEvent {
  messageState: MessageState!
}

function useSendCMessage({ chatId }: { chatId?: string }) {
  const [sendMessageMutation, { client }] = useChatMessageSendMutation();

  const sendChatMessage = useCallback(
    async (message = '') => {
      const chatEventsRef = client.cache.identify({
        chatId,
        __typename: 'ChatEvents',
      });

      // Shape of optimistic response
      const newMessage: BotTextBubbleEventFragment = {
        id: randomId,
        message,
        messageState: MessageState.Sending,
        __typename: 'BotTextMessageEvent',
      };

      // Write to cache
      const newMessageRef = client.cache.writeFragment({
        data: newMessage,
        fragment: BotTextBubbleEventFragmentDoc,
        fragmentName: 'BotTextBubbleEvent',
      });

      // Push to chatEvents
      client.cache.modify({
        id: chatEventsRef,
        fields: {
          chatEvents(existingChatEvents = [], { readField }) {
            return [...existingChatEvents, newMessageRef];
          },
        },
      });

      try {
        await sendMessageMutation({
          variables: {
            input: { message, chatId },
          },
          update: (cache, { data }) => {
            if (data?.ChatMessageSend && newMessageRef) {
              // Message sent succesfully
              // Set the mock message state to wait for discard
              // apollo merge policy will remove this optimistic response
              cache.modify({
                id: cache.identify(newMessageRef),
                fields: {
                  messageState() {
                    return MessageState.WaitForDiscard;
                  },
                },
              });

              // Set the response from BE to cache
              cache.writeFragment({
                id: chatEventsRef,
                fragment: gql`
                  ${ChatBubbleEventFragmentDoc}
                  fragment NewChatEvents on ChatEvents {
                    chatEvents {
                      ...ChatBubbleEvent
                    }
                  }
                `,
                fragmentName: 'NewChatEvents',
                data: {
                  chatEvents: [data.ChatMessageSend],
                },
              });
            }
          },
        });
      } catch (error) {
        if (newMessageRef) {
          // Update cache - mark as failed to send
          client.cache.modify({
            id: client.cache.identify(newMessageRef),
            fields: {
              messageState() {
                return MessageState.Fail;
              },
            },
          });
        }
      }
    },
    [chatId, sendMessageMutation, client]
  );

  ...
}

export default useSendMessage;

1. On extend.graphql, extend BotTextMesageEvent, add a client side only field messageState:MessageState

2. Setup field policy on apllo.ts

  • Default state of messageState is Sent
  • Create merge function on chatEvents. We will remove if messageState === 'WAIT_FOR_DISCARD'. Also to keep single list of data, we will merge existing data and incoming data into one array.

3. On useSendMessage.ts, we will handle the cache update during mutation.

  • Add the new message to cache, set messageState to Sending
  • Execute sendMessage mutation
  • If message sent successfully, we will replace the optimistic message to the response from the server. Set messageState to WAIT_FOR_DISCARD on the optimistic message, where it will be removed on Apollo field merge function.
  • If message sent unsuccessfully, simply update the optimistic message messageState to Fail

Conclusion

By now, I hope you will have some basic understanding on how we build AnyChat and organize the code. If you wish to know more, leave us comment and hopefully we can cover more topics later. Thank you.

Latest News