Creating Custom Messages

Developing a Custom Message Type Model and its Message Type View is a key step in building a Custom Messaging Experiences.

Many projects will need to implement Custom Message Type Model components. Some of these Message Type Model components will be simple informational messages, and others will provide rich interactive messages. Message Type Model components that are interactive and allow participants in the conversation to interact using UIs provided within these Messages help form what Layer refers to as Messaging Experiences.

A Messaging Experience is:

  • a Message that is sent within a Conversation
  • is visible to participants of that Conversation
  • Enables UI interactions within that Message
  • Shares state changes from that UI across all participants

Message Model Lifecycles

On receiving a new Message and creating a MessageTypeModel for it, the following lifecycle is followed:

On creating a new Message to represent some MessageType:

var model = new TextModel({
  title: "I am an important title",
  text: "I am the text of the message"
});
model.send({
  conversation,
  callback: message => message.send()
});

The following lifecycle is followed:

Note

parseMessage and parseModelPart are not called in this flow; its assumed that the simple JSON-based properties are already setup correctly after calling the constructor.

Here are a list of methods used to create custom MessageTypeModel subclasses; their use is illustrated below.

Name Parameters Description
parseMessage() () Calls all methods needed to initialize the MessageTypeModel from a Message. Custom subclasses should not provide a custom version of this
parseModelPart() ({ payload, isEdit }) Subclass this method if your class does anything other than copying in simple string/number/booleans from the model.part.body JSON properties
parseModelChildParts() ({ changes, isEdit }) Subclass this method if your class has sub-MessageParts to process or sub-MessageTypeModels to process during intialization
parseModelResponses() () Subclass this method if your class uses Message Response data to set any of its properties.
getModelsByRole() (roleName) Returns array of MessageTypeModel objects with the specified role; typically used from MessageTypeModelparseModelChildParts().
initBodyWithMetadata() (fieldList) Returns a plain object that can be serialized and set as a MessagePartbody; used from MessageTypeModelgenerateParts() to specify which fields we want in our MessagePartbody.
addChildModel() (model, role, callback) Adds a model as a child model of the current model, and generates a MessagePart and adds it to the Message Part Tree. Used from MessageTypeModelgenerateParts() to generate sub-MessageParts
addChildPart() (part, role) Adds a MessagePart as a child Message Part of the message being generated within MessageTypeModelgenerateParts().

Opinion Message Example

Goals: Introduce the basics of creating a custom Message Type that uses a Standard Message View Container.

An Opinion Message is a sample message used to allow a user to rate and comment on a previous message. Key data that this message will contain:

Name Type Description
comment String A comment that the user has about some item of content
rating Number A number from 1-5 indicating the level of enthusiasm for their comment (1: Comment is simply brain storming throwing ideas out there; 5: This is It! This is the Idea! We are Geniuses!)
description String The Message that an opinion is being expressed about
author String The author of the message described by description
A rating of 4, and a comment of "I love this stuff" for Lamb Stew

The Opinion Message Type Model

This Opinion Message Type Model is a subclass of Message Type Model, which is defined using the root class Root.

Step 1: Setup a Basic Message Type Model Class

At a minimum, your class should setup

import { Core } from '@layerhq/web-xdk'
const { Root, MessageTypeModel, Client } = Core;

class OpinionModel extends MessageTypeModel { }

OpinionModel.MIMEType = 'application/vnd.customco.opinion+json';
OpinionModel.messageRenderer = 'vnd-customco-opinion-message-type-view';

OpinionModel.prototype.comment = '';
OpinionModel.prototype.rating = 0;
OpinionModel.prototype.description = '';
OpinionModel.prototype.author = '';

Core.Root.initClass.apply(OpinionModel, [OpinionModel, 'OpinionModel']);
Client.registerMessageTypeModelClass(OpinionModel, 'OpinionModel');

export default OpinionModel;
Step 2: Generating a Message

Each MessageTypeModel needs to know how to generate a Message that represents its data. This is done using the MessageTypeModelgenerateParts() method. This method knows what properties need to be written to the MessagePartbody, and creates the MessagePart that will transmit this Model’s data to all participants. This implementation uses the MessageTypeModelinitBodyWithMetadata() which generates the MessagePartbody from the specified property values:

import { Core } from '@layerhq/web-xdk';
const { MessagePart, MessageTypeModel } = Core;

class OpinionModel extends MessageTypeModel {
  generateParts(callback) {
    const body = this.initBodyWithMetadata(['comment', 'rating', 'description', 'author']);

    // Create the MessagePart using the static MIMEType property defined below;
    // Store the part in `this.part`
    this.part = new MessagePart({
      mimeType: this.constructor.MIMEType,
      body: JSON.stringify(body),
    });

    // Provide the new part via a callback
    callback([this.part]);
  }
}

OpinionModel.prototype.comment = '';
OpinionModel.prototype.rating = 0;
OpinionModel.prototype.description = '';
OpinionModel.prototype.author = '';

The MessageTypeModelgenerateParts() method should

  1. Provide an array of MessagePart objects to the callback
  2. Set this.part to the Root MessagePart for its Message Part Subtree.
  3. Generate a MessagePart that contains all properties that need to be conveyed to other users. (comment, rating, description and author)

The MessageTypeModelgenerateParts() method will be called when creating a message to represent a locally created model:

// Locally created model
const model = new OpinionModel({
  comment: `I think thats a great idea, but we should consider passenger pidgeon
    as an alternative to make sure we've done our due diligence`,
  rating: 2,
  description: `Perhaps we should use Layer's XDK and build custom messages
     to create an optimized experience for our users`,
  author: "CTO"
});

 // Calling send or generateMessage() or send() will call generateParts()
model.send({ conversation });
Step 3: Working with the Standard Message View Container

Not all Models are designed to work with the Standard Message View Container; the Container shows metadata (title, description, footer, etc…) under the Message Type View. A Receipt Message would not be rendered with a title, description and footer below it. Nor would a Status Message. The Opinion Message however is going to support the Standard Message View Container which is done by supporting the getTitle, getDescription and getFooter methods:

class OpinionModel extends MessageTypeModel {
  getTitle() {return 'Opinion about:'; }
  getDescription() { return this.description; }
  getFooter() { return this.author; }
}
Step 4: Working with the Conversation Lists

The MessageTypeModelgetOneLineSummary() method controls how this Message is summarized, primarily for use within the Conversation List. Each Converation in the Converation List shows the Last Message summary; which for a Text Model/Message is usually just the text of the message. For an image though it might be “Image Received”. For this Opinion Model:

