Showing Note as Suffix on Username

Having put all the pieces in place, we’re now ready to modify the UI to display the attached note next to the user’s name. We’ll have to do a bit of plumbing to account for our own sins (denormalizing the field into the post means we’ll need to re-normalize it into accounts on the client-side). But then it’s just a few short tweaks to the React components to display it.

Two panel image. Panel 1 shows the `account_note` field we wish to copy down. Panel 2 shows where it is copied to

The final goal: get the data above to the location below.

This is the third part in a three part series.

Familiarity with React, Redux, and TypeScript will be helpful in understanding this part. Quick note on URLs: as I use the glitch-soc flavor of Mastodon, your files may be at slightly different, but analogous places, from my own and your line numbers might not quite line up.

Quick overview of Mastodon’s web UI

Mastodon’s web UI is, itself, a model-view-controller app that is pushed to the client and then begins making API requests to fetch data. The “backing store” for the web UI is a Redux storage, which keeps track of already-downloaded state, refreshes state on new downloads, and presents the state for React to build into a UI.

Dataflow in the Mastodon web UI

So to take advantage of the new data, we need to make some changes to the Redux layer and the component layer.

We start by looking into the components. Making some educated guesses based on component names mapping to model names coming from the server, I figure out that displayName is the variable that tracks what name should be shown for a user. Doing a text search turns it up in two places: app/javascript/flavours/glitch/components/display_name.tsx and app/javascript/flavours/glitch/components/status.jsx. In one case, there is a status property that grabs the acocunt out of the status, and in another, an account is passed as a property.And yes; these are pulling the name from the account in the status property. Okay, so one tricky bit: where we denormalized the display name onto the status previously for the APi, we’ll have to re-normalize it into the account so that display_name.tsx can fetch it.

Modifying Redux

Data coming in from the server is embedded into Redux via a set of actions. In general, this is how data is changed in Redux: an action modifies state, and state modification can trigger a re-render of some chunk of the UI. This is another model-view-controller decoupling: actions don’t care about how state is stored, state doesn’t care about how it’s displayed. I now know I’m looking for status and account state, so fishing around a bit I find that there are some status importers in app/javascript/flavours/glitch/actions/importer/index.js. Here, I’m going to fetch the account note off the status and cram it into the account attached to the status.

!
There’s an interesting inefficiency here that is letting me “get away with” this: the Mastodon API attaches the whole account under every status related to that account when it sends statuses over the wire. that’s hugely wasteful and might get changed in the future, which is something I should probably be sensitive to in this solution.

The change is pretty small: we add a new user_provided_acocunt_note field to accounts by cloning it down from the status.

