Spit and polish

* Add configuration system and default public channels support
* Add support for multiple channels and DM threads with users,
  with unread badge indicators. DMs rearrange themselves by
  most recently updated on top.
* Responsive CSS to work well on mobile devices.
This commit is contained in:
Noah 2023-02-05 00:53:50 -08:00
parent 3f756c5318
commit 8f60bdba0e
13 changed files with 615 additions and 119 deletions

View File

@ -1,20 +1,45 @@
# BareRTC # BareRTC
BareRTC is a simple WebRTC-based chat room application. It is especially BareRTC is a simple WebRTC-based chat room application. It is especially designed to be plugged into any existing website, with or without a pre-existing base of users.
designed to be plugged into any existing website, with or without a pre-existing
base of users. It is very much in the style of the old-school Flash based webcam chat rooms of the early 2000's: a multi-user chat room with DMs and _some_ users may broadcast video and others may watch multiple video feeds in an asynchronous manner. I thought that this should be such an obvious free and open source app that should exist, but it did not and so I had to write it myself.
This is still a **work in progress** and see the features it still needs, below.
# Features # Features
Planned features: * Specify multiple Public Channels that all users have access to.
* Users can open direct message (one-on-one) conversations with each other.
* No long-term server side state: messages are pushed out as they come in.
* Users may broadcast their webcam which shows a camera icon by their name in the Who List. Users may click on those icons to open multiple camera feeds of other users they are interested in.
* Mobile friendly: works best on iPads and above but adapts to smaller screens well.
* WebRTC means peer-to-peer video streaming so cheap on hosting costs!
* Simple integration with your existing userbase via signed JWT tokens.
* One common group chat area where all participants can broadcast text messages. Some important features it still needs:
* Direct (one-on-one) text conversations between any two users.
* Simple integration with your pre-existing userbase via signed JWT tokens. * JWT authentication, and admin user permissions (kick/ban/etc.)
* Support for profile URLs, custom avatar image URLs, custom profile fields to show in-app
* Lots of UI cleanup.
# Configuration # Configuration
TBD Work in progress. On first run it will create the settings.toml file for you:
```toml
[JWT]
Enabled = false
SecretKey = ""
[[PublicChannels]]
ID = "lobby"
Name = "Lobby"
Icon = "fa fa-gavel"
[[PublicChannels]]
ID = "offtopic"
Name = "Off Topic"
```
# Authentication # Authentication
@ -31,6 +56,25 @@ claims:
} }
``` ```
This feature is not hooked up yet. JWT authenticated users sent by your app is the primary supported userbase and will bring many features such as:
* Admin user permissions: you tell us who the admin is and they can moderate the chat room.
* User profile URLs that can be opened from the Who List.
* Custom avatar image URLs for your users.
* Extra profile fields/icons that you can customize the display with.
## Running Without Authentication
The default app doesn't need any authentication at all: users are asked to pick their own username when joining the chat. The server may re-assign them a new name if they enter one that's already taken.
It is not recommended to run in this mode as admin controls to moderate the server are disabled.
### Known Bugs Running Without Authentication
This app is not designed to run without JWT authentication for users enabled. In the app's default state, users can pick their own username when they connect and the server will adjust their name to resolve duplicates. Direct message threads are based on the username so if a user logs off, somebody else could log in with the same username and "resume" direct message threads that others were involved in.
Note that they would not get past history of those DMs as this server only pushes _new_ messages to users after they connect.
# License # License
GPLv3. GPLv3.

View File

@ -6,6 +6,7 @@ import (
"time" "time"
barertc "git.kirsle.net/apps/barertc/pkg" barertc "git.kirsle.net/apps/barertc/pkg"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/log"
) )
@ -27,6 +28,9 @@ func main() {
log.SetDebug(true) log.SetDebug(true)
} }
// Load configuration.
config.LoadSettings()
app := barertc.NewServer() app := barertc.NewServer()
app.Setup() app.Setup()
panic(app.ListenAndServe(address)) panic(app.ListenAndServe(address))

1
go.mod
View File