class OpinionModel extends MessageTypeModel {
  getOneLineSummary() {
    return `Opinion on ${this.author}'s Message`;
  }
}
Step 5: Wrap up

Here is the full definition:

import { Core } from '@layerhq/web-xdk'
const { Root, MessageTypeModel, Client } = Core;

class OpinionModel extends MessageTypeModel {
  generateParts(callback) {
    const body = this.initBodyWithMetadata(['comment', 'rating', 'description', 'author']);

    // Create the MessagePart using the static MIMEType property defined below
    this.part = new MessagePart({
      mimeType: this.constructor.MIMEType,
      body: JSON.stringify(body),
    });

    // Provide the new part via a callback
    callback([this.part]);
  }

  getTitle() {return 'Opinion about:'; }
  getDescription() { return this.description; }
  getFooter() { return this.author; }

  getOneLineSummary() {
    return `Opinion on ${this.author}'s Message`;
  }
}

OpinionModel.prototype.comment = '';
OpinionModel.prototype.rating = 0;
OpinionModel.prototype.description = '';
OpinionModel.prototype.author = '';

// Static property defines the MIME Type for this model
OpinionModel.MIMEType = 'application/vnd.customco.opinion+json';

// Static property specifies the preferred Message Type View for representing this Model
OpinionModel.messageRenderer = 'vnd-customco-opinion-message-type-view';

Root.initClass.apply(OpinionModel, [OpinionModel, 'OpinionModel']);
Client.registerMessageTypeModelClass(OpinionModel, 'OpinionModel');

export default OpinionModel;

The above MessageTypeModel subclass can be initialized in two ways: from a Message or from properties fed into a constructor.

Typically, a MessageTypeModel is initialized from a Message as an automatic part of processing the Messages being added to the Message List:

const model = message.createModel();

MessagecreateModel() checks to see if the Message has an existing Model, and if not, instantiates a new one. This will call MessageTypeModelparseMessage(); most of the handling of MessageTypeModelparseMessage() is handled by the by MessageTypeModel; the Opinion Model being a simple model does not need to provide its own version of this method.

The Opinion Message Type View

A Message Type View is a type of Webcomponent we will create using the Layer XDK Framework; it uses the MessageViewMixin Mixin to properly setup the View and its properties. Among other things, the Mixin defines the model property, and insures that any time the model is modified, the Model’s change events are wired up to call the View’s onRerender() method.

Step 1: Setup a Basic Message Type View Class
import Layer from '@layerhq/web-xdk';
const registerComponent = Layer.UI.registerComponent;
const MessageViewMixin = Layer.UI.mixins.MessageViewMixin;

registerComponent('vnd-customco-opinion-message-type-view', {
  mixins: [MessageViewMixin],
  style: `vnd-customco-opinion-message-type-view {
    display: block;
  }
  `,

  methods: {
    onRerender() {
      this.innerHTML = `${this.comment} : ${this.model.rating}`;
    }
  }
});

The above Message Type View is oversimplified but also functional and runnable.

  • The MessageViewMixin sets up the model property and wires its change events to the onRerender() method
  • The style insures that the UI Component has a suitable default display styling (required)
  • The onRerender method handles both initial rendering and any rerendering due to change events from the OpinionModel.

The onRerender method is called immediately after the onRender method, and handles any rendering that is likely to change during the lifespan of the component. The above innerHTML assignment could be done in onRender() if we can rule out allowing users to change their comment or rating.

Step 2: Using Templates, Styles and TextFormatters

An HTML template will have much nicer rendering than our initial version:

registerComponent('vnd-customco-opinion-message-type-view', {
  mixins: [MessageViewMixin],
  template: `
    <div class="user-rating" layer-id="rating"></div>
    <div class="user-comment" layer-id="comment"></div>
  `,
  style: `
    vnd-customco-opinion-message-type-view {
      display: flex;
      flex-direction: row;
      width: 100%;
    }
    vnd-customco-opinion-message-type-view .user-comment {
      min-height: 40px;
      text-align: center;
      flex-grow: 1;
      width: 100px /* Flexbox bug workaround */
    }
    vnd-customco-opinion-message-type-view .user-comment p {
      line-height: 40px;
    }

    vnd-customco-opinion-message-type-view .user-rating {
      text-align: center;
      line-height: 40px;
      width: 30px;
      border-right: solid 1px #ccc;
    }
    vnd-customco-opinion-message-type-view.rating1 .user-rating {
      background-color: light-green;
    }
    vnd-customco-opinion-message-type-view.rating2 .user-rating {
      background-color: #baefba;
      color: white;
    }
    vnd-customco-opinion-message-type-view.rating3 .user-rating {
      background-color: yellow;
    }
    vnd-customco-opinion-message-type-view.rating4 .user-rating {
      background-color: orange;
    }
    vnd-customco-opinion-message-type-view.rating5 .user-rating {
      background-color: red;
      color: white;
    }
  `,

  methods: {
    onRerender() {
      this.nodes.comment.innerHTML = this.comment;
      this.nodes.rating.innerHTML = this.model.rating;
      this.classList.add('rating' + this.model.rating);
    }
  }
});

The above View definition provides an HTML template, which defines this.nodes.comment and this.nodes.rating so that they can be directly manipulated and set.

It also provides CSS classes to adjust the styling based on the rating. Typically these class changes go at the top level of the View (i.e. vnd-customco-opinion-message-type-view.rating5 rather than .user-rating.rating5) so that any child nodes can be restyled based on those classes.

One additional improvement to the above UI Component would process the text, insure that newline characters are rendered with line breaks, emojis render correctly, URIs are hyperlinked, etc…

import Layer from '@layerhq/web-xdk';
const processText = Layer.UI.handlers.text.processText;
registerComponent('vnd-customco-opinion-message-type-view', {
  mixins: [MessageViewMixin],
  methods: {
    onRerender() {
      this.nodes.comment.innerHTML = processText(this.model.comment);
      this.nodes.rating.innerHTML = this.model.rating;
      this.classList.add('rating' + this.model.rating);
    }
  }
});
Step 3: Working with the Standard Message View Container

In order to work with the Standard Message View Container, this View will add two properties: widthType and messageViewContainerTagName.

