251 lines
8.7 KiB
Markdown
251 lines
8.7 KiB
Markdown
+++
|
|
title = "Creating a simple read-only Matrix client"
|
|
aliases = ["/docs/guides/creating-a-simple-read-only-matrix-client"]
|
|
+++
|
|
|
|
I created [matrix-enact] as a fun way to render Matrix rooms - it
|
|
essentially "performs" the room history by progressively speaking each message
|
|
event in chronological order. In this way, [matrix-enact] is effectively a
|
|
simple, read-only Matrix client. Let's see how it was built.
|
|
|
|
This article will introduce two important concepts in Matrix, specifically in
|
|
the [Matrix Client-Server API]:
|
|
|
|
* guest access
|
|
* the `/context` endpoint, which gets messages before and after a given event
|
|
|
|
## Not using the matrix-js-sdk
|
|
|
|
Although written in JavaScript (and Reactjs), this project does not use the
|
|
[matrix-js-sdk], it makes direct HTTP calls to the [Matrix Client-Server API].
|
|
Because there are only three endpoints we need to hit, we can keep the project
|
|
very light by not including an SDK.
|
|
|
|
## Get Guest access_token
|
|
|
|
Matrix allows for [guest access
|
|
](https://matrix.org/docs/spec/client_server/latest.html#guest-access) by
|
|
providing an interface to register a new guest user and be immediately given an
|
|
access token. To do this we call the `/register` endpoint with a query param
|
|
`kind` set to `guest`. In matrix-enact, this looks like:
|
|
|
|
```javascript
|
|
import axios from 'axios';
|
|
var url = "https://matrix.org/_matrix/client/r0/register?kind=guest";
|
|
const res = await axios.post(url, {});
|
|
const { data } = await res;
|
|
// data.access_token will contain the access token, we must store it
|
|
```
|
|
|
|
Once we have the access token, we use it in the same way as if logged in with a
|
|
normal user.
|
|
|
|
## Translate a Room Alias to a Room ID
|
|
|
|
In the UI, the user can enter either a room alias or a room ID. Whichever they
|
|
enter, to get message content from a room we need the ID. This means we need to
|
|
detect if an alias has been entered, and if so get the correct room ID for that
|
|
alias:
|
|
|
|
```javascript
|
|
// we know that if the first character is a '#', we have an alias not an id
|
|
if (this.state.roomEntry[0] === "#") {
|
|
var getIdUrl = "https://matrix.org/_matrix/client/r0/directory/room/";
|
|
getIdUrl += encodeURIComponent(this.state.roomEntry);
|
|
const res = await axios.get(getIdUrl);
|
|
const { data } = await res;
|
|
// data.room_id contains the room id for the alias
|
|
}
|
|
```
|
|
|
|
## `/context` endpoint
|
|
|
|
We use the `/context` endpoint to get chronological history of a room timeline.
|
|
|
|
Looking at [this section of the Client-Server API][context] we see:
|
|
|
|
> This API returns a number of events that happened just before and after the
|
|
specified event. This allows clients to get the context surrounding an
|
|
event.
|
|
|
|
To get messages from this endpoint we need to provide a room id and the event id
|
|
we want context for. Check out the comments in the code below to follow along.
|
|
|
|
```javascript
|
|
async loadScriptFromEventId(startEventId) {
|
|
// first we construct the url as per the CS API
|
|
const url = `https://matrix.org/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(startEventId)}?limit=100&access_token=${this.state.accessToken}`;
|
|
|
|
axios.get(url).then(res => {
|
|
// make an array to store the events from the response
|
|
var newEvents = [];
|
|
|
|
// we only want the events that follow our start events
|
|
newEvents = newEvents.concat(res.data.events_after);
|
|
|
|
// and we only want events that contain a body field, i.e. that are messages
|
|
newEvents = newEvents.filter(e => e.content.body);
|
|
|
|
// finally, since we're using React for this app,
|
|
// we store these messages in the state object
|
|
this.setState({events: this.state.events.concat(newEvents)});
|
|
});
|
|
}
|
|
```
|
|
|
|
## Loop until we have enough messages
|
|
|
|
Notice the previous URL we hit when calling `/context`. We specified a `limit`
|
|
value of `100`. In fact, `100` is usually the limit enforced by the homeserver.
|
|
This limit refers to the number of events, not the number of messages -
|
|
remember that we are filtering them in the code above.
|
|
|
|
If we say that we want our script to be 50 lines long, but after filtering we
|
|
are left with only 30 messages, what should we do? Get more events after the
|
|
latest one, and append the new events to our script. Knowing that we have taken
|
|
a value from the form to be stored in `state.messageCount`, and in the previous
|
|
section we inserted message events into `state.events`, we can compare these
|
|
two variables, and if needed, call `loadScriptFromEventId()` again with the
|
|
last known event.
|
|
|
|
```javascript
|
|
if (this.state.messageCount > this.state.events.length) {
|
|
// get last known event
|
|
var lastEvent = res.data.events_after[res.data.events_after.length - 1];
|
|
this.loadScriptFromEventId(lastEvent.event_id);
|
|
} else {
|
|
this.setState({events: this.state.events.slice(0, this.state.messageCount), statusMessage: "Done"});
|
|
}
|
|
```
|
|
|
|
## Using the Web Audio API
|
|
|
|
The [Web Audio API] is a massive topic, out of the scope of this article. We'll
|
|
cover just enough to be able to show the "happy path" of performing
|
|
Text-to-Speech (TTS) sequentially.
|
|
|
|
To deliver a line as audio, the fundamental code is as follows:
|
|
|
|
```javascript
|
|
var utterance = new SpeechSynthesisUtterance();
|
|
utterance.text = "some string";
|
|
var someVoice = window.speechSynthesis.getVoices()[0];
|
|
utterance.voice = someVoice;
|
|
window.speechSynthesis.speak(utterance);
|
|
```
|
|
|
|
To find out when an utterance ends, attach a function to the onend event:
|
|
|
|
```
|
|
utterance.onend = function() {
|
|
// do something when the line ends
|
|
};
|
|
```
|
|
|
|
Knowing that we can perform TTS on strings we provide, and that we can call a
|
|
function when a line ends, from here it's easy to see how we can use the list
|
|
of messages to "enact" the message history.
|
|
|
|
## Using the Web Audio API with React
|
|
|
|
We will:
|
|
|
|
* assign each user a random voice from TTS voices available in the current
|
|
browser
|
|
* trigger each line sequentially and with the correct voice, thus giving the
|
|
impression of a script being performed
|
|
|
|
Let's create a `nextLine()` function in our `App` component, and use this to
|
|
insert lines associated with "Parts", meaning that each part is a separate user
|
|
with an assigned voice.
|
|
|
|
```javascript
|
|
nextLine() {
|
|
var line = this.state.line;
|
|
if (! this.state.events[line]) return;
|
|
var newPart = this.state.events[line].sender;
|
|
if (! this.state.parts.find(p =>{return p.name === newPart;})) {
|
|
this.setState({
|
|
parts: this.state.parts.concat([{
|
|
name: newPart,
|
|
voice: voices[getRandomInt(0, voices.length)]
|
|
}])
|
|
})
|
|
}
|
|
this.setState({
|
|
script: this.state.script.concat(this.state.events[line]),
|
|
line: this.state.line + 1,
|
|
nextText: "Continue"
|
|
});
|
|
}
|
|
```
|
|
|
|
By incrementing the `line` counter, we progress through the script, adding a
|
|
line at a time to the correct `Part`.
|
|
|
|
During rendering, the App renders an array of `Part` Components, which in turn
|
|
render an array of lines, filtered for that particular Part:
|
|
|
|
```javascript
|
|
const lines = this.props.script.map((line, lineNumber) => {
|
|
line.lineNumber = lineNumber;
|
|
return line;
|
|
}).filter(l => l.sender === part.name);
|
|
```
|
|
|
|
Knowing that in React, the `constructor` for a Component is called only once, we
|
|
perform the TTS process itself inside the constructor method:
|
|
|
|
```javascript
|
|
class Line extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
var utterance = new SpeechSynthesisUtterance();
|
|
var nextLine = this.props.nextLine;
|
|
utterance.text = this.props.lineText;
|
|
utterance.voice = this.props.part.voice;
|
|
synth.speak(utterance);
|
|
}
|
|
}
|
|
```
|
|
|
|
Finally, we'll use what we already learned about the `onend` event to insert the
|
|
next line:
|
|
|
|
```javascript
|
|
class Line extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
var utterance = new SpeechSynthesisUtterance();
|
|
var nextLine = this.props.nextLine;
|
|
utterance.onend = function(a) {
|
|
nextLine();
|
|
};
|
|
utterance.text = this.props.lineText;
|
|
utterance.voice = this.props.part.voice;
|
|
synth.speak(utterance);
|
|
}
|
|
}
|
|
```
|
|
|
|
In this way, nextLine() is called in a loop, meaning that the lines are added to
|
|
React sequentially, and spoken aloud as they are added.
|
|
|
|
## Conclusion
|
|
|
|
This article covered a lot of ground:
|
|
|
|
* Matrix Guess access
|
|
* the [`/context/` API endpoint][context]
|
|
* filtering content from Matrix events
|
|
* passing these strings to the [Web Audio API]
|
|
|
|
To learn more about Matrix development, check out the
|
|
[Matrix Documentation](https://matrix.org/docs/).
|
|
|
|
[Matrix Client-Server API]: https://matrix.org/docs/spec/client_server/latest.html
|
|
[matrix-enact]: https://github.com/benparsons/matrix-enact
|
|
[context]: https://matrix.org/docs/spec/client_server/latest#get-matrix-client-r0-rooms-roomid-context-eventid
|
|
[Web Audio API]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
|
|
[matrix-js-sdk]: https://github.com/matrix-org/matrix-js-sdk
|