@ -5,6 +5,7 @@ go 1.19
require git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b require git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
require ( require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/klauspost/compress v1.10.3 // indirect github.com/klauspost/compress v1.10.3 // indirect
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
golang.org/x/crypto v0.5.0 // indirect golang.org/x/crypto v0.5.0 // indirect

2
go.sum
View File

@ -1,5 +1,7 @@
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o= git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o=
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y= git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=

84
pkg/config/config.go Normal file
View File

@ -0,0 +1,84 @@
package config
import (
"bytes"
"encoding/json"
"html/template"
"os"
"git.kirsle.net/apps/barertc/pkg/log"
"github.com/BurntSushi/toml"
)
// Config for your BareRTC app.
type Config struct {
JWT struct {
Enabled bool
SecretKey string
}
PublicChannels []Channel
}
// GetChannels returns a JavaScript safe array of the default PublicChannels.
func (c Config) GetChannels() template.JS {
data, _ := json.Marshal(c.PublicChannels)
return template.JS(data)
}
// Channel config for a default public room.
type Channel struct {
ID string // Like "lobby"
Name string // Like "Main Chat Room"
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
}
// Current loaded configuration.
var Current = DefaultConfig()
// DefaultConfig returns sensible defaults and will write the initial
// settings.toml file to disk.
func DefaultConfig() Config {
var c = Config{
PublicChannels: []Channel{
{
ID: "lobby",
Name: "Lobby",
Icon: "fa fa-gavel",
},
{
ID: "offtopic",
Name: "Off Topic",
},
},
}
return c
}
// LoadSettings reads a settings.toml from disk if available.
func LoadSettings() error {
data, err := os.ReadFile("./settings.toml")
if err != nil {
// Settings file didn't exist, create the default one.
if os.IsNotExist(err) {
WriteSettings()
return nil
}
return err
}
_, err = toml.Decode(string(data), &Current)
return err
}
// WriteSettings will commit the settings.toml to disk.
func WriteSettings() error {
log.Error("Note: initial settings.toml was written to disk.")
var buf = new(bytes.Buffer)
err := toml.NewEncoder(buf).Encode(Current)
if err != nil {
return err
}
return os.WriteFile("./settings.toml", buf.Bytes(), 0644)
}

View File

@ -2,9 +2,11 @@ package barertc
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/util"
) )
// OnLogin handles "login" actions from the client. // OnLogin handles "login" actions from the client.
@ -52,9 +54,28 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
return return
} }
// Message to be echoed to the channel.
var message = Message{
Action: ActionMessage,
Channel: msg.Channel,
Username: sub.Username,
Message: msg.Message,
}
// Is this a DM?
if strings.HasPrefix(msg.Channel, "@") {
// Echo the message only to both parties.
// message.Channel = "@" + sub.Username
s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username
s.SendTo(msg.Channel, message)
return
}
// Broadcast a chat message to the room. // Broadcast a chat message to the room.
s.Broadcast(Message{ s.Broadcast(Message{
Action: ActionMessage, Action: ActionMessage,
Channel: msg.Channel,
Username: sub.Username, Username: sub.Username,
Message: msg.Message, Message: msg.Message,
}) })
@ -82,7 +103,7 @@ func (s *Server) OnOpen(sub *Subscriber, msg Message) {
} }
// Make up a WebRTC shared secret and send it to both of them. // Make up a WebRTC shared secret and send it to both of them.
secret := RandomString(16) secret := util.RandomString(16)
log.Info("WebRTC: %s opens %s with secret %s", sub.Username, other.Username, secret) log.Info("WebRTC: %s opens %s with secret %s", sub.Username, other.Username, secret)
// Ring the target of this request and give them the secret. // Ring the target of this request and give them the secret.

View File