Every Message Type View should define its widthType. There are 3 width types:

  • Layer.UI.Constants.WIDTH.FULL: Uses all available width within the Message List whether needed or not (up to 450px)
  • Layer.UI.Constants.WIDTH.FLEX: width that has a minimum width and a maximum width but tries for an optimal size for its contents
  • Layer.UI.Constants.WIDTH.ANY: No minimum width, but otherwise behaves as FLEX. Rendering looks more like a Chat Bubble than a Card

In this case, its a FLEX width to fit the contents, as there is metadata to show and formatted contents for which a chat bubble styling is not suited.

Any Message Type View that uses Message View Container must specify the HTML Tag Name for the container that will contain that Message Type View using the View’s messageViewContainerTagName property.

import Layer from '@layerhq/web-xdk';

const Widths = Layer.UI.Constants.WIDTH;
registerComponent('vnd-customco-opinion-message-type-view', {
  mixins: [MessageViewMixin],
  style: `vnd-customco-opinion-message-type-view {
    display: block;
  }
  `,

  properties: {
    widthType: {
      value: Widths.FLEX
    },
    messageViewContainerTagName: {
      noGetterFromSetter: true,
      value: 'layer-standard-message-view-container'
    }
  }
});

Using React Rendering for The Opinion Message Type View

Some developers may prefer to write their Views in react. This section shows how to use a React Component to handle rendering while using the webcomponent as a framework for it, providing the expected properties and methods provided by the MessageViewMixin.

Step 1: Setup a Basic Message Type View Class

This basically copies the template from above. All styling other than insuring that the webcomponent gets a display can be put into your project’s Style Sheets.

import { Layer } from '../../get-layer'
import './opinion-message-type-model';

const registerComponent = Layer.UI.registerComponent;
const MessageViewMixin = Layer.UI.mixins.MessageViewMixin;
const Widths = Layer.UI.Constants.WIDTH;

registerComponent('vnd-customco-opinion-message-type-view', {

  // Make sure that this View contains all the necessary Message Type View properties and methods
  mixins: [MessageViewMixin],

  // Make sure that the webcomponent has a display style
  style: `
    vnd-customco-opinion-message-type-view {
      display: block;
      width: 100%;
    }
  `,

  properties: {
    // Setup the width type
    widthType: {
      value: Widths.FLEX
    },

    // Setup the Message Container
    messageViewContainerTagName: {
      noGetterFromSetter: true,
      value: 'layer-standard-message-view-container'
    }
  }
});
Step 2: Adding a React Renderer

The React component will take as its main property, the model:

import { Component } from "react";

class Opinion extends Component {
  render() {
    return (
      <div className={'rating' + this.props.model.rating}>
        <div className="user-rating">{this.props.model.rating}</div>
        <div className="user-comment">{this.props.model.comment}</div>
      </div>
    );
  }
}

Your react component can manage rendering, as well as internal state changes. It may also be useful within the constructor to subscribe to model changes:

import { Component } from "react";

class Opinion extends Component {
  constructor(props) {
    super(props);
    props.model.on('message-type-model:change', evt => this.setState({ lastEvent: evt }), this);
  }
  render() {
    return (
      <div className={'rating' + this.props.model.rating}>
        <div className="user-rating">{this.props.model.rating}</div>
        <div className="user-comment">{this.props.model.comment}</div>
      </div>
    );
  }
}

Finally, the webcomponent itself will need on onRender method:

import Layer from '@layerhq/web-xdk';
const registerComponent = Layer.UI.registerComponent;

registerComponent('vnd-customco-opinion-message-type-view', {
  methods: {
    onRender() {
      ReactDOM.render(<Opinion model={this.model} />, this);
    }
  },
});

PDF Message Example

Goals: Introduce the use of sub-message-parts for carrying additional data

This PDF Message will render a PDF document with a title and author below the document. The PDF Message Type Model starts with what we saw in the Opinion Message Type Model and adds the following:

  • It has a Child MessagePart in its MessageTypeModelchildParts array
  • It has an action that it supports (performed when the user clicks/taps on the Message)
  • It renders something more than simple textual HTML

Note

There are in fact two techniques that can be used for adding a PDF file;

  • Add a Message Part with raw PDF data (illustrated in this example)
  • Add a sub-message-type-model such as a File Model that represents a PDF file (illustrated in the next example)

Generally, using a sub-message-type-model is simpler, but this example is here to insure that the underlying models and structures of custom messages are understood.

The PDF Message will have the following properties:

Name Type Description
source MessagePart A MessagePart containing the PDF data
title String The title of the document
author String The author of the document
A PDF Message

The PDF Message Type Model

Step 1: Setup a Basic Message Type Model Class

Following the example for Step 1 of the Opinion Message Type Model, we start with:

import { Core } from '@layerhq/web-xdk';
const { Client, MessagePart, Root, MessageTypeModel } = Core;

class PDFModel extends MessageTypeModel {}

PDFModel.prototype.source = null;
PDFModel.prototype.author = '';
PDFModel.prototype.title = '';

// Static property specifies the preferred Message Type View for representing this Model
PDFModel.messageRenderer = 'vnd-customco-pdf-message-type-view';

// Static property defines the MIME Type that will be used when creating new Messages from this Model
PDFModel.MIMEType = 'application/vnd.customco.pdf+json';

Core.Root.initClass.apply(PDFModel, [PDFModel, 'PDFModel']);
Client.registerMessageTypeModelClass(PDFModel, 'PDFModel');

export default PDFModel;
Step 2: Generating a Message

