Skip to main content

Embedded Webview

info

This document assumes you know the basic usage of Next.js and React.js. You can learn more about them here:

  • React.js - A JavaScript library for building user interfaces.
  • Next.js - The React Framework for Production.

Chat UI brings a new way to communicate with users, but it cannot totally replace GUI. For the features that require precise control, instant interactions and richer displays, GUI is still a better choice.

The best practice we suggest is a hybrid experience that combines the advantages of both. While a chatbot is easier to access, we can extend a webview to ship more amazing features in GUI.

Webview Platform

@machinat/webview platform serves embedded webviews in the chat. It does these three things in the background:

  1. Host a web app with Next.js.
  2. Log in users with their chat platforms account.
  3. Connect a WebSocket to communicate with the server.

Install With Creator

If you are creating a new project with our app creator, add a --webview flag and everything will be set up. Like this:

npm init @machinat/app@latest -- -p telegram --webview my-project
info

Check Manually Install section for step-by-step setup guide.

Open the Webview

The chat platforms may provide some special components to open the webview in the chatroom. For example:

import * as Messenger from '@machinat/messenger/components';
import { WebviewButton as MessengerWebviewButton } from '@machinat/messenger/webview';

<Messenger.ButtonTemplate
buttons={
<MessengerWebviewButton title="Open Webview ↗️" />
}
>
Hello World!
</Messenger.ButtonTemplate>

With the codes above, the webview will be opened in the Messenger app when the WebviewButton is tapped. Check the document of each platform for the details.

Determine the platform

Sometimes you might want to decide which platform to log in, for example, when a user opens the web page in the browser directly. It's determined in this order:

  1. The platform option while constructing WebviewClient. For example:
const client = new WebviewClient({
platform: 'line',
// ...
});
  1. The platform querystring param on the URL. Like:
https://my.machinat.app/webview?platform=messenger
  1. The platform that already logged in.

Notice that some platforms only support opening webviews from the chatroom, like Messenger.

Webview Client

In the front-end, the WebviewClient handles the login flow and the communication to the server. It can be constructed like this:

import WebviewClient from '@machinat/webview/client';
import MessengerAuth from '@machinat/messenger/webview/client';

const client = new WebviewClient({
authPlatforms: [MessengerAuth],
});

You have to add the chat platforms to log in with at authPlatforms. The client will sign in the user and connect to the server after constructed.

useClient Hook

If you are using React.js in front-end, use the useClient hook to create a client in the lifetime of a component. Like this:

import { useClient } from '@machinat/webview/client';
import MessengerAuth from '@machinat/messenger/webview/client';

export default function MyApp() {
const client = useClient({
authPlatforms: [MessengerAuth],
});

const sayHello = () => {
client.send({ type: 'hello', payload: 'world' });
};

return (
<div>
<h1>Hello World</h1>
<button onClick={sayHello}>hello</button>
</div>
);
}

Receive Events from Server

On the client-side, you can use client.onEvent(listener) to subscribe events from the server. Like this:

client.onEvent(({ event }) => {
if (event.type === 'connect') {
// handle connect
} else if (event.type === 'app_data') {
// handle app data
}
});

The listener receive an event context object with following info:

  • event - object, event object.

    • platform - 'webview'.
    • category - string, event category.
    • type - string, event type.
    • user - object, the logged-in user
    • channel - object, the connection to the server.
  • auth - object, auth info.

    • platform - string, authenticating platform.
    • user - object, the logged-in user.
    • channel - object, the chat where the user comes from.
    • loginAt - Date, the logged-in time.
    • expireAt - Date, the time when authorization expires.
    • data - any, raw auth data from chat platform.
  • authenticator - object, the authenticator instance of the authenticated platform.

connect and disconnect

Two system events will be received when the connection status is changed:

  • connect - received when the connection is connected.

    • category - 'connection'.
    • type - 'connect'.
    • payload - null.
  • disconnect - received when the connection is disconnected.

    • category - 'connection'.
    • type - 'disconnect'.
    • payload - object.
      • reason - undefined | string, reason for disconnect.

Send Event on Client-Side

Use client.send(eventObj) method to send an event to the server. For example:

client.send({
type: 'greeting',
payload : '👋',
});

The eventObj take these properties:

  • category - optional, string, set to 'default' if not specified.
  • type - required, string, the event type.
  • payload - optional, any, the value will be serialized and sent to the server.

You don't have to wait for 'connect' to send events. The events sent before it are queued and delivered after it's connected.

useEventReducer Hook

useEventReducer hook provides a convenient way to handle events in a React component (e.g. a Next.js page). For example, an app can display data from the server like this:

import WebviewClient, { useEventReducer } from '@machinat/webview/client';
// ...

export default function Home() {
const { color, content } = useEventReducer(
client,
(data, { event }) => {
if (event.type === 'app_data') {
return event.payload;
}
if (event.type === 'color_updated') {
return { ...data, color: event.payload.color };
}
return data;
},
{ color: '#000', content: 'loading...' }
);

return (
<main>
<input
type="color"
value={color}
onChange={(e) =>
client.send({
type: 'update_color',
payload: { color: e.target.value },
})
}
/>
<div style={{ textColor: color }}>Content: {content}</div>
</main>
);
}

useEventReducer(client, reducer, initialState) takes a reducer function of type (state, eventContext) => newState. Everytime an event is received, the reducer is called to update the new state. It's useful to maintain the real-time app data.

Webview Platform

On the server side, the @machinat/webview platform need to be registered in your app. Like this:

import Machinat from '@machinat/core';
import Http from '@machinat/http';
import Webview from '@machinat/webview';
import TelegramAuth from '@machinat/telegram/webview';
import nextConfig from '../webview/next.config.js';

const app = Machinat.createApp({
modules: [
Http.initModule({/* ... */}),
],
platforms: [
Webview.initModule({
webviewHost: 'your.domain.com',
authSecret: '_secret_string_to_sign_token_',
authPlatforms: [TelegramAuth],
nextServerOptions: {
dev: process.env.NODE_ENV !== 'production',
dir: `./webview`,
conf: nextConfig,
},
}),
],
});

The authPlatforms should correspond to the client settings. Add the auth providers from all the platforms that requires webviews. More options can be found here.

Receive Events from Clients

On the server-side, events from the client are received as ordinary event context. For example:

app.onEvent(async ({ platform, event, bot }) => {
if (platform === 'webview') {
if (event.type === 'connect') {
const { color } = await getUserState(event.user);

return bot.send(event.channel, {
type: 'app_data',
payload : { color, content: 'Hello Webview' },
});
}

if (event.type === 'update_color') {
await updateUserState(event.user, event.payload.color);

return bot.send(event.channel, {
type: 'color_updated',
payload: { color: event.payload },
});
}
}
});

The webview event context contains the following info:

  • platform - 'webview'.

  • bot - object, the webview bot.

  • event - object, event object.

    • platform - 'webview'.
    • category - string, event category.
    • type - string, event type.
    • user - object, the logged-in user.
    • channel - object, the connection to the client.
  • metadata - object, meta info about the connection.

    • source - 'websocket'.
    • request - object, http upgrade request info.
    • connection - object, identical to event.channel.
    • auth - object, auth info, identical to context.auth in client-side.
      • platform - string, authenticating platform.
      • user - object, the logged-in user.
      • channel - object, the chat where the user comes from.
      • loginAt - Date, the logged-in time.
      • expireAt - Date, the time when authorization expires.
      • data - any, raw auth data from chat platform.

The 'connect' and 'disconnect' events are emitted on server-side too when the status of a connection has changed.

Send Event to the Client

bot.send(connection, eventObj) method sends an event back to the client. It takes the same event object as client.send(eventObj).

await bot.send(event.channel, {
category: 'event_category',
type: 'event_type',
payload: { some: 'serializable content' }
});

Note that the sending promise sometimes resolves even if the delivery fails (e.g. client is offline). You can tell whether it succeed like this:

const result = await bot.send(event.channel, {
type: 'foo',
payload: 'bar',
});
if (result.connections.length === 0) {
console.log('sending is not completed');
}

Broadcast by a Topic

In some cases you might need to broadcast an event to many connections. For example, to make a multi-players game.

A connection can subscribe to a topic with bot.subscribeTopic. Like:

await bot.subscribeTopic(event.channel, 'topicName');

Then you can send events to all the connections that subscribe to a topic with bot.sendTopic. Like:

const result = await bot.sendTopic('topicName', {
type: 'game_start',
payload: { game: 'data' },
});

To put them together, this example let users say hello on a global topic:

