Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Labs feature: Early implementation of voice messages #5769

Merged
merged 18 commits into from
Mar 24, 2021

Conversation

turt2live
Copy link
Member

@turt2live turt2live commented Mar 17, 2021

This is deliberately behind a labs flag because it is not finished.

Requires element-hq/element-web#16705
For element-hq/element-web#1358

Note: There's a bunch of @@ TravisR TODOs in here. These are annotated so I can find them and fix them for a later PR.


State of affairs screenshots:

Voice message rendering:
image

Unsupported (older) element webs will see this (the rendering is intentionally not behind the labs flag at the moment):
image
(other client behaviour undefined and not checked - we're aware of the risks)

Composer states (not final):
image
image
image
image

@turt2live turt2live marked this pull request as ready for review March 17, 2021 06:24
@turt2live turt2live requested a review from a team March 17, 2021 06:24
Copy link
Member

@dbkr dbkr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just had a quick look so far but I didn't realise this would involve bundling a wasm-ed opus encoder :( How big does it end up being? I wonder if we should be backing the webm horse instead of ogg, especially if it allows us to avoid shipping a wasm-ed libopus (which I think it does).

src/voice/VoiceRecorder.ts Outdated Show resolved Hide resolved
mediaTrackConstraints: <MediaTrackConstraints>{
deviceId: CallMediaHandler.getAudioInput(),
},
encoderSampleRate: 16000, // we could go down to 12khz, but we lose quality
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12kHz would be pretty weird. Honestly I would probably just go up to 48Khz: it's what browsers will use over WebRTC.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I'd expect to see a mono / stereo param here maybe (unless that's implicit from the source?) And more importantly a target bitrate / quality setting?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apaprently it defaults to mono, and picks a quality/bitrate for us. I'll play around with some options that seem to fit well for us and hardcode them for good measure.

For the sample rate: 16khz didn't distort my voice at all - it was indistinguishable to 48khz, and ultimately lowered file size. The file size gains aren't super large though (at 16khz it's about 34kb for 5s of audio, at 48khz it's 45kb for 5s).

deviceId: CallMediaHandler.getAudioInput(),
},
encoderSampleRate: 16000, // we could go down to 12khz, but we lose quality
encoderApplication: 2048, // voice (default is "audio")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could tweak this but iirc this is a, "you can set this but you probably don't need to" thing (I guess they are voice messages...)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea, it seemed to be working fine without this but hey: spec compliance or something? idk, it's an option I found while finding other options so I set it.

src/voice/VoiceRecorder.ts Outdated Show resolved Hide resolved
@turt2live
Copy link
Member Author

The encoder is only 332kb of JS/wasm, so not too bad. Olm is sitting at 150kb (legacy module at 436kb), so it doesn't seem controversially large to me.

@turt2live turt2live requested a review from dbkr March 19, 2021 23:08
This leads to more reliable frequency/timing information, and involves a whole lot less decoding.

We still maintain ongoing encoded frames to avoid having to do one giant encode at the end, as that could take long enough to be disruptive.
@turt2live
Copy link
Member Author

@dbkr so I ended up using the browser's API. Apologies for the unexpected complexity in this PR review :s

Copy link
Member

@dbkr dbkr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generally looking good. would be good to get a final sanity check on ogg vs webm and if ogg is really the way we want to go, since once this is merged, we're a lot closer to being stuck with it.

src/components/views/rooms/VoiceRecordComposerTile.tsx Outdated Show resolved Hide resolved
});

