User customizable theme components (how-to)

On my forum I wanted give the users some customization options on the default theme. I wanted to give them the option to set a background for the forum. I could make different themes for this, but that is quite a bit of extra work. And while themes do have settings, these are for everybody and not specifically for a user. But there is a way to achieve user configurable settings for themes, and that is by “abusing” user fields.

In my theme the user has actually 3 settings for the wallpaper (image, blending with the background color, and content translucently) but for this post I am just going to focus on a single setting, the wallpaper image.

Step 1: creating the user field

The user have an option to select a bunch of different wallpapers, or no wallpaper at all. For the user field I created an optional dropdown field with the following options:

  1. none – no wall paper at all, this is important
  2. Default – if the user made no selection
  3. Alternative One
  4. Alternative Two

This is an optional field, so the default value would be blank. For a blank value the I would like the default wallpaper to be used.

Step 2: CSS

In the common/common.scss I defined the following:

:root {
  --wallpaper-default: url(#{$img-wallpaper-default});
  --wallpaper-alternative-one: url(#{$img-wallpaper-alternative-one});
  --wallpaper-alternative-two: url(#{$img-wallpaper-alternative-two});
  /* default to none to reduce possible initial image switching */
  --wallpaper: none;
}

body {
  background-image: var(--wallpaper);
}

The :root contains the variables to the various wallpaper images, which are theme assets. The background image is set to the --wallpaper variable, which is initially none. So by default when the CSS is loaded there will be no wallpaper. This is done to remove a possible “flicker” when the user selected a wallpaper which is different from the original.

Each selectable wallpaper gets a variable --wallpaper-xx-yy-zz. User field values are plain text. Based that value I will select the correct CSS variable to use as the background image.

Step 3: theme settings

In the JavaScript code I need to know the user field which contains the wallpaper setting. The easiest way to get this is to refer to the user field ID directly. So you need to create a theme setting to configure this userfield ID

wallpaper_user_field:
  default: -1
  type: integer

Step 4: setting the wallpaper

For this you need to write some JavaScript. As this code need to be executed away and as early as possible you would create an initializer javascripts/discourse/initializer/your-theme-component.js.

Lets start with the basics:

import { setOwner } from '@ember/owner';
import { withPluginApi } from 'discourse/lib/plugin-api';

const PLUGIN_ID = 'your-theme-component';

class YourThemeWallpaper {
  api;
  styleRoot;

  constructor(owner, api) {
    setOwner(this, owner);
    this.api = api;
    // fetch the `html` element
    this.styleRoot = document.body.parentElement.style;
    this.loadWallpaperSettings();
  }

  // more logic will be added later
}

export default {
  name: PLUGIN_ID,
  initialize(owner) {
    withPluginApi('1.34.0', (api) => {
      this.instance = new YourThemeWallpaper(owner, api);
    });
  }
}

This is for the large boiler plate code to add some theme component JavaScript. The styleRoot variable should reference the html element of the document, which we are going to use to change the effective CSS.

The function loadWallpaperSettings will do the actual loading of the user setting, a looks much like this:

loadWallpaperSettings() {
  let user = this.api.getCurrentUser();
  if (user) {
    this.api.container.lookup('store:main').find('user', user.username).then((user) => {
      let wp = user.user_fields[settings.wallpaper_user_field] || '';
      this.setWallpaper(wp);
    });
  } else {
    this.setWallpaper('');
  }
}

In order to get the user field values, an extra API call needs to be made, which could take a little while. Once this returns, we get the user field value and call setWallpaper which that value. settings.wallpaper_user_field is a reference to the theme setting from step #3. If the setting wasn’t configured the wp field will default to an empty string.

If there is no current user we call the setWallpaper function with a empty string to load the default wallpaper.

The setWallpaper function will actually update the wallpaper:

setWallpaper(wallpaper) {
  if (!wallpaper || wallpaper === '') {
    // use the default if none was set
    wallpaper = 'default';
  }
  // normalize the value to fit the CSS variable
  wallpaper = wallpaper.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-');
  if (wallpaper === 'none') {
    this.styleRoot.setProperty('--wallpaper', 'none');
  } else {
    this.styleRoot.setProperty('--wallpaper', 'var(--wallpaper-'+wallpaper+')');
  }
}

If the configured wallpaper setting was "none" we remove the background image. Otherwise the value is turned normalized to resemble a CSS variable. So “Alternative One” will result in setting the CSS variable --wallpaper to var(--wallpaper-alternative-one). If the user field value does not map to a CSS variable it will effectively result in no wallpaper, which is good enough.

Step 5: faster loading

The above setup works great. However, the extra API call can result in the wallpaper to be set quite late. Which does not produce a great result. In order to solve this, I make use of the browser’s Local Storage to store and retrieve the setting.

In the constructor, before loading the user’s wallpaper settings, first try setting the wallpaper based on data from the local storage. When loading the user settings, upload the local storage with what was configured.

const WALLPAPER_KEY = 'your_theme_wallpaper';

constructor(owner, api) {
  setOwner(this, owner);
  this.api = api;
  // fetch the `html` element
  this.styleRoot = document.body.parentElement.style;
  this.loadLocalStorage();
  this.loadWallpaperSettings();
}

loadWallpaperSettings() {
  let user = this.api.getCurrentUser();
  if (user) {
    this.api.container.lookup('store:main').find('user', user.username).then((user) => {
      let wp = user.user_fields[settings.wallpaper_user_field] || '';
      localStorage.setItem(WALLPAPER_KEY, wp);
      this.setWallpaper(wp);
    });
  } else {
    localStorage.removeItem(WALLPAPER_KEY);
    this.setWallpaper('');
  }
}

loadLocalStorage() {
  let data = localStorage.getItem(WALLPAPER_KEY);
  if (data) {
    this.setWallpaper(data);
  }
}

This greatly improves setting the wallpaper early in loading Discourse. Effectively the setWallpaper function would be called twice. But as the value rarely changes, this will not be noticable. Only if the user changed the wallpaper it would flicker during the next load. This is also the reason why the initial wallpaper in the CSS is set to none instead of the default.

Step 6: live preview

When changing the color scheme in Discourse you get a live preview of this new color scheme, you do not have to save and reload to see how it would look. This would also be really nice for the wallpaper settings so that users to check which they like the most.

To pull this off you need some additionally trickery by modifying the user field components.

At the end of the constructor, I added a call to this.setupLivePreview() which contains the following:

setupLivePreview() {
  api.onPageChange((url, title) => {
    if (this.wallpaperPreviewed) {
      this.wallpaperPreviewed = false;
      this.loadWallpaperSettings();
    }
  });

  let _this = this;
  api.modifyClass('component:user-fields/dropdown', {
    pluginId: PLUGIN_ID,
    didUpdateAttrs() {
      if (this.field.id == settings.wallpaper_user_field) {
        _this.wallpaperPreviewed = true;
        _this.setWallpaper(this.value);
      }
    }
  });		
}

In the first part we listing to page change events. If wallpaperPreviewed was true, we reload the user’s originally configured settings. If the user did not save the new values of the user fields, then the wallpaper would be restored to the original setting.

Next is the real magic. We modify the dropdown user field component to add some additional code to the didUpdateAttrs function. This would be called if the user field would get a new value. When this happens, we set the wallpaperPreviewed variable to true, so that on a page change the save settings would be loaded. Next we call the setWallpaper function to show the currently selected wallpaper instead.

The same thing can be done for the other user field types. For example, for the plain text field you would modify the class component:user-fields/text.

Conclusion

With this you give your users some control over how theme components can effect their experience by user user fields.

The only problem is that the user fields are under Preferences > Profile instead of Preferences > Interface where you would probably want to have them.

8 Likes