@ -2,8 +2,9 @@ package barertc
type Message struct { type Message struct {
Action string `json:"action,omitempty"` Action string `json:"action,omitempty"`
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
Message string `json:"message",omitempty` Message string `json:"message,omitempty"`
// WhoList for `who` actions // WhoList for `who` actions
WhoList []WhoList `json:"whoList,omitempty"` WhoList []WhoList `json:"whoList,omitempty"`

View File

@ -2,10 +2,11 @@ package barertc
import ( import (
"html/template" "html/template"
"math/rand"
"net/http" "net/http"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/util"
) )
// IndexPage returns the HTML template for the chat room. // IndexPage returns the HTML template for the chat room.
@ -13,11 +14,23 @@ func IndexPage() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Load the template, TODO: once on server startup. // Load the template, TODO: once on server startup.
tmpl := template.New("index") tmpl := template.New("index")
// Variables to give to the front-end page.
var values = map[string]interface{}{
// A cache-busting hash for JS and CSS includes.
"CacheHash": util.RandomString(8),
// The current website settings.
"Config": config.Current,
}
tmpl.Funcs(template.FuncMap{ tmpl.Funcs(template.FuncMap{
// Cache busting random string for JS and CSS dependency. // Cache busting random string for JS and CSS dependency.
"CacheHash": func() string { // "CacheHash": func() string {
return RandomString(8) // return util.RandomString(8)
}, // },
//
}) })
tmpl, err := tmpl.ParseFiles("web/templates/chat.html") tmpl, err := tmpl.ParseFiles("web/templates/chat.html")
if err != nil { if err != nil {
@ -26,16 +39,6 @@ func IndexPage() http.HandlerFunc {
// END load the template // END load the template
log.Info("Index route hit") log.Info("Index route hit")
tmpl.ExecuteTemplate(w, "index", nil) tmpl.ExecuteTemplate(w, "index", values)
}) })
} }
// RandomString returns a random string of any length.
func RandomString(n int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"
var result = make([]byte, n)
for i := 0; i < n; i++ {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
}

13
pkg/util/strings.go Normal file
View File

@ -0,0 +1,13 @@
package util
import "math/rand"
// RandomString returns a random string of any length.
func RandomString(n int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"
var result = make([]byte, n)
for i := 0; i < n; i++ {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
}

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
"git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/log"
@ -229,12 +230,31 @@ func (s *Server) Broadcast(msg Message) {
for _, sub := range s.IterSubscribers(true) { for _, sub := range s.IterSubscribers(true) {
sub.SendJSON(Message{ sub.SendJSON(Message{
Action: msg.Action, Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username, Username: msg.Username,
Message: msg.Message, Message: msg.Message,
}) })
} }
} }
// SendTo sends a message to a given username.
func (s *Server) SendTo(username string, msg Message) {
log.Debug("SendTo(%s): %+v", username, msg)
username = strings.TrimPrefix(username, "@")
s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
for _, sub := range s.IterSubscribers(true) {
if sub.Username == username {
sub.SendJSON(Message{
Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username,
Message: msg.Message,
})
}
}
}
// SendWhoList broadcasts the connected members to everybody in the room. // SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() { func (s *Server) SendWhoList() {
var ( var (

View File

@ -5,6 +5,10 @@ body {
min-height: 100vh; min-height: 100vh;
} }
.float-right {
float: right;
}
/************************ /************************
* Main CSS Grid Layout * * Main CSS Grid Layout *
************************/ ************************/
@ -51,6 +55,27 @@ body {
overflow: hidden; overflow: hidden;
} }
/* Responsive CSS styles */
@media screen and (min-width: 1024px) {
.mobile-only {
display: none;
}
}
@media screen and (max-width: 1024px) {
.chat-container {
grid-template-columns: 0px 1fr 0px;
column-gap: 0;
}
.left-column {
display: none;
}
.right-column {
display: none;
}
}
/*********************************************** /***********************************************
* Reusable CSS Grid-based Bulma Card layouts * * Reusable CSS Grid-based Bulma Card layouts *
* with a fixed header, full size scrollable * * with a fixed header, full size scrollable *

View File

@ -14,6 +14,11 @@ const app = Vue.createApp({
return { return {
busy: false, busy: false,
// Website configuration provided by chat.html template.
config: {
channels: PublicChannels,
},
channel: "lobby", channel: "lobby",
username: "", //"test", username: "", //"test",
message: "", message: "",
@ -46,9 +51,34 @@ const app = Vue.createApp({
// Chat history. // Chat history.
history: [], history: [],
channels: {
// There will be values here like:
// "lobby": {
// "history": [],
// "updated": timestamp,
// "unread": 4,
// },
// "@username": {
// "history": [],
// ...
// }
},
historyScrollbox: null, historyScrollbox: null,
DMs: {}, DMs: {},
// Responsive CSS for mobile.
responsive: {
leftDrawerOpen: false,
rightDrawerOpen: false,
nodes: {
// DOM nodes for the CSS grid cells.
$container: null,
$left: null,
$center: null,
$right: null,
}
},
loginModal: { loginModal: {
visible: false, visible: false,
}, },
@ -58,6 +88,22 @@ const app = Vue.createApp({
this.webcam.elem = document.querySelector("#localVideo"); this.webcam.elem = document.querySelector("#localVideo");
this.historyScrollbox = document.querySelector("#chatHistory"); this.historyScrollbox = document.querySelector("#chatHistory");
this.responsive.nodes = {
$container: document.querySelector(".chat-container"),
$left: document.querySelector(".left-column"),
$center: document.querySelector(".chat-column"),
$right: document.querySelector(".right-column"),
};
window.addEventListener("resize", () => {
// Reset CSS overrides for responsive display on any window size change.
this.resetResponsiveCSS();
});
for (let channel of this.config.channels) {
this.initHistory(channel.ID);
}
this.ChatServer("Welcome to BareRTC!") this.ChatServer("Welcome to BareRTC!")
if (!this.username) { if (!this.username) {
@ -66,6 +112,46 @@ const app = Vue.createApp({
this.signIn(); this.signIn();
} }
}, },
computed: {
chatHistory() {
if (this.channels[this.channel] == undefined) {
return [];
}
let history = this.channels[this.channel].history;
// How channels work:
// - Everything going to a public channel like "lobby" goes
// into the "lobby" channel in the front-end
// - Direct messages are different: they are all addressed
// "to" the channel of the current @user, but they are
// divided into DM threads based on the username.
if (this.channel.indexOf("@") === 0) {
// DM thread, divide them by sender.
// let username = this.channel.substring(1);
// return history.filter(v => {
// return v.username === username;
// });
}
return history;
},
channelName() {
// Return a suitable channel title.
if (this.channel.indexOf("@") === 0) {
// A DM, return it directly as is.
return this.channel;
}
// Find the friendly name from our config.
for (let channel of this.config.channels) {
if (channel.ID === this.channel) {
return channel.Name;
}
}
return this.channel;
},
},
methods: { methods: {
signIn() { signIn() {
this.loginModal.visible = false; this.loginModal.visible = false;
@ -89,6 +175,7 @@ const app = Vue.createApp({
console.debug("Send message: %s", this.message); console.debug("Send message: %s", this.message);
this.ws.conn.send(JSON.stringify({ this.ws.conn.send(JSON.stringify({
action: "message", action: "message",
channel: this.channel,
message: this.message, message: this.message,
})); }));
@ -111,7 +198,21 @@ const app = Vue.createApp({
this.username = msg.username; this.username = msg.username;
} }
this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`); // this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`);
},
// WhoList updates.
onWho(msg) {
this.whoList = msg.whoList;
// If we had a camera open with any of these and they have gone
// off camera, close our side of the connection.
for (let row of this.whoList) {
if (this.WebRTC.streams[row.username] != undefined &&
row.videoActive !== true) {
this.closeVideo(row.username);
}
}
}, },
// Send a video request to access a user's camera. // Send a video request to access a user's camera.
@ -125,7 +226,7 @@ const app = Vue.createApp({
// Response for the opener to begin WebRTC connection. // Response for the opener to begin WebRTC connection.
const secret = msg.openSecret; const secret = msg.openSecret;
console.log("OPEN: connect to %s with secret %s", msg.username, secret); console.log("OPEN: connect to %s with secret %s", msg.username, secret);
this.ChatClient(`onOpen called for ${msg.username}.`); // this.ChatClient(`onOpen called for ${msg.username}.`);
this.startWebRTC(msg.username, true); this.startWebRTC(msg.username, true);
}, },
@ -139,13 +240,13 @@ const app = Vue.createApp({
}, },
onUserExited(msg) { onUserExited(msg) {
// A user has logged off the server. Clean up any WebRTC connections. // A user has logged off the server. Clean up any WebRTC connections.
delete(this.WebRTC.streams[msg.username]); this.closeVideo(msg.username);
delete(this.WebRTC.pc[msg.username]);
}, },
// Handle messages sent in chat. // Handle messages sent in chat.
onMessage(msg) { onMessage(msg) {
this.pushHistory({ this.pushHistory({
channel: msg.channel,
username: msg.username, username: msg.username,
message: msg.message, message: msg.message,
}); });
@ -189,7 +290,7 @@ const app = Vue.createApp({
switch (msg.action) { switch (msg.action) {
case "who": case "who":
console.log("Got the Who List: %s", msg); console.log("Got the Who List: %s", msg);
this.whoList = msg.whoList; this.onWho(msg);
break; break;
case "me": case "me":
console.log("Got a self-update: %s", msg); console.log("Got a self-update: %s", msg);
@ -226,7 +327,7 @@ const app = Vue.createApp({
this.pushHistory({ this.pushHistory({
username: msg.username || 'Internal Server Error', username: msg.username || 'Internal Server Error',
message: msg.message, message: msg.message,
isChatServer: true, isChatClient: true,
}); });
default: default:
console.error("Unexpected action: %s", JSON.stringify(msg)); console.error("Unexpected action: %s", JSON.stringify(msg));
@ -242,7 +343,7 @@ const app = Vue.createApp({
// Start WebRTC with the other username. // Start WebRTC with the other username.
startWebRTC(username, isOfferer) { startWebRTC(username, isOfferer) {
this.ChatClient(`Begin WebRTC with ${username}.`); // this.ChatClient(`Begin WebRTC with ${username}.`);
let pc = new RTCPeerConnection(configuration); let pc = new RTCPeerConnection(configuration);
this.WebRTC.pc[username] = pc; this.WebRTC.pc[username] = pc;
@ -271,7 +372,7 @@ const app = Vue.createApp({
// If the user is offerer let the 'negotiationneeded' event create the offer. // If the user is offerer let the 'negotiationneeded' event create the offer.
if (isOfferer) { if (isOfferer) {
this.ChatClient("We are the offerer - set up onNegotiationNeeded"); // this.ChatClient("We are the offerer - set up onNegotiationNeeded");
pc.onnegotiationneeded = () => { pc.onnegotiationneeded = () => {
console.error("WebRTC OnNegotiationNeeded called!"); console.error("WebRTC OnNegotiationNeeded called!");
this.ChatClient("Negotiation Needed, creating WebRTC offer."); this.ChatClient("Negotiation Needed, creating WebRTC offer.");
@ -281,12 +382,12 @@ const app = Vue.createApp({
// When a remote stream arrives. // When a remote stream arrives.
pc.ontrack = event => { pc.ontrack = event => {
this.ChatServer("ON TRACK CALLED!!!"); // this.ChatServer("ON TRACK CALLED!!!");
console.error("WebRTC OnTrack called!", event); console.error("WebRTC OnTrack called!", event);
const stream = event.streams[0]; const stream = event.streams[0];
// Do we already have it? // Do we already have it?
this.ChatClient(`Received a video stream from ${username}.`); // this.ChatClient(`Received a video stream from ${username}.`);
if (this.WebRTC.streams[username] == undefined || if (this.WebRTC.streams[username] == undefined ||
this.WebRTC.streams[username].id !== stream.id) { this.WebRTC.streams[username].id !== stream.id) {
this.WebRTC.streams[username] = stream; this.WebRTC.streams[username] = stream;
@ -306,8 +407,8 @@ const app = Vue.createApp({
// TODO: currently both users need to have video on for the connection // TODO: currently both users need to have video on for the connection
// to succeed - if offerer doesn't addTrack it won't request a video channel // to succeed - if offerer doesn't addTrack it won't request a video channel
// and so the answerer (who has video) won't actually send its // and so the answerer (who has video) won't actually send its
if (this.webcam.active) { if (!isOfferer && this.webcam.active) {
this.ChatClient(`Sharing our video stream to ${username}.`); // this.ChatClient(`Sharing our video stream to ${username}.`);
let stream = this.webcam.stream; let stream = this.webcam.stream;
stream.getTracks().forEach(track => { stream.getTracks().forEach(track => {
console.error("Add stream track to WebRTC", stream, track); console.error("Add stream track to WebRTC", stream, track);
@ -317,14 +418,17 @@ const app = Vue.createApp({
// If we are the offerer, begin the connection. // If we are the offerer, begin the connection.
if (isOfferer) { if (isOfferer) {
pc.createOffer().then(this.localDescCreated(pc, username)).catch(this.ChatClient); pc.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: true,
}).then(this.localDescCreated(pc, username)).catch(this.ChatClient);
} }
}, },
// Common handler function for // Common handler function for
localDescCreated(pc, username) { localDescCreated(pc, username) {
return (desc) => { return (desc) => {
this.ChatClient(`setLocalDescription ${JSON.stringify(desc)}`); // this.ChatClient(`setLocalDescription ${JSON.stringify(desc)}`);
pc.setLocalDescription( pc.setLocalDescription(
new RTCSessionDescription(desc), new RTCSessionDescription(desc),
() => { () => {
@ -370,7 +474,7 @@ const app = Vue.createApp({
// Add the new ICE candidate. // Add the new ICE candidate.
console.log("Set description:", message); console.log("Set description:", message);
this.ChatClient(`Received a Remote Description from ${msg.username}: ${JSON.stringify(msg.description)}.`); // this.ChatClient(`Received a Remote Description from ${msg.username}: ${JSON.stringify(msg.description)}.`);
pc.setRemoteDescription(new RTCSessionDescription(message), () => { pc.setRemoteDescription(new RTCSessionDescription(message), () => {
// When receiving an offer let's answer it. // When receiving an offer let's answer it.
if (pc.remoteDescription.type === 'offer') { if (pc.remoteDescription.type === 'offer') {
@ -386,7 +490,7 @@ const app = Vue.createApp({
// }); // });
// } // }
this.ChatClient(`setRemoteDescription callback. Offer recieved - sending answer. Cam active? ${this.webcam.active}`); // this.ChatClient(`setRemoteDescription callback. Offer recieved - sending answer. Cam active? ${this.webcam.active}`);
console.warn("Creating answer now"); console.warn("Creating answer now");
pc.createAnswer().then(this.localDescCreated(pc, msg.username)).catch(this.ChatClient); pc.createAnswer().then(this.localDescCreated(pc, msg.username)).catch(this.ChatClient);
} }
@ -397,6 +501,79 @@ const app = Vue.createApp({
* Front-end web app concerns. * Front-end web app concerns.
*/ */
// Set active chat room.
setChannel(channel) {
this.channel = typeof(channel) === "string" ? channel : channel.ID;
this.scrollHistory();
this.channels[this.channel].unread = 0;
},
hasUnread(channel) {
if (this.channels[channel] == undefined) {
return 0;
}
return this.channels[channel].unread;
},
openDMs(user) {
let channel = "@" + user.username;
this.initHistory(channel);
this.setChannel(channel);
},
leaveDM() {
// Validate we're in a DM currently.
if (this.channel.indexOf("@") !== 0) return;
if (!window.confirm(
"Do you want to close this chat thread? Your conversation history will " +
"be forgotten on your computer, but your chat partner may still have " +
"your chat thread open on their end."
)) {
return;
}
let channel = this.channel;
this.setChannel(this.config.channels[0].ID);
delete(this.channels[channel]);
},
activeChannels() {
// List of current channels, unread indicators etc.
let result = [];
for (let channel of this.config.channels) {
let data = {
ID: channel.ID,
Name: channel.Name,
};
if (this.channels[channel] != undefined) {
data.Unread = this.channels[channel].unread;
data.Updated = this.channels[channel].updated;
}
result.push(data);
}
return result;
},
activeDMs() {
// List your currently open DM threads, sorted by most recent.
let result = [];
for (let channel of Object.keys(this.channels)) {
// @mentions only
if (channel.indexOf("@") !== 0) {
continue;
}
result.push({
channel: channel,
name: channel.substring(1),
updated: this.channels[channel].updated,
unread: this.channels[channel].unread,
});
}
result.sort((a, b) => {
return a.updated < b.updated;
});
return result;
},
// Start broadcasting my webcam. // Start broadcasting my webcam.
startVideo() { startVideo() {
if (this.webcam.busy) return; if (this.webcam.busy) return;
@ -426,16 +603,31 @@ const app = Vue.createApp({
return; return;
} }
// Camera is already open? Then disconnect the connection.
if (this.WebRTC.pc[user.username] != undefined) {
// TODO: this breaks the connection both ways :(
// this.closeVideo(user.username);
// return;
}
// We need to broadcast video to connect to another. // We need to broadcast video to connect to another.
// TODO: because if the offerer doesn't add video tracks they // TODO: because if the offerer doesn't add video tracks they
// won't request video support so the answerer's video isn't sent // won't request video support so the answerer's video isn't sent
if (!this.webcam.active) { if (!this.webcam.active) {
this.ChatServer("You will need to turn your own camera on first before you can connect to " + user.username + "."); this.ChatServer("You will need to turn your own camera on first before you can connect to " + user.username + ".");
return; // return;
} }
this.sendOpen(user.username); this.sendOpen(user.username);
}, },
closeVideo(username) {
// A user has logged off the server. Clean up any WebRTC connections.
delete (this.WebRTC.streams[username]);
if (this.WebRTC.pc[username] != undefined) {
this.WebRTC.pc[username].close();
delete (this.WebRTC.pc[username]);
}
},
// Stop broadcasting. // Stop broadcasting.
stopVideo() { stopVideo() {
@ -443,12 +635,36 @@ const app = Vue.createApp({
this.webcam.stream = null; this.webcam.stream = null;
this.webcam.active = false; this.webcam.active = false;
// Close all WebRTC sessions.
for (username of Object.keys(this.WebRTC.pc)) {
this.closeVideo(username);
}
// Tell backend our camera state. // Tell backend our camera state.
this.sendMe(); this.sendMe();
}, },
pushHistory({username, message, action="message", isChatServer, isChatClient}) { initHistory(channel) {
this.history.push({ if (this.channels[channel] == undefined) {
this.channels[channel] = {
history: [],
updated: Date.now(),
unread: 0,
};
}
},
pushHistory({ channel, username, message, action = "message", isChatServer, isChatClient }) {
// Default channel = your current channel.
if (!channel) {
channel = this.channel;
}
// Initialize this channel's history?
this.initHistory(channel);
// Append the message.
this.channels[channel].updated = Date.now();
this.channels[channel].history.push({
action: action, action: action,
username: username, username: username,
message: message, message: message,
@ -456,6 +672,11 @@ const app = Vue.createApp({
isChatClient, isChatClient,
}); });
this.scrollHistory(); this.scrollHistory();
// Mark unread notifiers if this is not our channel.
if (this.channel !== channel) {
this.channels[channel].unread++;
}
}, },
scrollHistory() { scrollHistory() {
@ -468,6 +689,43 @@ const app = Vue.createApp({
}, },
// Responsive CSS controls for mobile. Notes for maintenance:
// - The chat.css has responsive CSS to hide the left/right panels
// and set the grid-template-columns for devices < 1024px width
// - These functions override w/ style tags to force the drawer to
// be visible and change the grid-template-columns.
// - On window resize (e.g. device rotation) or when closing one
// of the side drawers, we reset our CSS overrides to default so
// the main chat window reappears.
openChannelsPanel() {
// Open the left drawer
let $container = this.responsive.nodes.$container,
$drawer = this.responsive.nodes.$left;
$container.style.gridTemplateColumns = "1fr 0 0";
$drawer.style.display = "block";
},
openWhoPanel() {
// Open the right drawer
let $container = this.responsive.nodes.$container,
$drawer = this.responsive.nodes.$right;
$container.style.gridTemplateColumns = "0 0 1fr";
$drawer.style.display = "block";
},
openChatPanel() {
this.resetResponsiveCSS();
},
resetResponsiveCSS() {
let $container = this.responsive.nodes.$container,
$left = this.responsive.nodes.$left,
$right = this.responsive.nodes.$right;
$left.style.removeProperty("display");
$right.style.removeProperty("display");
$container.style.removeProperty("grid-template-columns");
},
// Send a chat message as ChatServer // Send a chat message as ChatServer
ChatServer(message) { ChatServer(message) {
this.pushHistory({ this.pushHistory({

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css"> <link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css"> <link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{CacheHash}}"> <link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
<title>BareRTC</title> <title>BareRTC</title>
</head> </head>
<body> <body>
@ -45,12 +45,21 @@
</div> </div>
<div class="chat-container"> <div class="chat-container">
<!-- Left Column: Channels & DMs -->
<div class="left-column"> <div class="left-column">
<div class="card grid-card"> <div class="card grid-card">
<header class="card-header has-background-success-dark"> <header class="card-header has-background-success-dark">
<p class="card-header-title has-text-light"> <div class="columns is-mobile card-header-title has-text-light">
Channels <div class="column is-narrow mobile-only">
</p> <button type="button"
class="button is-success"
@click="openChatPanel">
<i class="fa fa-arrow-left"></i>
</button>
</div>
<div class="column">Channels</div>
</div>
</header> </header>
<div class="card-content"> <div class="card-content">
<aside class="menu"> <aside class="menu">
@ -59,7 +68,18 @@
</p> </p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="#" class="is-active">Chat Room</a></li> <li v-for="c in activeChannels()"
v-bind:key="c.ID">
<a :href="'#'+c.ID"
@click.prevent="setChannel(c)"
:class="{'is-active': c.ID == channel}">
[[c.Name]]
<span v-if="hasUnread(c.ID)"
class="tag is-danger">
[[hasUnread(c.ID)]]
</span>
</a>
</li>
</ul> </ul>
<p class="menu-label"> <p class="menu-label">
@ -67,26 +87,19 @@
</p> </p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="#">Chat Room</a></li> <li v-for="c in activeDMs()"
<li><a href="#">DMs</a></li> v-bind:key="c.channel">
<li><a href="#">DMs</a></li> <a :href="'#'+c.channel"
<li><a href="#">DMs</a></li> @click.prevent="setChannel(c.channel)"
<li><a href="#">DMs</a></li> :class="{'is-active': c.channel == channel}">
<li><a href="#">DMs</a></li> [[c.name]]
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li> <span v-if="hasUnread(c.channel)"
<li><a href="#">DMs</a></li> class="tag is-danger">
<li><a href="#">DMs</a></li> [[hasUnread(c.channel)]]
<li><a href="#">DMs</a></li> </span>
<li><a href="#">DMs</a></li> </a>
<li><a href="#">DMs</a></li> </li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
<li><a href="#">DMs</a></li>
</ul> </ul>
</aside> </aside>
@ -97,11 +110,36 @@
<div class="chat-column"> <div class="chat-column">
<div class="card grid-card"> <div class="card grid-card">
<header class="card-header has-background-link"> <header class="card-header has-background-link">
<p class="card-header-title has-text-light"> <div class="columns is-mobile card-header-title has-text-light">
Chat Room <div class="column is-narrow mobile-only">
</p> <!-- Responsive mobile button to pan to Left Column -->
<button type="button"
class="button is-success"
@click="openChannelsPanel">
<i class="fa fa-message"></i>
</button>
</div>
<div class="column">[[channelName]]</div>
<div class="column is-narrow">
<!-- DMs: Leave convo button -->
<button type="button"
v-if="channel.indexOf('@') === 0"
class="float-right button is-small is-warning is-outline"
@click="leaveDM()">
<i class="fa fa-trash"></i>
</button>
</div>
<div class="column is-narrow mobile-only">
<!-- Responsive mobile button to pan to Right Column -->
<button type="button"
class="button is-success"
@click="openWhoPanel">
<i class="fa fa-user-group"></i>
</button>
</div>
</div>
</header> </header>
<div class="video-feeds"> <div class="video-feeds" v-show="webcam.active || Object.keys(WebRTC.streams).length > 0">
<video class="feed" <video class="feed"
v-show="webcam.active" v-show="webcam.active"
id="localVideo" id="localVideo"
@ -114,49 +152,10 @@
:id="'videofeed-'+username" :id="'videofeed-'+username"
autoplay muted> autoplay muted>
</video> </video>
<div v-for="(stream, username) in WebRTC.streams" class="feed">
[[username]] - [[stream]]
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
<div class="feed">
y
</div>
</div> </div>
<div class="card-content" id="chatHistory"> <div class="card-content" id="chatHistory">
<div v-for="(msg, i) in history" v-bind:key="i"> <div v-for="(msg, i) in chatHistory" v-bind:key="i">
<div> <div>
<label class="label" <label class="label"
:class="{'has-text-success is-dark': msg.isChatServer, :class="{'has-text-success is-dark': msg.isChatServer,
@ -172,6 +171,7 @@
<div v-else> <div v-else>
[[msg.message]] [[msg.message]]
</div> </div>
</div> </div>
</div> </div>
@ -214,12 +214,20 @@
</div> </div>
</div> </div>
<!-- Right Column: Who Is Online -->
<div class="right-column"> <div class="right-column">
<div class="card grid-card"> <div class="card grid-card">
<header class="card-header has-background-success-dark"> <header class="card-header has-background-success-dark">
<p class="card-header-title has-text-light"> <div class="columns is-mobile card-header-title has-text-light">
Who Is Online <div class="column">Who Is Online</div>
</p> <div class="column is-narrow mobile-only">
<button type="button"
class="button is-success"
@click="openChatPanel">
<i class="fa fa-arrow-left"></i>
</button>
</div>
</div>
</header> </header>
<div class="card-content p-2"> <div class="card-content p-2">
@ -228,6 +236,13 @@
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column">[[ u.username ]]</div> <div class="column">[[ u.username ]]</div>
<div class="column is-narrow"> <div class="column is-narrow">
<button type="button"
class="button is-small px-2 py-1"
@click="openDMs(u)"
:disabled="u.username === username">
<i class="fa fa-message"></i>
</button>
<button type="button" class="button is-small" <button type="button" class="button is-small"
:disabled="!u.videoActive" :disabled="!u.videoActive"
@click="openVideo(u)"> @click="openVideo(u)">
@ -244,8 +259,13 @@
</div> </div>
</div><!-- /app --> </div><!-- /app -->
<script type="text/javascript">
const PublicChannels = {{.Config.GetChannels}};
</script>
<script src="/static/js/vue-3.2.45.js"></script> <script src="/static/js/vue-3.2.45.js"></script>
<script src="/static/js/BareRTC.js?{{CacheHash}}"></script> <script src="/static/js/BareRTC.js?{{.CacheHash}}"></script>
</body> </body>
</html> </html>