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.
This is the third part in a three part series.
- Part 1: Adding a field to the Mastodon API
- Part 2: Setting up the UI dev environment
- Part 3: Surfacing a new field in the Mastodon UI
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.
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.
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