app.onEvent(async ({ event, bot }) => {  
if (event.type === 'connect') {
return bot.subscribeTopic(event.channel, 'world');
}

if (event.type === 'hello') {
const result = await bot.sendTopic('world', {
type: 'hello',
payload: event.payload,
});

console.log(`hello to ${result.connections.length} connections`);
}
});

To unsubscribe a topic, use bot.unsubscribeTopic like:

await bot.unsubscribeTopic(event.channel, 'topicName');

Interact With Chat

metadata.auth.channel refers to the chatroom where the user comes from. You can use it to provide features that extend the chatting experience.

With webviews, the bot can ship any features you can do in a web app. One common usage is filling complicated input in the webview, like selecting a location on the map.

Send Messages to Chat

To send messages back to the original chatroom, you can use BasicBot service like:

import Machinat, { BasicBot } from '@machinat/core';

app.onEvent(
makeContainer({ deps: [BasicBot] })(
(basicBot) =>
async ({ platform, metadata, event }) => {
if (platform === 'webview' && event.type === 'connect') {
await basicBot.render(
metadata.auth.channel,
<p>I see you on the webview!</p>
);
}
}
)
);

Manually Install

First install the following packages:

npm install react react-dom next @machinat/webview

Create Web App

Next you need a Next.js app to host the webview. You can create one with:

npx create-next-app@latest webview

Check Next.js document for more details.

Server-Side Setup

Then register the @machinat/webview platform to your app like this:

// src/app.js
import Machinat from '@machinat/core';
import Http from '@machinat/http';
import Webview from '@machinat/webview';
import Telegram from '@machinat/telegram';
import TelegramAuth from '@machinat/telegram/webview';
import nextConfig from '../webview/next.config.js';

const app = Machinat.createApp({
modules: [
// http module must be installed
Http.initModule({
listenOptions: { port: 8080 },
}),
],
platforms: [
Webview.initModule({
// hostname of your server
webviewHost: 'xxx.machinat.com',
// secret string for siging auth token
authSecret: '_some_secret_string_',
// authenticators from chat platforms
authPlatforms: [
TelegramAuth,
],
// Next.js server options
nextServerOptions: {
// to start server in dev mode or not
dev: process.env.NODE_ENV !== 'production',
// Next.js directory from project root
dir: `./webview`,
// require configs from next.config.js
conf: nextConfig,
},
}),
Telegram.initModule({/* ... */}),
],
});

The webview page should be available at / of your server now. You can check more platform options here.

authPlatforms on Server

To integrate with the chatroom, you have to add the supported authPlatforms to log in users. Conventionally, the providers are available at @machinat/<platform>/webview.

import MessengerAuth from '@machinat/messenger/webview';
import TelegramAuth from '@machinat/telegram/webview';
import LineAuth from '@machinat/line/webview';
// ...
Webview.initModule({
authPlatforms: [
MessengerAuth,
TelegramAuth,
LineAuth,
],
// ...
}),

Client-Side Setup

At the client-side, we can connect to the server using WebviewClient. For example:

// webview/pages/index.js
import WebviewClient from '@machinat/webview/client';
import TelegramAuth from '@machinat/telegram/webview/client';

const client = new WebviewClient({
// prevent connections while rendering on server-side
mockupMode: typeof window === 'undefined',
// authenticators from chat platforms
authPlatforms: [
new TelegramAuth(),
],
});

client.onError(console.error);

After the client is constructed, it'll do these two thing automatically:

  1. Log in user to the selected chat platform .
  2. Opens a WebSocket connection to the server.

You can check more client options here.

authPlatforms on Client

The supported authPlatforms also need to be added at the client. Check the guide of each platform for the details.

Get Settings from Server

If an authenticator require settings from server-side, use publicRuntimeConfig to pass it to the client. For example:

import MessengerAuth from '@machinat/messenger/webview/client';

// to activate publicRuntimeConfig
export const getServerSideProps = () => ({ props: {} });
// get runtime settings
const { publicRuntimeConfig } = getConfig();

const client = new WebviewClient({
authPlatforms: [
new MessengerAuth({
appId: publicRuntimeConfig.messengerAppId,
}),
],
});

Then add the setting in next.config.js like this:

module.exports = {
publicRuntimeConfig: {
messengerAppId: process.env.MESSENGER_APP_ID,
},
};