let tooltip = _t("Record a voice message");
if (!!this.state.recorder) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!!this.state.recorder) {
if (Boolean(this.state.recorder)) {

I think this is our nominally preferred way of doing this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it? I don't recall this, and haven't done this conversion in years

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair enough - I think this might be from kegan days. I have no preference personally.

src/voice/VoiceRecorder.ts Outdated Show resolved Hide resolved
src/voice/VoiceRecorder.ts Outdated Show resolved Hide resolved
src/voice/VoiceRecorder.ts Outdated Show resolved Hide resolved
}

public get isSupported(): boolean {
return !!Recorder.isRecordingSupported();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return !!Recorder.isRecordingSupported();
return Boolean(Recorder.isRecordingSupported());

src/voice/VoiceRecorder.ts Outdated Show resolved Hide resolved
src/voice/VoiceRecorder.ts Outdated Show resolved Hide resolved
@jryans
Copy link
Collaborator

jryans commented Mar 23, 2021

Is it possible to use the MediaRecorder API and avoid the need for any recording libraries?

@dbkr
Copy link
Member

dbkr commented Mar 23, 2021

Is it possible to use the MediaRecorder API and avoid the need for any recording libraries?

not if we're using ogg as a container format 'cos chrome doesn't support it - hence wondering if webm would be the way to go

@jryans
Copy link
Collaborator

jryans commented Mar 23, 2021

Ah yeah, in that case would be great to use webm instead if we can. What are the constraints on formats here? Can we just pick whatever we like best (I assume not...)?

@t3chguy
Copy link
Member

t3chguy commented Mar 23, 2021

@jryans
Copy link
Collaborator

jryans commented Mar 23, 2021

The MSC says it must be ogg opus https://github.com/matrix-org/matrix-doc/pull/2516/files#diff-c926d801e68078ea5aaf2ac264459e8ce5fd830b2f46b11aa853bf45a11a1069R25

I see... However, I don't see any discussion of why Ogg was chosen in the MSC. Perhaps we should advocate for WebM at the MSC level and try to move to that then.

@turt2live
Copy link
Member Author

There were a number of resources saying the media recorder API is unreliable and doesn't produce a consistent format, so I steered clear of it.

We chose opus after great debate for its compression and versatility, knowing that encoders would be needed in clients.

The point is to test something and we can always change it later. This is a labs feature of an evolving MSC: there's no reason why opus will be set in stone with this, and we're trialing formats before the mobile team gets to this point in development. I'm not interested in rehashing formats here, basically.

@jryans
Copy link
Collaborator

jryans commented Mar 23, 2021

We chose opus after great debate for its compression and versatility, knowing that encoders would be needed in clients.

Where did this debate occur? I don't see it on the MSC at least...

@turt2live
Copy link
Member Author

#matrix-spec - if it's not there then it's a failure of the MSC process.

matrix-org/matrix-spec-proposals#2516 (comment)

@jryans
Copy link
Collaborator

jryans commented Mar 23, 2021

Thanks for recording the thinking on the MSC. Discussion in #matrix-spec is fine for ad-hoc thoughts, but it seems important to record the thinking behind big choices (like the container format here) somewhere in the MSC document or PR discussion (which you've now done).

As you say, it's an experiment, so I won't try to block on the extra encoder... Does it already download separately from the main bundle at least? Ideally we are not forcing a large download for everyone for an experimental feature.

@turt2live
Copy link
Member Author

As far as I can tell it's not even being requested, which is curious. The field is required (otherwise it explodes), but the request doesn't show up in the network log. Because the error is happening on creation, I'm inclined to believe that it only pulls it down when it needs it.

I'm still not sure I'd qualify a few hundred kilobytes as "large" though...

@turt2live turt2live requested a review from dbkr March 24, 2021 00:26
@jryans
Copy link
Collaborator

jryans commented Mar 24, 2021

I'm still not sure I'd qualify a few hundred kilobytes as "large" though...

We made an effort to isolate zxcvbn to an async download on use when it was first added because of similar concerns over several hundred kilobytes. In isolation, it might not seem so large, but when you do that again and again, it can add up. Overall, I just want to ensure we're making good choices and applying tools already within reach to minimise size where possible.

@turt2live
Copy link
Member Author

ah, the actual bundle (with the not-encoder bit) will have grown a bit...

Using a simple yarn build on my dev machine, here's the difference of develop versus this PR (if merged to the same develop):

$ ls -la
total 15044
drwxr-xr-x 1 travp 197609       0 Mar 23 19:37 ./
drwxr-xr-x 1 travp 197609       0 Mar 23 19:37 ../
-rw-r--r-- 1 travp 197609    3695 Mar 23 19:37 0.js
-rw-r--r-- 1 travp 197609    3613 Mar 23 19:37 0.js.map
-rw-r--r-- 1 travp 197609    3116 Mar 23 19:37 1.js
-rw-r--r-- 1 travp 197609    3124 Mar 23 19:37 1.js.map
-rw-r--r-- 1 travp 197609    2636 Mar 23 19:37 2.js
-rw-r--r-- 1 travp 197609    2521 Mar 23 19:37 2.js.map
-rw-r--r-- 1 travp 197609  818705 Mar 23 19:37 23.js
-rw-r--r-- 1 travp 197609   41067 Mar 23 19:37 23.js.map
-rw-r--r-- 1 travp 197609   19999 Mar 23 19:37 24.js
-rw-r--r-- 1 travp 197609   16038 Mar 23 19:37 24.js.map
-rw-r--r-- 1 travp 197609    7120 Mar 23 19:37 25.js
-rw-r--r-- 1 travp 197609    8131 Mar 23 19:37 25.js.map
-rw-r--r-- 1 travp 197609    1253 Mar 23 19:37 26.js
-rw-r--r-- 1 travp 197609    1284 Mar 23 19:37 26.js.map
-rw-r--r-- 1 travp 197609    3284 Mar 23 19:37 27.js
-rw-r--r-- 1 travp 197609    3174 Mar 23 19:37 27.js.map
-rw-r--r-- 1 travp 197609    1962 Mar 23 19:37 28.js
-rw-r--r-- 1 travp 197609    1519 Mar 23 19:37 28.js.map
-rw-r--r-- 1 travp 197609    1764 Mar 23 19:37 29.js
-rw-r--r-- 1 travp 197609    1119 Mar 23 19:37 29.js.map
-rw-r--r-- 1 travp 197609    9980 Mar 23 19:37 3.js
-rw-r--r-- 1 travp 197609   11014 Mar 23 19:37 3.js.map
-rw-r--r-- 1 travp 197609    2172 Mar 23 19:37 30.js
-rw-r--r-- 1 travp 197609    1233 Mar 23 19:37 30.js.map
-rw-r--r-- 1 travp 197609   13382 Mar 23 19:37 4.js
-rw-r--r-- 1 travp 197609   10700 Mar 23 19:37 4.js.map
-rw-r--r-- 1 travp 197609     110 Mar 23 19:37 5.js
-rw-r--r-- 1 travp 197609     110 Mar 23 19:37 5.js.map
-rw-r--r-- 1 travp 197609   29382 Mar 23 19:37 bundle.css
-rw-r--r-- 1 travp 197609   17451 Mar 23 19:37 bundle.js
-rw-r--r-- 1 travp 197609     363 Mar 23 19:37 bundle.js.LICENSE.txt
-rw-r--r-- 1 travp 197609   22951 Mar 23 19:37 bundle.js.map
-rw-r--r-- 1 travp 197609    1912 Mar 23 19:37 compatibility-view.css
-rw-r--r-- 1 travp 197609    3018 Mar 23 19:37 compatibility-view.js
-rw-r--r-- 1 travp 197609    1277 Mar 23 19:37 compatibility-view.js.map
-rw-r--r-- 1 travp 197609    3708 Mar 23 19:37 element-web-app.js
-rw-r--r-- 1 travp 197609    3890 Mar 23 19:37 element-web-app.js.map
-rw-r--r-- 1 travp 197609    2979 Mar 23 19:37 element-web-component-index.js
-rw-r--r-- 1 travp 197609    2907 Mar 23 19:37 element-web-component-index.js.map
-rw-r--r-- 1 travp 197609    1912 Mar 23 19:37 error-view.css
-rw-r--r-- 1 travp 197609    1067 Mar 23 19:37 error-view.js
-rw-r--r-- 1 travp 197609     662 Mar 23 19:37 error-view.js.map
-rw-r--r-- 1 travp 197609  100406 Mar 23 19:37 indexeddb-worker.js
-rw-r--r-- 1 travp 197609   28967 Mar 23 19:37 indexeddb-worker.js.map
-rw-r--r-- 1 travp 197609 2349658 Mar 23 19:37 init.js
-rw-r--r-- 1 travp 197609 2291210 Mar 23 19:37 init.js.map
-rw-r--r-- 1 travp 197609    1244 Mar 23 19:37 jitsi.css
-rw-r--r-- 1 travp 197609  354688 Mar 23 19:37 jitsi.js
-rw-r--r-- 1 travp 197609     935 Mar 23 19:37 jitsi.js.LICENSE.txt
-rw-r--r-- 1 travp 197609  482051 Mar 23 19:37 jitsi.js.map
-rw-r--r-- 1 travp 197609    9628 Mar 23 19:37 mobileguide.js
-rw-r--r-- 1 travp 197609   12891 Mar 23 19:37 mobileguide.js.map
-rw-r--r-- 1 travp 197609  407960 Mar 23 19:37 theme-dark-custom.css
-rw-r--r-- 1 travp 197609     982 Mar 23 19:37 theme-dark-custom.js
-rw-r--r-- 1 travp 197609    1669 Mar 23 19:37 theme-dark-custom.js.map
-rw-r--r-- 1 travp 197609  397066 Mar 23 19:37 theme-dark.css
-rw-r--r-- 1 travp 197609     975 Mar 23 19:37 theme-dark.js
-rw-r--r-- 1 travp 197609    1662 Mar 23 19:37 theme-dark.js.map
-rw-r--r-- 1 travp 197609  393703 Mar 23 19:37 theme-legacy-dark.css
-rw-r--r-- 1 travp 197609     982 Mar 23 19:37 theme-legacy-dark.js
-rw-r--r-- 1 travp 197609    1669 Mar 23 19:37 theme-legacy-dark.js.map
-rw-r--r-- 1 travp 197609  393029 Mar 23 19:37 theme-legacy.css
-rw-r--r-- 1 travp 197609     977 Mar 23 19:37 theme-legacy.js
-rw-r--r-- 1 travp 197609    1664 Mar 23 19:37 theme-legacy.js.map
-rw-r--r-- 1 travp 197609  407424 Mar 23 19:37 theme-light-custom.css
-rw-r--r-- 1 travp 197609     983 Mar 23 19:37 theme-light-custom.js
-rw-r--r-- 1 travp 197609    1670 Mar 23 19:37 theme-light-custom.js.map
-rw-r--r-- 1 travp 197609  396233 Mar 23 19:37 theme-light.css
-rw-r--r-- 1 travp 197609     976 Mar 23 19:37 theme-light.js
-rw-r--r-- 1 travp 197609    1663 Mar 23 19:37 theme-light.js.map
-rw-r--r-- 1 travp 197609    1838 Mar 23 19:37 usercontent.js
-rw-r--r-- 1 travp 197609    2620 Mar 23 19:37 usercontent.js.map
-rw-r--r-- 1 travp 197609 3338023 Mar 23 19:37 vendors~init.js
-rw-r--r-- 1 travp 197609    3548 Mar 23 19:37 vendors~init.js.LICENSE.txt
-rw-r--r-- 1 travp 197609 2774900 Mar 23 19:37 vendors~init.js.map
$ ls -la
total 15076
drwxr-xr-x 1 travp 197609       0 Mar 23 19:39 ./
drwxr-xr-x 1 travp 197609       0 Mar 23 19:39 ../
-rw-r--r-- 1 travp 197609    3695 Mar 23 19:39 0.js
-rw-r--r-- 1 travp 197609    3613 Mar 23 19:39 0.js.map
-rw-r--r-- 1 travp 197609    3116 Mar 23 19:39 1.js
-rw-r--r-- 1 travp 197609    3124 Mar 23 19:39 1.js.map
-rw-r--r-- 1 travp 197609    2636 Mar 23 19:39 2.js
-rw-r--r-- 1 travp 197609    2521 Mar 23 19:39 2.js.map
-rw-r--r-- 1 travp 197609  818705 Mar 23 19:39 23.js
-rw-r--r-- 1 travp 197609   41067 Mar 23 19:39 23.js.map
-rw-r--r-- 1 travp 197609   19999 Mar 23 19:39 24.js
-rw-r--r-- 1 travp 197609   16038 Mar 23 19:39 24.js.map
-rw-r--r-- 1 travp 197609    7120 Mar 23 19:39 25.js
-rw-r--r-- 1 travp 197609    8131 Mar 23 19:39 25.js.map
-rw-r--r-- 1 travp 197609    1253 Mar 23 19:39 26.js
-rw-r--r-- 1 travp 197609    1284 Mar 23 19:39 26.js.map
-rw-r--r-- 1 travp 197609    3284 Mar 23 19:39 27.js
-rw-r--r-- 1 travp 197609    3174 Mar 23 19:39 27.js.map
-rw-r--r-- 1 travp 197609    1962 Mar 23 19:39 28.js
-rw-r--r-- 1 travp 197609    1519 Mar 23 19:39 28.js.map
-rw-r--r-- 1 travp 197609    1764 Mar 23 19:39 29.js
-rw-r--r-- 1 travp 197609    1119 Mar 23 19:39 29.js.map
-rw-r--r-- 1 travp 197609    9980 Mar 23 19:39 3.js
-rw-r--r-- 1 travp 197609   11014 Mar 23 19:39 3.js.map
-rw-r--r-- 1 travp 197609    2172 Mar 23 19:39 30.js
-rw-r--r-- 1 travp 197609    1233 Mar 23 19:39 30.js.map
-rw-r--r-- 1 travp 197609   13382 Mar 23 19:39 4.js
-rw-r--r-- 1 travp 197609   10700 Mar 23 19:39 4.js.map
-rw-r--r-- 1 travp 197609     110 Mar 23 19:39 5.js
-rw-r--r-- 1 travp 197609     110 Mar 23 19:39 5.js.map
-rw-r--r-- 1 travp 197609   29382 Mar 23 19:39 bundle.css
-rw-r--r-- 1 travp 197609   17451 Mar 23 19:39 bundle.js
-rw-r--r-- 1 travp 197609     363 Mar 23 19:39 bundle.js.LICENSE.txt
-rw-r--r-- 1 travp 197609   22951 Mar 23 19:39 bundle.js.map
-rw-r--r-- 1 travp 197609    1912 Mar 23 19:39 compatibility-view.css
-rw-r--r-- 1 travp 197609    3018 Mar 23 19:39 compatibility-view.js
-rw-r--r-- 1 travp 197609    1277 Mar 23 19:39 compatibility-view.js.map
-rw-r--r-- 1 travp 197609    3708 Mar 23 19:39 element-web-app.js
-rw-r--r-- 1 travp 197609    3890 Mar 23 19:39 element-web-app.js.map
-rw-r--r-- 1 travp 197609    2979 Mar 23 19:39 element-web-component-index.js
-rw-r--r-- 1 travp 197609    2907 Mar 23 19:39 element-web-component-index.js.map
-rw-r--r-- 1 travp 197609    1912 Mar 23 19:39 error-view.css
-rw-r--r-- 1 travp 197609    1067 Mar 23 19:39 error-view.js
-rw-r--r-- 1 travp 197609     662 Mar 23 19:39 error-view.js.map
-rw-r--r-- 1 travp 197609  100406 Mar 23 19:39 indexeddb-worker.js
-rw-r--r-- 1 travp 197609   28967 Mar 23 19:39 indexeddb-worker.js.map
-rw-r--r-- 1 travp 197609 2353999 Mar 23 19:39 init.js
-rw-r--r-- 1 travp 197609 2295475 Mar 23 19:39 init.js.map
-rw-r--r-- 1 travp 197609    1244 Mar 23 19:39 jitsi.css
-rw-r--r-- 1 travp 197609  354688 Mar 23 19:39 jitsi.js
-rw-r--r-- 1 travp 197609     935 Mar 23 19:39 jitsi.js.LICENSE.txt
-rw-r--r-- 1 travp 197609  482051 Mar 23 19:39 jitsi.js.map
-rw-r--r-- 1 travp 197609    9628 Mar 23 19:39 mobileguide.js
-rw-r--r-- 1 travp 197609   12891 Mar 23 19:39 mobileguide.js.map
-rw-r--r-- 1 travp 197609  408539 Mar 23 19:39 theme-dark-custom.css
-rw-r--r-- 1 travp 197609     982 Mar 23 19:39 theme-dark-custom.js
-rw-r--r-- 1 travp 197609    1669 Mar 23 19:39 theme-dark-custom.js.map
-rw-r--r-- 1 travp 197609  397645 Mar 23 19:39 theme-dark.css
-rw-r--r-- 1 travp 197609     975 Mar 23 19:39 theme-dark.js
-rw-r--r-- 1 travp 197609    1662 Mar 23 19:39 theme-dark.js.map
-rw-r--r-- 1 travp 197609  394282 Mar 23 19:39 theme-legacy-dark.css
-rw-r--r-- 1 travp 197609     982 Mar 23 19:39 theme-legacy-dark.js
-rw-r--r-- 1 travp 197609    1669 Mar 23 19:39 theme-legacy-dark.js.map
-rw-r--r-- 1 travp 197609  393608 Mar 23 19:39 theme-legacy.css
-rw-r--r-- 1 travp 197609     977 Mar 23 19:39 theme-legacy.js
-rw-r--r-- 1 travp 197609    1664 Mar 23 19:39 theme-legacy.js.map
-rw-r--r-- 1 travp 197609  408003 Mar 23 19:39 theme-light-custom.css
-rw-r--r-- 1 travp 197609     983 Mar 23 19:39 theme-light-custom.js
-rw-r--r-- 1 travp 197609    1670 Mar 23 19:39 theme-light-custom.js.map
-rw-r--r-- 1 travp 197609  396812 Mar 23 19:39 theme-light.css
-rw-r--r-- 1 travp 197609     976 Mar 23 19:39 theme-light.js
-rw-r--r-- 1 travp 197609    1663 Mar 23 19:39 theme-light.js.map
-rw-r--r-- 1 travp 197609    1838 Mar 23 19:39 usercontent.js
-rw-r--r-- 1 travp 197609    2620 Mar 23 19:39 usercontent.js.map
-rw-r--r-- 1 travp 197609 3345944 Mar 23 19:39 vendors~init.js
-rw-r--r-- 1 travp 197609    3548 Mar 23 19:39 vendors~init.js.LICENSE.txt
-rw-r--r-- 1 travp 197609 2784067 Mar 23 19:39 vendors~init.js.map

In particular, the vendors~init.js grew from 3338023 bytes to 3345944 - a difference of just 7.9kb. We could probably factor this out to a lazily loaded script, though given we'd eventually want everyone to access it as soon as they click the button I'm not sure it's savings in the end.

However, these bundle files are massive - we should look into that separately.

@turt2live
Copy link
Member Author

also having tested the build output with a server that logs requests, I can see the encoderWorker being requested only once the user clicks the button (ie: the recorder starts). It also looks like it gets cached somewhere in the browser as refreshing with cache disabled doesn't cause it to be re-requested.

@jryans
Copy link
Collaborator

jryans commented Mar 24, 2021

However, these bundle files are massive - we should look into that separately.

I've added element-hq/element-web#8356 to the platform backlog to eventually take a look. They definitely are large, and I'm sure a variety of changes can help soften the impact of such a large size.

Copy link
Member

@dbkr dbkr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since other apps seem to have converged on ogg as a container format, I think I've convinced myself that it is probably the way to go. Arguably we could be using the browser's encoder and just re-muxing, but that's probably premature optimisation, so lgtm.

@turt2live turt2live merged commit 8587ec8 into develop Mar 24, 2021
@turt2live turt2live deleted the travis/voice-messages/exp branch March 24, 2021 15:56
@nmscode nmscode mentioned this pull request Jul 26, 2023
3 tasks
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants