fx-private-relay/docs/frontend-architecture.md

11 KiB

The frontend can be found in /frontend. It is a Next.js app, which means it uses React.

How does the frontend get built?

We're using Next.js's static HTML export to generate a set of static HTML, JS and CSS files. Those are then served by our Django backend using Whitenoise.

How do I navigate the frontend codebase?

Every URL (e.g. relay.firefox.com/accounts/profile/) has a corresponding .page.tsx file (e.g. /frontend/src/pages/accounts/profile.page.tsx), so those are usually your entry points.

If your editor understands TypeScript, you can usually Ctrl+Click on a component to jump to its definition, to narrow down to the relevant code.

Tip: install the React DevTools to quickly find the relevant component.

I want to...

Add a new page

Create it in /frontend/src/pages. See https://nextjs.org/docs/basic-features/pages.

Send a request to the API

We use SWR. See src/hooks/api for examples.

Add a new string

Add it to pendingTranslations.ftl, then submit a PR to the l10n repo. Updates to that repository (both yours, as well as updated translations from Pontoon) are automatically pushed to this repository (see the "Update submodules" commits in the commit history) by a scheduled daily job.

Add styling

We use CSS modules. Add a .module.scss or .module.css file for the component you're styling. Class names in there will be modified to be unique; you can get those class names as properties on the CSS file's default export.

Import tokens from Protocol.

Add telemetry

Uses react-ga. See useGaViewPing (and useFxaFlowTracker when measuring use of sign in/up links in particular) and trackPurchaseStart.

Add/modify environment-specific data

We only compile our front-end once, and then deploy the built code to our different environments (stage, production). This is different from an approach that you might be familiar with from other front-end projects, in which the code is built for each environment separately.

The consequence of this is that we can't inline environment-specific variables in our built code (i.e. like this). And since the front-end code is executed in the user's browser, it can't access the server environment like our back-end can.

But of course, the back-end can. So instead, what we do is as follows. The back-end exposes an API endpoint, runtime_data, via which it exposes selected environment variables to the outside world. You can extend this endpoint in /api/views.py. The front-end, then, makes an API request to that endpoint using the useRuntimeData hook; you can find this in /frontend/src/hooks/api/runtimeData.ts.

Add/modify mock data

When you create a pull request, the frontend is deployed to Netlify using the code from that PR. However, the back-end is mocked out (using Mock Service Worker). You can also run the UI with a mocked back-end locally by running npm run dev:mocked in the frontend directory.

The mock data is defined in /frontend/src/apiMocks/mockData.ts. For every API endpoint, different sets of data are defined for different user IDs, defined in the mockIds array. At the time of writing, the following mock users are available (IDs are inspired by the nine states of design):

  • empty: A user that just signed up for Relay, but has not created any aliases yet, nor have they upgraded to Premium.
  • onboarding: A user that has just upgraded to Premium, but hasn't completed the Premium onboarding flow yet.
  • some: A user that has an account that has seen some use: they've upgraded to Premium, and have created some aliases.
  • full: A user that has utilised most of the features of Relay. They have Premium, set up a custom domain, have both random and custom aliases, and have experienced an email bounce.

If you append ?mockId=<mockId> (e.g. ?mockId=some) to the URL, it will automatically log in as that mocked user. This is useful to quickly showcase a feature that's only visible in a particular state to e.g. a non-engineer. Alternatively, you can just click "Sign in", where you can choose between the different mock users.

If you need to modify the mock data (e.g. because an API exposes some new data), you can do so by first updating the API type definitions in the applicable API endpoint's data fetching hook (in /frontend/src/hooks/api/). If you then look at mockData.ts in an editor that supports TypeScript (or run npm run build:mocked), you should then be guided through which objects need updating.

If you want to add a new mock user, start by adding it to the mockIds array. TypeScript will then show errors for every endpoint's mock data that has not yet defined the relevant mock data, helping you add all the relevant data. Once you've done that, you should then see the new mock user in the usual way.

Communicate with the add-on

There are two types of communication between the add-on and the website: sharing data, and notifications of events. And since this communication can happen in either direction, there are four situations to consider:

1. The website shares data with the add-on

This is mainly to tell the add-on which user is currently logged in, and data about them. (There are also plans to minimise this to only share the API key with the add-on, which can then fetch the rest of the data from the API directly, and even to have the add-on authenticate against Firefox Accounts directly and then being able to communicate with the API without even needing to interact with the website.)

This is done via the <firefox-private-relay-addon-data> element, present in the user's dashboard, rendered by the <AddonData> component. The add-on looks for that element via its ID (#profile-main) and reads its attributes.

Thus, if you want to share data with the add-on, you can add it as an attribute there. Then in the add-on, you can expand get_profile_data.js to read it and copy it over to the extension storage.

2. The add-on shares data with the website

The add-on mainly tells the website whether it is installed, and, in case the user has disabled server-side storage of mask labels, the labels it has stored locally. It does so by changing attributes on the <firefox-private-relay-addon> element, rendered in _app.page.tsx. React components looking to access that data can do so using the useAddonData hook.

If you have more data to share with the website, you can add it in the add-on in inject_addon_data.js. To then make it available in the website, add the data you're injecting to the AddonData type in /frontend/src/hooks/addon.ts. Additionally, since attributes are always strings, you'll want to define how to convert that to the proper data structure (e.g. via JSON.parse) by adding a property to the attributeParsers object in that same file. The property name is the attribute you're writing, and the value is a function that takes a string (the attribute value) and returns the proper data structure.

3. The website sends a notification to the add-on

When e.g. the user performs an action on the website that the add-on should immediately act on, the website can send it a notification. This is used e.g. to tell the add-on to update the extension's storage when the user changes a mask label and has server-side storage disabled.

To do so, React components can again use the useAddonData hook, whose return value also includes a sendEvent function. This then fires a website event on the <firefox-private-relay-addon> element, which is listened to by the add-on in get_profile_data.js.

4. The add-on sends a notification to the website

We don't actually do this. Instead, the add-on simply calls browser.tabs.reload on any open website tab if it needs to reflect updated data.

Show/hide content to/from users with the add-on installed

Apply the class is-visible-with-addon to hide an element unless the user is visiting with the add-on. Conversely, add is-hidden-with-addon to show it unless the user is visiting with the add-on. Note that these are plain CSS classes, i.e. not CSS modules (in other words, use them as plain strings, rather than via styles["is-visible-with-addon"]).

They are defined in /frontend/src/styles/globals.scss for the website, and in relay-website.css in the add-on.

Work on the tracker removal report

Forwarded emails with blocked trackers contain a link to a report listing the detected trackers. This report is located at /tracker-report/, and hence generated by /frontend/src/pages/tracker-report.page.tsx. The content of the report is generated dynamically, based on data passed to it via the fragment (also known as hash, i.e. the part after #) in the URL. This ensures that the server does not have to do extra work to generate that report, and since browsers do not even send fragments to the server, the data contained in the report won't show up in our logs either.

In essence, the data is a URL encoded JSON object with three fields:

  • sender: a string, containing the email address that sent the email in which trackers were blocked.
  • received_at: a number, containing the UNIX time (i.e. nr. of seconds since Jan 1st, midnight, UTC, so the output of Date.now()) the email was sent.
  • trackers: an object with the tracker domains as keys, and the number found trackers from that domain as a value.

An example:

{ "sender": "email@example.com", "received_at": 1655288077484, "trackers": { "ads.facebook.com": 1, "ads.googletagmanager.com": 2 } }

You can generate this URL yourself by pasing the following in your browser console, changing the contents of JSON.stringify() with your desired values:

{ let url = new URL("http://localhost:3000/tracker-report"); url.hash = JSON.stringify({ sender: "email@example.com", received_at: Date.now(), trackers: { "ads.facebook.com": 1, "ads.googletagmanager.com": 2 } }); url.href }

This generates the following URL:

http://localhost:3000/tracker-report#{%22sender%22:%22email@example.com%22,%22received_at%22:1655288077484,%22trackers%22:{%22ads.facebook.com%22:1,%22ads.googletagmanager.com%22:2}}