--- a/app/javascript/flavours/glitch/actions/importer/index.js
+++ b/app/javascript/flavours/glitch/actions/importer/index.js
@@ -70,7 +70,9 @@ export function importFetchedStatuses(statuses) {

     function processStatus(status) {
       pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings')));
-      pushUnique(accounts, status.account);
+      const account = {...status.account};
+      account.user_provided_account_note = status.account_note ? status.account_note: '';
+      pushUnique(accounts, account);

       if (status.filtered) {
         status.filtered.forEach(result => pushUnique(filters, result.filter));

Note that we first clone the entire account before adding a new field. Redux does a lot of equality-by-same-object testing to determine when something has changed; it’s important to make a copy if you alter a field because (a) in general, Redux will flag those objects read-only so you have to and (b) the fresh object is how Redux knows to signal the UI to do a redraw.

At app/javascript/flavours/glitch/actions/importer/normalizer.js, There is also a normalizer that preps up display names for rendering to the UI by doing things like splicing emoji in and sanitizing the text against HTML escape characters. We’ll want to sanitize our note also.

--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -23,7 +23,10 @@ export function normalizeAccount(account) {
   const emojiMap = makeEmojiMap(account.emojis);
   const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;

+  account.user_provided_account_note_html = account.user_provided_account_note ? emojify(escapeTextContentForBrowser(account.user_provided_account_note), emojiMap) : '';
+
   account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
+
   account.note_emojified = emojify(account.note, emojiMap);
   account.note_plain = unescapeHTML(account.note);

There is now a user_provided_account_note_html field alongside the display_name_html field that the UI can use.

Modifying React Components

Now that the data is in place, we can modify the components to render it. There are two relevant components to update: the status renderer component has a hook for making a screenreader-friendly version of statuses, and then the display name renderer component handles rendering them for visual consumption.

Starting with the status component. We add a helper function to select the display name as either displayName or displayName (uesrProvidedAccountNote).

--- a/app/javascript/flavours/glitch/components/status.jsx
+++ b/app/javascript/flavours/glitch/components/status.jsx
@@ -28,13 +28,34 @@ import StatusPrepend from './status_prepend';

 const domParser = new DOMParser();

-export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => {
+/**
+ * Get the user name that should be displayed.
+ * @param {Status} status The entire status object
+ * @returns {string} The name that should be displayed.
+ */
+function getDisplayName(status) {
   const displayName = status.getIn(['account', 'display_name']);
+  const userProvidedAccountNote = status.getIn(['account', 'user_provided_account_note']);
+
+  if (displayName && userProvidedAccountNote) {
+    return `${displayName} (${userProvidedAccountNote})`;
+  }
+  if (!displayName && userProvidedAccountNote) {
+    return userProvidedAccountNote;
+  }
+  return displayName;
+}
+
+
+export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => {
+  const displayName = getDisplayName(status);

   const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text');
   const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
   const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent;

+
+
   const values = [
     displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
     spoilerText && !expanded ? spoilerText : contentText,

Finally, we update the dedicated display-name component. Same idea: small helper function to select just the HTML-ified display name or the HTML-ified display name and HTML-ified user-provided account note.

--- a/app/javascript/flavours/glitch/components/display_name.tsx
+++ b/app/javascript/flavours/glitch/components/display_name.tsx
@@ -17,6 +17,18 @@ interface Props {
   inline?: boolean;
 }

+/**
+ * Compile the display name from the name and a user provided account note.
+ * @param account The account containing the relevant values
+ * @returns A string, either just the display name or the display name and the note.
+ */
+function getDisplayName(account: Account): string {
+  const displayName = account.get('display_name_html');
+  const note = account.get('user_provided_account_note_html') as string;
+
+  return note ? `${displayName} (${note})` : displayName;
+}
+
 export class DisplayName extends React.PureComponent<Props> {
   handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
     currentTarget,
@@ -70,7 +82,7 @@ export class DisplayName extends React.PureComponent<Props> {
           <bdi key={a.get('id')}>
             <strong
               className='display-name__html'
-              dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
+              dangerouslySetInnerHTML={{ __html: getDisplayName(a) }}
             />
           </bdi>
         ))
@@ -91,7 +103,7 @@ export class DisplayName extends React.PureComponent<Props> {
           <strong
             className='display-name__html'
             dangerouslySetInnerHTML={{
-              __html: account.get('display_name_html'),
+              __html: getDisplayName(account),
             }}
           />
         </bdi>

All set! We check it in our dev environment and, sure enough, “bob” is now “(Bob, from accounting)”.

Next steps

This solution was a quick hack and there’s definitely room for improvement. The main one would be to stop using the already-defined note in this fashion; that field has semantic meaning, and copying it up here will be surprising for some users. Better would be to create our own new custom data as a sibling of the note (known_as ?) and plumb that through. It would also be more ideal if the API were sending the information via the account data, not glued to the status itself; doing that will require some thought as to how to properly feed the data into the serializer (possibly by modifying the model itself to fetch the relevant relationships when a user is logged in), or the controller to doctor the model before passing it on).


If you end up making use of this change in your Mastodon server, let me know! I hope it proves useful (or that even this walk through the server and client architectures inspires someone to apply their own tweaks). One of the great things about Mastodon is that as long as you don’t alter the Fediverse protocol adherence itself, your server is yours and you can do what you want with it.

Comments