As shown with the Opinion Message Type Model Example, we provide a [generateParts()` method to transform the Model into a Message. The key difference from the Opinion Message that was generated is the need to store the PDF data in a separate MessagePart.

Lets suppose that this Model is created after a Javascript File Object has been generated via an <input type="file" /> input:

var model = new PDFModel({
  source: pdfFile,
  title: "Death star plans",
  author: "Darth Adar"
});

The above model is transformed into a Message using MessageTypeModelgenerateParts() as follows:

import Layer from '@layerhq/web-xdk';
class PDFModel extends MessageTypeModel {
  generateParts(callback) {
    const body = this.initBodyWithMetadata(['title', 'author']);

    this.part = new MessagePart({
      mimeType: this.constructor.MIMEType,
      body: JSON.stringify(body),
    });

    // Replace the File/Blob source property with a proper MessagePart property.
    this.source = new MessagePart(this.source);

    // Setup this Message Part to be a Child Message Part within the Message Part Tree
    this.addChildPart(this.source, 'source');

    // Provide the Parts Array for this PDFModel
    callback([this.part, this.source]);
  }
}

The above example not only creates a Message with an extra MessagePart, but also establishes its place within the Message Part Tree by calling MessageTypeModeladdChildPart().

Note

The source property is not written to this.part.body; but rather is sent as a separate MessagPart; only title and author are sent in this.part.body.

Step 3: Parsing a Message

In the previous example, the Opinion Message Type Model was simple enough that there were no Child Message Parts/Child Models. MessageTypeModelparseModelChildParts method is used to look at all Child Models and Child Message Parts and initialize this Models’ 'state from those parts/models.

class PDFModel extends MessageTypeModel {
  parseModelChildParts({ changes, isEdit }) {
    super.parseModelChildParts({ changes, isEdit });

    // Setup this.source to refer to the MessagePart whose role=source
    this.source = this.childParts.filter(part => part.role === 'source')[0];
  }
}

The MessageTypeModelchildParts property has 0 or more MessagePart objects that are child nodes within the Message Part Tree. Using the MessagePartrole property we can filter for the source part.

Step 4: Working with the Standard Message View Container

In the previous example, the Opinion Message Type Model provided getTitle(), getDescription() and getFooter() methods in order to work with a Standard Message View Container. The PDF Message will need the same.

class PDFModel extends MessageTypeModel {
  getTitle() { return this.title || '' }
  getDescription() { return ''; }
  getFooter() { return this.author || ''; }
}
Step 5: Working with the Conversation Lists

As was shown in the Opinion Message Type Model Example, a getOneLineSummary() method controls how the Message is rendered in the Conversation List:

class OpinionModel extends MessageTypeModel {
  getOneLineSummary() {
    return this.title || 'PDF File';
  }
}
Step 6: Providing an Action

A defaultAction property is added which specifies what happens when the user taps/clicks on a PDF Message View representing this Model:

class PDFModel extends MessageTypeModel {}

PDFModel.defaultAction = 'vnd-open-pdf';

The defaultAction of vnd-open-pdf tells the XDK that whenever the user selects a View representing this Model, the vnd-open-pdf action should be run. Note that each Message can be created with a custom action when the Message is sent.

A handler for vnd-open-pdf is registered with the XDK as follows:

import Layer from '@layerhq/web-xdk';
const register = Layer.UI.MessageActions.register;

register('vnd-open-pdf', function openPDFHandler({ data, model }) {
  if (model.source.url) {
    window.open(model.source.url);
  } else {
    // URLs can expire, and if that url is no longer available, we need to call
    // fetchStream to update the url so we can open it
    this.model.source.fetchStream(url => window.open(url));
  }
});

Note

Providing an Action for this Message Type is done to explain how this is done. This particular PDF renderer which allows users to page and zoom by clicking on the document is not an ideal use case for when you’d provide a defaultAction like this.

Step 7: Wrap up
import { Core } from '@layerhq/web-xdk';
const { Client, MessagePart, Root, MessageTypeModel } = Core;

class PDFModel extends MessageTypeModel {
  generateParts(callback) {
    const body = this.initBodyWithMetadata(['title', 'author']);

    this.part = new MessagePart({
      mimeType: this.constructor.MIMEType,
      body: JSON.stringify(body),
    });

    // Replace the File/Blob source property with a proper MessagePart property.
    this.source = new MessagePart(this.source);

    // Setup this Message Part to be a Child Message Part within the Message Part Tree
    this.addChildPart(this.source, 'source');

    callback([this.part, this.source]);
  }

  parseModelChildParts({ changes, isEdit }) {
    super.parseModelChildParts({ changes, isEdit });

    // Setup this.source to refer to the MessagePart whose role=source
    this.source = this.childParts.filter(part => part.role === 'source')[0];
  }

  getTitle() { return this.title || '' }
  getDescription() { return ''; }
  getFooter() { return this.author || ''; }

  getOneLineSummary() {
    return this.title || 'PDF File';
  }
}

PDFModel.prototype.source = null;
PDFModel.prototype.author = '';
PDFModel.prototype.title = '';

// Static property specifies the preferred Message Type View for representing this Model
PDFModel.messageRenderer = 'vnd-customco-pdf-message-type-view';

// Static property defines the MIME Type that will be used when creating new Messages from this Model
PDFModel.MIMEType = 'application/vnd.customco.pdf+json';

PDFModel.defaultAction = 'vnd-open-pdf';

Core.Root.initClass.apply(PDFModel, [PDFModel, 'PDFModel']);
Client.registerMessageTypeModelClass(PDFModel, 'PDFModel');

export default PDFModel;

The PDF Message Type View

A Message Type View is a type of Webcomponent we will create using the Layer XDK Framework; it uses the MessageViewMixin Mixin to properly setup the View and its properties. Among other things, the Mixin defines the model property, and insures that any time the model is modified, the Model’s change events are wired up to call the View’s onRerender() method.

This viewer looks a lot like the Opinion Message Type View, but has a very different template property and onRerender() method.

Step 1: Quick Start

Lets get things setup using what we learned in the Opinion Message Type View, and setup a basic starting point that works with the Standard Message View Container

import Layer from '@layerhq/web-xdk';
const registerComponent = Layer.UI.registerComponent;
const MessageViewMixin = Layer.UI.mixins.MessageViewMixin;
const Widths = Layer.UI.Constants.WIDTH;

registerComponent('vnd-customco-pdf-message-type-view', {
  mixins: [MessageViewMixin],

  // Every UI Component must define an initial display style
  style: `vnd-customco-pdf-message-type-view {
    display: block;
    width: 100%;
    height: 400px;
  }
  `,

  properties: {
    widthType: {
      // Go for as wide of a PDF viewer as we can
      value: Widths.FULL
    },
    messageViewContainerTagName: {
      noGetterFromSetter: true,
      value: 'layer-standard-message-view-container'
    },
  },
  methods: {
    onRender() {
    },
    onRerender() {
    },
  }
});
Step 2: Render the PDF

For rendering this Message we will need to add a template and an onRender method. We use onRender here instead of onRerender as we do not envision updates being made to the PDF document after its been sent.

registerComponent('vnd-customco-pdf-message-type-view', {
  template: `<object layer-id="pdf" type="application/pdf" width="100%" height="100%">
    <a layer-id="fallback">Download PDF</a>
  </object>`,

  methods: {
    onRender() {
      if (this.model.source.url) {
        this.nodes.pdf.data = this.model.source.url;
        this.nodes.fallback.href = this.model.source.url;
      } else {
        this.model.source.fetchStream(this.onRender.bind(this));
      }
    },
    onRerender() {
    }
  }
});

Note that if the Model’s source MessageParturl property is null, then the Cloud Storage URL has expired, and must be refreshed with a call to MessagePartfetchStream().

Pie Chart Example

Goals: Introduces Sub-Models, Sub-Message-Viewers and using a Titled Message View Container (instead of the Standard Message View Container)

While the PDF Message Example above contains simple rendering and leaves the rest of its rendering to the Standard Message View Container, more sophisticated messages will need to do all of their own rendering (though for this example we leave rendering the Title Bar of the message to the Titled Message View Container). This example will illustrate:

The Pie Chart Message will have the following properties:

  • title: The title of the document

The Pie Chart Message will have a Sub Model that is a File Message that contains an application/csv file.

Note

The PDF Example above could also have used a File Message for its PDF file; instead it used a raw application/pdf MessagePart solely for purposes of illustrating how to use a raw Message Part without an accompanying Message Type Model.

A sample Pie Cart Message with a File Message sub-message in the bottom right corner

Pie Message Type Model

This Message Type Model introduces one new concept: a Sub Model. Note that while the PDF Message Type had a Sub Message Part, it used a MessagePart for raw data. This Custom Message Model uses a MessagePart that will be represented by a FileMessageModel, and which can be rendered AS a File Message.

This model differs from the PDF Model:

  • Adding Submodels requires those models be handled as part of of generating a Message in generateParts
  • Adding Submodels requires those models to be imported from parseModelChildParts
  • Not using a Standard Message View Container means we do not need getTitle(), getDescription() and getFooter()
  • The model is responsible for loading and parsing its data (in this case CSV data)

Note that a Pie Model can be created as follows:

var model = new PieChartModel({
  title: "Eat Pie?",
  fileModel: new FileModel({
    source: CSVBlob
  })
});
import { Core } from '@layerhq/web-xdk';
const { Client, MessagePart, Root, MessageTypeModel } = Core;

class PieChartModel extends MessageTypeModel {
  generateParts(callback) {
    const body = this.initBodyWithMetadata(['title']);

    this.part = new MessagePart({
      mimeType: this.constructor.MIMEType,
      body: JSON.stringify(body),
    });

    const parts = [this.part];
    this.addChildModel(this.fileModel, 'csv', (newParts) => {
      newParts.forEach(p => parts.push(p));

      // Parse the CSV data from our input data
      this._parseCSV(this.fileModel);

      callback(parts);
    });
  }

  parseModelChildParts({ changes, isEdit }) {
    super.parseModelChildParts({ changes, isEdit });

    // Set the fileModel property to point to the csv File Model
    this.fileModel = this.getModelsByRole('csv')[0];

    this._parseCSV(this.fileModel);
  }

  // getSourceBody: fetches Rich Content and populates the MessagePart body if its unset.
  _parseCSV(fileModel) {
    fileModel.getSourceBody((body) => {
      const oldData = this.data;
      this.data = body.split(/\n/).map(line => line.split(/\s*,\s*/));

      // Replace string data that results from splitting a string with numerical data,
      // omitting headers
      this.data.forEach((row, index) => {
        if (index) this.data[index] = row.map((value, index) => index ? Number(value) : value);
      });
      this._triggerAsync('message-type-model:change', {
        property: 'data',
        newValue: this.data,
        oldValue: oldData
      });
    });
  }

  getOneLineSummary() {
    return 'Its a Pie Chart';
  }
}

PieChartModel.prototype.fileModel = null;
PieChartModel.prototype.title = '';
PieChartModel.prototype.data = null;

// Static property specifies the preferred Message Type View for representing this Model
PieChartModel.messageRenderer = 'vnd-customco-pie-chart-message-type-view';

// Static property defines the MIME Type that will be used when creating new Messages from this Model
PieChartModel.MIMEType = 'application/vnd.customco.pie+json';

Core.Root.initClass.apply(PieChartModel, [PieChartModel, 'PieChartModel']);
Client.registerMessageTypeModelClass(PieChartModel, 'PieChartModel');

MessagePart.TextualMimeTypes.push('text/csv');

export default PieChartModel;

Some utilties are used here:

  1. When the app calls either MessageTypeModelsend() or MessageTypeModelgenerateMessage() those methods will call MessageTypeModeladdChildModel(). MessageTypeModeladdChildModel() takes an input MessageTypeModel, calls generateParts() on it to gather its MessagePart objects, and adds them to the Message Part Tree. In this case MessageTypeModeladdChildModel() is told to assign MessageTypeModelrole to be csv while adding the CSV file to the Message Part Tree.
  2. MessageTypeModelparseModelChildParts() calls MessageTypeModelgetModelsByRole()) to find the Child Model whose MessageTypeModelrole is csv
  3. MessageTypeModelparseModelChildParts() and MessageTypeModelgenerateParts() setup the data property with its CSV data so that the Pie Message View can get the data and render it.
  4. MessagePart needs to be updated to understand that application/csv is a textual MIME Type and not a Blob. This is done by registering the MIME Type with the static MessagePartTextualMimeTypes property.

The Pie Chart Message Type View

This Message Type View is much like ones we’ve seen before, except that:

This viewer looks a lot like the Opinion Message Type View, but has a very different template property and onRerender() method.

Step 1: Quick Start

Lets get things setup using what we learned in the Opinion Message Type View, and setup a basic starting point that works with the Titled Message View Container:

import Layer from '@layerhq/web-xdk';
import './pie-chart-message-type-model';

const registerComponent = Layer.UI.registerComponent;
const MessageViewMixin = Layer.UI.mixins.MessageViewMixin;
const Widths = Layer.UI.Constants.WIDTH;

registerComponent('vnd-customco-pie-chart-message-type-view', {
  mixins: [MessageViewMixin],
  template: `
    <div class='vnd-customco-pie' layer-id='pie'></div>
    <layer-message-viewer layer-id='viewer'></layer-message-viewer>
  `,

  // Every UI Component must define an initial display style
  style: `vnd-customco-pie-chart-message-type-view {
    display: block;
    height: 250px;
  }
  vnd-customco-pie-chart-message-type-view .vnd-customco-pie {
    height: 100%;
  }
  vnd-customco-pie-chart-message-type-view > layer-message-viewer {
    position: absolute;
    bottom: 0px;
    right: 0px;
    width: 50px !important;
    min-width: 50px !important;
    height: 70px;
  }
  `,

  properties: {

    // Fill out to use available width; normally we would use FLEX width and let
    // the UI component sort out its preferred width based on its content,
    // but this content is loaded asynchronously and adapts to whatever size
    // is available
    widthType: {
      value: Widths.FULL
    },

    // Wrap this UI in a Titled Message View Container
    messageViewContainerTagName: {
      noGetterFromSetter: true,
      value: 'layer-titled-message-view-container'
    }
  },
  methods: {

    // Get the CSS Class for a title bar icon, used by <layer-titled-message-view-container />
    getIconClass() {
      return 'layer-poll-message-view-icon';
    },

    // Get the Title Text for a title bar, used by <layer-titled-message-view-container />
    getTitle() {
      return this.model.title || 'Pie Chart';
    },

    // Take care of any DOM setup that should be done before any properties are set
    // and setterse are called.
    onCreate() {
      // Disable wrapping this File Message in a Standard Message Container View
      // which would add a title and other text below the file.  Set this before any properties
      // are assigned and any rendering is done
      this.nodes.viewer.messageViewContainerTagName = '';

      // No borders around the sub-message-viewer
      this.nodes.viewer.cardBorderStyle = 'none';
    },

    // Basic setup to be done after properties are all available
    onAfterCreate() {
      // Pass the File Model to the Viewer for it to render
      this.nodes.viewer.model = this.model.fileModel;
    },

    // Note that the change event in the Model's _parseCSV method
    // will automatically cause onRerender to be called. This is also
    // called automatically after onRender completes.
    onRerender() {
      this._renderPieData();
    },

    // Saved for next step
    _renderPieData() {
    }
  }
});

Working with a Titled Message View Container requires this View to provide:

  • getIconClass() which returns a CSS class that you will need to configure to load a suitable icon for your title bar
  • getTitle() which will return text for the title bar. This text can come from the Model, or be hard coded text chosen by your UI Component.

Working with a Sub Message Viewer allows us to leave the rendering of any Sub Model to the Viewer, and requires us to:

  1. (Optional) Override the sub-viewer’s default use of a Standard Message View Container by setting handlers.message.MessageViewermessageViewContainerTagName on the sub-viewer. This should be done in onCreate after the DOM structure is generated but before the model property is assigned, and subviewers generated.
  2. (Optional) Override the sub-viewer’s default use of a surrounding border. A surrounding border for a sub-viewer is good when the sub-viewer is a Carousel Item, but not desirable when trying to present the sub-viewer’s content as a part of the parent Message. Again, this is done in onCreate before any properties are assigned or rendering is done.
  3. Provide the sub-model to the MessageViewer; this is done in onAfterCreate as all properties should be assigned by thi spoint.

Everything this Message does with its subviewer (rendering a file icon, and allowing the user to click on that file to download the CSV file) could have been done by reimplementing features provided by the File Message. This custom message takes advantage of the fact that the capabilities are already implemented in a separate message.

Note

This example should benefit from the File Model’s support for clicking to download CSV content; however, sample CSV may well be small enough that it does not get stored on a remote server (See Rich Content/External Content) and therefore will not at this time actually download the CSV file.

Step 2: Rendering the Chart

One could put <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script> into your HTML file. Alternatively, you could simply insure it gets loaded by anyone who imports your Custom Message Component:

import Layer from '@layerhq/web-xdk';
import './pie-chart-message-type-model';

// Insure that the google charting library is loaded
const script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'https://www.gstatic.com/charts/loader.js';
document.head.appendChild(script);

registerComponent('vnd-customco-pie-chart-message-type-view', {
  ...
});

Complicating matters… we need to:

  1. Wait for google’s library to load
  2. Use google’s library to request it load its Visualization library
  3. Wait for the Visualization Library
  4. Render the chart
registerComponent('vnd-customco-pie-chart-message-type-view', {
  methods: {
    _renderPieData() {
      /* eslint-disable */

      // Make sure that the google visualization library has loaded
      if (typeof google === 'undefined') {
        google.charts.setOnLoadCallback(this._renderPieData.bind(this));
      }

      // If the visualization library hasn't yet loaded, make sure its loaded, and then wait until its ready
      else if (!google.visualization || !google.visualization.PieChart) {
        if (!this.properties.loadCalled) {
          google.charts.load('current', {'packages':['corechart']});
          this.properties.loadCalled = true;
        }
        setTimeout(this._renderPieData.bind(this), 500);
      }

      // If ready to draw, then instantiate google's chart and pass it the data
      else {
        this._drawPieData();
      }
    },

    // Adapted from https://google-developers.appspot.com/chart/interactive/docs/gallery/piechart
    _drawPieData() {
      if (!this.properties.chart) this.properties.chart = new google.visualization.PieChart(this.nodes.pie);
      const data = google.visualization.arrayToDataTable(this.model.data);
      this.properties.chart.draw(data, {});
    }
  }
});

Note

Google may have better best practices than this for using their charts.

Finally: if the charting library is already loaded, it may try rendering its data before this View has been inserted into the document; that means the view does not yet have dimensions. This results in less than optimal rendering. So we add one more method: onAttach().

registerComponent('vnd-customco-pie-chart-message-type-view', {
  methods: {
    // Note that the change event in the Model's _parseCSV method
    // will automatically cause onRerender to be called. This is also
    // called automatically after onRender completes.
    onRerender() {
      this._renderPieData();
    },

    // The UI does not know its size until this method is called;
    // use this opportunity to rerender the chart
    onAttach() {
      this._renderPieData();
    },
  }
});

Signature Message Example

Goals: Introduce a Message that enacts a simple workflow between users where state of the workflow is shared among all participants

The Signature Message will require the following properties:

  • subModel: A Sub MessageTypeModel that represents the “thing” that is being signed for. While typically this would be an actual Product Message Model, this should accept any MessageTypeModel as users may be asked to sign for something other than a Product. “Sign to indicate you have seen this Agreement” accompanied by a File Message Model for example.
  • signatureId: A unique identifier from the signature service once the user has signed the the Message
  • label: A textual description of what it is that the user is being asked to sign
  • signatureEnabledFor: An Identity ID indicating which participant in the Conversation is permitted to sign this.

This example will also introduce the Response Message for sharing state across clients and users.

Signature Message Type Model

This Message Type Model introduces one new concept: Sending and Receiving Response Messages.

The Signature Model is created as follows:

var model = new SignatureModel({
  label: "Please sign to indicate that you have read this agreement",
  subModel: new FileModel({
    sourceUrl: 'https://github.com/layerhq/web-xdk/blob/master/LICENSE'
  }),
  signatureEnabledFor: 'layer:///identities/FrodoTheDodo'
});
Step 1: The Quick Start

The Signature Model definition starts from something not too different from the Pie Chart Message Type Model:

import { Core } from '@layerhq/web-xdk';
const { Client, MessagePart, Root, MessageTypeModel } = Core;

class SignatureModel extends MessageTypeModel {
  // Generate the Message Parts needed to represent this Model and all Sub Models
  generateParts(callback) {
    // Note that only simple values can be initialized this way,
    // and only values that should have initial values;
    // thus `signatureId` is Not here (document should not be signed yet)
    // and `subModel` is not a simple value.
    const body = this.initBodyWithMetadata(['label', 'signatureEnabledFor']);

    // Create the MessagePart using the static MIMEType property defined below
    this.part = new MessagePart({
      mimeType: this.constructor.MIMEType,
      body: JSON.stringify(body),
    });

    const parts = [this.part];

    // Gather all Message Parts needed to represent the submodel and add them to parts.
    this.addChildModel(this.subModel, 'resource', (newParts) => {
      newParts.forEach(p => parts.push(p));
    });

    callback(parts);
  }

  parseModelChildParts({ changes, isEdit }) {
    super.parseModelChildParts({ changes, isEdit });
    this.subModel = this.getModelsByRole('resource');
  }

  getOneLineSummary() {
    return this.signatureId ? 'Signature Completed' : 'Signature Requested';
  }
}

SignatureModel.prototype.label = '';
SignatureModel.prototype.subModel = null;
SignatureModel.prototype.signatureId = '';
SignatureModel.prototype.signatureEnabledFor = '';


// Static property defines the MIME Type that will be used when creating new Messages from this Model
SignatureModel.MIMEType = 'application/x.customco.signature+json';

// Static property specifies the preferred Message Type View for representing this Model
SignatureModel.messageRenderer = 'signature-view';

// Register the class, setting up all property setters/getters/event handling, etc...
Root.initClass.apply(SignatureModel, [SignatureModel, 'SignatureModel']);

// Register the Message Type Model Class with the Client; this allows this SignatureModel to be found and used by the Message Viewer
Client.registerMessageTypeModelClass(SignatureModel, 'SignatureModel');

export default SignatureModel;

The starting point for setting up this Model involves:

  1. generateParts: writing basic properties to the model’s Message Part and generating Message Parts for the Sub Models
  2. parseModelChildParts: Imports the submodel into the subModel property
  3. getOneLineSummary: Generate some sort of text to show in a Conversation List to represent this Message/Model
Step 2: Managing State through Response Messages

A Response Message can be sent containing a textual description of a change (shown to all participants in the conversation) and a set of state changes that are to be performed upon the message shown in all Clients. A Message Model will register the states that it sends/listens for, and call parseResponseModel each time it changes.

In detail, each Response Message causes:

  1. The server to update a Message Part in the Message that the user responded to with that user’s changes to the state
  2. Each Client gets notified that they have a new/updated Response Summary Message Part
  3. Each Client’s Message Type Model applies those change to its state and then triggers change events
  4. Each Client’s UI can render those state changes

All state changes are indexed by the Identity ID of the user who sent the Response Message; which means that any access to state must be done using the correct Identity.

To avoid ambiguity about which users are allowed to edit which response properties, typically an enabledFor or signatureEnabledFor property is provided that removes any ambiguity over which user is expected and allowed to manipulate that one state variable.

Responses to the Message via the “Response Message” are all automatically written to the MessageTypeModelresponses property.

Continuing the Signature Message Example, we need to provide the following APIs:

  • A MessageTypeModelregisterAllStates() method which will declare what state values are going to be sent or received
  • A MessageTypeModelparseModelResponses() method which is automatically called whenever the Response Data has changed
  • A method for validating a signature and getting a signatureId from the server
  • A method for sending a Response Message that adds the signatureId to the Signature Message

Registering State

This Message Type Model will share only a single State: signature_id. It must declare that as a state variable as well as what rules will be used to understand it:

  • Layer.Constants.CRDT_TYPES.FIRST_WRITER_WINS: A write once state value; if multiple devices write it at the same time, only the first writer wins.
  • Layer.Constants.CRDT_TYPES.LAST_WRITER_WINS: A state variable that can change many times, but can not be cleared
  • Layer.Constants.CRDT_TYPES.LAST_WRITER_WINS_NULLABLE: A state variable that can change many times, and can have all values cleared
  • Layer.Constants.CRDT_TYPES.SET: A state variable that represents a set of values instead of a single value
class SignatureModel extends MessageTypeModel {
  registerAllStates() {
    this.registerState('signature_id', Layer.Constants.CRDT_TYPES.FIRST_WRITER_WINS);
  }
}

The MessageTypeModelregisterAllStates() is a Message Type Model lifecycle method that will always be called during initialization.

Parsing the State

The MessageTypeModelparseModelResponses() is called any time your Message’s Response Summary has been updated with new response data. In this case we use it to check for any changes to the signatureId and set our signatureId property.

Note

This method is also called during your Message Type Model’s intialization if the Message has had a Response to it in the past.

class SignatureModel extends MessageTypeModel {
  parseModelResponses() {
    const signatureId = this.responses.getState('signature_id', this.signatureEnabledFor]);
    if (signatureId !== this.signatureId) {
      this._triggerAsync('message-type-model:change', {
        property: 'signatureId',
        newValue: signatureId,
        oldValue: this.signatureId,
      });
      this.signatureId = signatureId;
    }
  }
}

Sending the Signature

State is sent using this.responses.addState(stateName, value). Calling this will cause a Response Message to be created (or data added to a queued Response Message) and will ultimately lead to all participants seeing the state change.

The Response Message’s text is set using this.responses.setResponseMessageText as the Response Message is rendered separately from the updates to the Message. The Response Message describes the change to all participants, so that even if the message is scrolled out of view, the change is known and understood.

class SignatureModel extends MessageTypeModel {
  // Send the Response Message and update local state
  sendSignature(signatureId) {
    this.responses.setResponseMessageText(`Layer.client.user.displayName} has signed`);
    this.responses.addState('signature_id', signatureId);
    this.responses.sendResponseMessage();

    // Trigger a standard change event for the UI to use to rerender
    const oldId = this.signatureId;
    this.signatureId = signatureId;
    this._triggerAsync('message-type-model:change', {
      property: 'signatureId',
      newValue: this.signatureId,
      oldValue: oldId,
    });
  }
}

The rest

The remaining details are just mechanics of our hypothetical Signature Service where the signature is sent to the server and comes back with a unique Signature ID:

class SignatureModel extends MessageTypeModel {

  // Get a signature_id from the server
  signDocument(signatureImage) {
    xhr({
      url: signatureValidationService,
      data: signatureImage,
      onSuccess => (result) {
        this.sendSignature(result.signature_id);
      }
    });
  }
}

Any time a participant signs the Message, the following sequence is executed:

  1. signDocument is called by the UI
  2. signDocument validates the signature and gets back a signatureId (a value that validates that the document is signed)
  3. sendSignature is called which creates a Response Model that once sent, will start the process of adding the specified signature_id to the Signature Message for all participants
  4. sendSignature updates the local model’s signatureId value
  5. sendSignature triggers a change event so that the View can rerender with the new state
  6. A backend service called the Message Response Integration Service will update the Signature Message with the new signature_id
  7. Each participant will receive an update to that message which will cause each model’s model.responses to be updated to include the latest Message Response data.
  8. Each participant’s parseModelResponses() method will be called; it is responsible for knowing what responses are possible, and extracting them and setting the model’s signatureId property value.
  9. A change event is triggered when the signatureId changes so that any Views can be updated (the user who signed it already has an up-to-date state and will not see this extra change event)

Signature Message View

import Layer from '@layerhq/web-xdk';
const registerComponent = Layer.UI.registerComponent;
const MessageViewMixin = Layer.UI.mixins.MessageViewMixin;

// Register this UI Component with Layer's XDK-UI Framework
registerComponent('signature-view', {
  mixins: [MessageViewMixin],
  templates: `
    <div class='signature-label' layer-id='label'></div>
    <layer-message-viewer layer-id='submessage'></layer-message-viewer>

    <!-- A div where the actual signature widget goes -->
    <div class='signature-custom-input' layer-id='customerInput'>
      <div class='signature-pad' layer-id='signature'></div>
    </div>

    <!-- A div for the user who is waiting for rather than giving a signature -->
    <div class='signature-sender-waiting' layer-id='senderWaiting'>
      Waiting for customer to sign
    </div>

    <!-- A div for the all users if a signature has been accepted -->
    <div class='signature-complete' layer-id='senderComplete'>
      Signature Accepted!
    </div>
  `,
  style: `
    signature-view {
      display: block;
    }
    signature-view .signature-sender-waiting {
      display: block;
    }
    signature-view .signature-custom-input {
      display: none;
    }
    signature-view .signature-complete {
      display: none;
    }
  `,
  properties: {
    signatureUtil: {},
  },
  methods: {
    onCreate() {
      this.signatureUtil = new ThirdPartySignatureLibrary({
        node: this.nodes.signature,
        signature: this.model.signatureId || null,
        onSigned: signatureImage => this.model.signDocument(signatureImage)
      });
      this.updateTabs();
    },

    onRender() {
      this.nodes.label.innerHTML = this.model.label;
      this.nodes.submessage.model = this.model.subModel;
    },

    onRerender() {
      this.updateTabs();
    }

    updateTabs() {
      if (this.model.signatureId) {
        this.nodes.senderWaiting.style.display = 'none';
        this.nodes.customerInput.style.display = 'block'; // Show the signature
        this.nodes.senderComplete.style.display = 'block'; // Show the completion message
      } else if (this.model.message.sender !== this.client.user) {
        this.nodes.senderWaiting.style.display = 'none';
        this.nodes.customerInput.style.display = 'block';
        this.nodes.senderComplete.style.display = 'none';
      } else {
        this.nodes.senderWaiting.style.display = 'block';
        this.nodes.customerInput.style.display = 'none';
        this.nodes.senderComplete.style.display = 'none';
      }
  }
});

The above example makes some simplifying assumptions:

  1. There is some third party signature library being used to collect the signature and transform it into an image
  2. That library takes as input the DOM node
  3. That library takes as input an optional signatureId for a prior signature that is to be rendered
  4. That library takes as input an onSigned callback to be called when the signature has been completed

Status Messages

Typical Messages are rendered as any other message: with a timestamp, avatar, sent/delivered/read indicators, etc…

What if you want to be able to send a Message from any user, and have all users see that message simply show as a status message without any Avatar, Sender or timestamp? You might start by looking at the default Status Message. But if you need something more than simple text as your Status Message, then you may define your own Message Type and register it to be presented as a Status Message (presented without Avatar, timestamp, etc…).

The following status message presents a large “Process Completed” stamp that users can click on for details.

Status Message Example: Process Completed Model

import { Core, UI } from '@layerhq/web-xdk';
const { Client, MessagePart, Root, MessageTypeModel } = Core;

class ProcessCompletedModel extends MessageTypeModel {

  generateParts(callback) {
    const body = this.initBodyWithMetadata(['processId']);

    this.part = new MessagePart({
      mimeType: this.constructor.MIMEType,
      body: JSON.stringify(body),
    });

    callback([this.part]);
  }

  getOneLineSummary() {
    return 'Process Completed';
  }
}

ProcessCompletedModel.prototype.processId = '';

// Static property defines the MIME Type that will be used when creating new Messages from this Model
// This MIME Type represents some sample mimetype produced by some hypothetical company
ProcessCompletedModel.MIMEType = 'application/vnd.customco.processcompleted+json';

// Static property specifies the preferred Message Type View for representing this Model
ProcessCompletedModel.messageRenderer = 'process-completed-view';

// Register the class, setting up all property setters/getters/event handling, etc...
Root.initClass.apply(ProcessCompletedModel, [ProcessCompletedModel, 'ProcessCompletedModel']);

// Register the Message Type Model Class with the Client; this allows this ProcessCompletedModel to be found and used by the Message Viewer
Client.registerMessageTypeModelClass(ProcessCompletedModel, 'ProcessCompletedModel');

// Register this Message Type to be handled as a Status Message
UI.registerStatusModel(ProcessCompletedModel);

export default ProcessCompletedModel;

Key to the above is the call to Layer.UI.registerStatusModel(ProcessCompletedModel) which tells the UI to treat Messages driven by the above Model differently from other Messages.

Building a View for the above model is left as an exercise; just note that your view will be shown centered in the Message List rather than on the right or left edge of the Message List.

Concepts API Reference