Timestamps, Sound Effects & Love

This commit is contained in:
Noah 2023-02-06 13:27:29 -08:00
parent 4810d95a65
commit 9487595e04
15 changed files with 338 additions and 28 deletions

View File

@ -1,2 +1,7 @@
.PHONY: run
run: run:
go run cmd/BareRTC/main.go -debug go run cmd/BareRTC/main.go -debug
.PHONY: build
build:
go build -o BareRTC cmd/BareRTC/main.go

View File

@ -33,5 +33,7 @@ func main() {
app := barertc.NewServer() app := barertc.NewServer()
app.Setup() app.Setup()
log.Info("Listening at %s", address)
panic(app.ListenAndServe(address)) panic(app.ListenAndServe(address))
} }

View File

@ -19,6 +19,7 @@ type Config struct {
} }
Title string Title string
Branding string
WebsiteURL string WebsiteURL string
PublicChannels []Channel PublicChannels []Channel

View File

@ -108,10 +108,11 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
// Message to be echoed to the channel. // Message to be echoed to the channel.
var message = Message{ var message = Message{
Action: ActionMessage, Action: ActionMessage,
Channel: msg.Channel, Channel: msg.Channel,
Username: sub.Username, Username: sub.Username,
Message: markdown, Message: markdown,
Timestamp: time.Now(),
} }
// Is this a DM? // Is this a DM?
@ -119,7 +120,9 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
// Echo the message only to both parties. // Echo the message only to both parties.
s.SendTo(sub.Username, message) s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username message.Channel = "@" + sub.Username
s.SendTo(msg.Channel, message) if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
return return
} }

View File

@ -1,10 +1,13 @@
package barertc package barertc
import "time"
type Message struct { type Message struct {
Action string `json:"action,omitempty"` Action string `json:"action,omitempty"`
Channel string `json:"channel,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"`
Timestamp time.Time `json:"at,omitempty"`
// JWT token for `login` actions. // JWT token for `login` actions.
JWTToken string `json:"jwt,omitempty"` JWTToken string `json:"jwt,omitempty"`

View File

@ -239,23 +239,26 @@ func (s *Server) Broadcast(msg Message) {
continue continue
} }
sub.SendJSON(Message{ sub.SendJSON(msg)
Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username,
Message: msg.Message,
})
} }
} }
// SendTo sends a message to a given username. // SendTo sends a message to a given username.
func (s *Server) SendTo(username string, msg Message) { func (s *Server) SendTo(username string, msg Message) error {
log.Debug("SendTo(%s): %+v", username, msg) log.Debug("SendTo(%s): %+v", username, msg)
username = strings.TrimPrefix(username, "@") username = strings.TrimPrefix(username, "@")
s.subscribersMu.RLock() s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock() defer s.subscribersMu.RUnlock()
// If no timestamp, add it.
if msg.Timestamp.IsZero() {
msg.Timestamp = time.Now()
}
var found bool
for _, sub := range s.IterSubscribers(true) { for _, sub := range s.IterSubscribers(true) {
if sub.Username == username { if sub.Username == username {
found = true
sub.SendJSON(Message{ sub.SendJSON(Message{
Action: msg.Action, Action: msg.Action,
Channel: msg.Channel, Channel: msg.Channel,
@ -264,6 +267,11 @@ func (s *Server) SendTo(username string, msg Message) {
}) })
} }
} }
if !found {
return fmt.Errorf("%s is not online", username)
}
return nil
} }
// SendWhoList broadcasts the connected members to everybody in the room. // SendWhoList broadcasts the connected members to everybody in the room.
@ -292,8 +300,9 @@ func (s *Server) SendWhoList() {
for _, sub := range subscribers { for _, sub := range subscribers {
sub.SendJSON(Message{ sub.SendJSON(Message{
Action: ActionWhoList, Action: ActionWhoList,
WhoList: users, WhoList: users,
Timestamp: time.Now(),
}) })
} }
} }

View File

@ -18,6 +18,13 @@ const app = Vue.createApp({
config: { config: {
channels: PublicChannels, channels: PublicChannels,
website: WebsiteURL, website: WebsiteURL,
sounds: {
available: SoundEffects,
settings: DefaultSounds,
ready: false,
audioContext: null,
audioTracks: {},
}
}, },
// User JWT settings if available. // User JWT settings if available.
@ -96,9 +103,15 @@ const app = Vue.createApp({
loginModal: { loginModal: {
visible: false, visible: false,
}, },
settingsModal: {
visible: false,
},
} }
}, },
mounted() { mounted() {
this.setupSounds();
this.webcam.elem = document.querySelector("#localVideo"); this.webcam.elem = document.querySelector("#localVideo");
this.historyScrollbox = document.querySelector("#chatHistory"); this.historyScrollbox = document.querySelector("#chatHistory");
@ -280,10 +293,20 @@ const app = Vue.createApp({
// Handle messages sent in chat. // Handle messages sent in chat.
onMessage(msg) { onMessage(msg) {
// Play sound effects if this is not the active channel.
if (msg.channel.indexOf("@") === 0) {
if (msg.channel !== this.channel) {
this.playSound("DM");
}
} else if (msg.channel !== this.channel) {
this.playSound("Chat");
}
this.pushHistory({ this.pushHistory({
channel: msg.channel, channel: msg.channel,
username: msg.username, username: msg.username,
message: msg.message, message: msg.message,
at: msg.at,
}); });
}, },
@ -293,6 +316,9 @@ const app = Vue.createApp({
if (msg.message.indexOf("has exited the room!") > -1) { if (msg.message.indexOf("has exited the room!") > -1) {
// Clean up data about this user. // Clean up data about this user.
this.onUserExited(msg); this.onUserExited(msg);
this.playSound("Leave");
} else {
this.playSound("Enter");
} }
// Push it to the history of all public channels. // Push it to the history of all public channels.
@ -302,6 +328,19 @@ const app = Vue.createApp({
action: msg.action, action: msg.action,
username: msg.username, username: msg.username,
message: msg.message, message: msg.message,
at: msg.at,
});
}
// Push also to any DM channels for this user.
let channel = "@" + msg.username;
if (this.channels[channel] != undefined) {
this.pushHistory({
channel: channel,
action: msg.action,
username: msg.username,
message: msg.message,
at: msg.at,
}); });
} }
}, },
@ -320,8 +359,8 @@ const app = Vue.createApp({
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`); this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
if (ev.code !== 1001) { if (ev.code !== 1001) {
this.ChatClient("Reconnecting in 1s"); this.ChatClient("Reconnecting in 5s");
setTimeout(this.dial, 1000); setTimeout(this.dial, 5000);
} }
}); });
@ -345,6 +384,13 @@ const app = Vue.createApp({
} }
let msg = JSON.parse(ev.data); let msg = JSON.parse(ev.data);
try {
// Cast timestamp to date.
msg.at = new Date(msg.at);
} catch(e) {
console.error("Parsing timestamp '%s' on msg: %s", msg.at, e);
}
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);
@ -384,7 +430,12 @@ const app = Vue.createApp({
username: msg.username || 'Internal Server Error', username: msg.username || 'Internal Server Error',
message: msg.message, message: msg.message,
isChatServer: true, isChatServer: true,
at: new Date(),
}); });
break;
case "ping":
console.debug("Received ping from server");
break;
default: default:
console.error("Unexpected action: %s", JSON.stringify(msg)); console.error("Unexpected action: %s", JSON.stringify(msg));
} }
@ -574,6 +625,14 @@ const app = Vue.createApp({
* Front-end web app concerns. * Front-end web app concerns.
*/ */
// Settings modal.
showSettings() {
this.settingsModal.visible = true;
},
hideSettings() {
this.settingsModal.visible = false;
},
// Set active chat room. // Set active chat room.
setChannel(channel) { setChannel(channel) {
this.channel = typeof(channel) === "string" ? channel : channel.ID; this.channel = typeof(channel) === "string" ? channel : channel.ID;
@ -803,7 +862,7 @@ const app = Vue.createApp({
}; };
} }
}, },
pushHistory({ channel, username, message, action = "message", isChatServer, isChatClient }) { pushHistory({ channel, username, message, action = "message", at, isChatServer, isChatClient }) {
// Default channel = your current channel. // Default channel = your current channel.
if (!channel) { if (!channel) {
channel = this.channel; channel = this.channel;
@ -818,6 +877,7 @@ const app = Vue.createApp({
action: action, action: action,
username: username, username: username,
message: message, message: message,
at: at || new Date(),
isChatServer, isChatServer,
isChatClient, isChatClient,
}); });
@ -894,6 +954,77 @@ const app = Vue.createApp({
isChatClient: true, isChatClient: true,
}); });
}, },
// Format a datetime nicely for chat timestamp.
prettyDate(date) {
let hours = date.getHours(),
minutes = String(date.getMinutes()).padStart(2, '0'),
seconds = String(date.getSeconds()).padStart(2, '0'),
ampm = hours >= 11 ? "pm" : "am";
return `${(hours%12)+1}:${minutes}:${seconds} ${ampm}`;
},
/**
* Sound effect concerns.
*/
setupSounds() {
if (AudioContext) {
this.config.sounds.audioContext = new AudioContext();
} else {
this.config.sounds.audioContext = window.AudioContext || window.webkitAudioContext;
}
if (!this.config.sounds.audioContext) {
console.error("Couldn't set up AudioContext! No sound effects will be supported.");
return;
}
console.error("AudioContext:", this.config.sounds.audioContext);
// Create <audio> elements for all the sounds.
for (let effect of this.config.sounds.available) {
if (!effect.filename) continue; // 'Quiet' has no audio
let elem = document.createElement("audio");
elem.autoplay = false;
elem.src = `/static/sfx/${effect.filename}`;
document.body.appendChild(elem);
let track = this.config.sounds.audioContext.createMediaElementSource(elem);
track.connect(this.config.sounds.audioContext.destination);
this.config.sounds.audioTracks[effect.name] = elem;
console.warn(effect.name, this.config.sounds.audioTracks[effect.name]);
}
// Apply the user's saved preferences if any.
for (let setting of Object.keys(this.config.sounds.settings)) {
if (localStorage[`sound:${setting}`] != undefined) {
this.config.sounds.settings[setting] = localStorage[`sound:${setting}`];
}
}
},
playSound(event) {
let filename = this.config.sounds.settings[event];
console.error("Play sound:", event, filename, JSON.stringify(this.config.sounds));
// Do we have an audio track?
console.log(this.config.sounds.audioTracks[filename]);
if (this.config.sounds.audioTracks[filename] != undefined) {
let track = this.config.sounds.audioTracks[filename];
console.log("Track:", track);
track.play();
console.log("Playing %s", filename);
}
},
setSoundPref(event) {
this.playSound(event);
// Store the user's setting in localStorage.
localStorage[`sound:${event}`] = this.config.sounds.settings[event];
},
} }
}); });

35
web/static/js/sounds.js Normal file
View File

@ -0,0 +1,35 @@
// Available sound effects.
const SoundEffects = [
{
name: "Quiet",
filename: null
},
{
name: "Trill",
filename: "beep-6-96243.mp3"
},
{
name: "Beep",
filename: "beep-sound-8333.mp3"
},
{
name: "Bird",
filename: "bird-3-f-89236.mp3"
},
{
name: "Ping",
filename: "ping-82822.mp3"
},
{
name: "Sonar",
filename: "sonar-ping-95840.mp3"
}
];
// Defaults
var DefaultSounds = {
Chat: "Quiet",
DM: "Trill",
Enter: "Quiet",
Leave: "Quiet",
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
Free sounds from https://pixabay.com/sound-effects

Binary file not shown.

Binary file not shown.

View File

@ -8,10 +8,11 @@
<link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.css"> <link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.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>{{.Config.Title}}</title>
</head> </head>
<body> <body>
<div id="BareRTC-App"> <div id="BareRTC-App">
<!-- Sign In modal --> <!-- Sign In modal -->
<div class="modal" :class="{'is-active': loginModal.visible}"> <div class="modal" :class="{'is-active': loginModal.visible}">
<div class="modal-background"></div> <div class="modal-background"></div>
@ -45,13 +46,122 @@
</div> </div>
</div> </div>
<!-- Settings modal -->
<div class="modal" :class="{'is-active': settingsModal.visible}">
<div class="modal-background"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-info">
<p class="card-header-title has-text-light">Chat Settings</p>
</header>
<div class="card-content">
<h3 class="subtitle">Sounds</h3>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">DM chat</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.DM" @change="setSoundPref('DM')">
<option v-for="s in config.sounds.available"
v-bind:key="s.name"
:value="s.name">
[[s.name]]
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Public chat</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Chat" @change="setSoundPref('Chat')">
<option v-for="s in config.sounds.available"
v-bind:key="s.name"
:value="s.name">
[[s.name]]
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Room enter</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Enter" @change="setSoundPref('Enter')">
<option v-for="s in config.sounds.available"
v-bind:key="s.name"
:value="s.name">
[[s.name]]
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Room leave</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Leave" @change="setSoundPref('Leave')">
<option v-for="s in config.sounds.available"
v-bind:key="s.name"
:value="s.name">
[[s.name]]
</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="card-footer">
<div class="card-footer-item">
<button type="button" class="button is-primary"
@click="hideSettings()">
Close
</button>
</div>
</footer>
</div>
</div>
</div>
<div class="chat-container"> <div class="chat-container">
<!-- Top header panel --> <!-- Top header panel -->
<header class="chat-header"> <header class="chat-header">
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column is-narrow"> <div class="column is-narrow">
<strong class="is-6">{{AsHTML .Config.Title}}</strong> <strong class="is-6">{{AsHTML .Config.Branding}}</strong>
</div> </div>
<div class="column"> <div class="column">
<!-- Stop/Start video buttons --> <!-- Stop/Start video buttons -->
@ -98,6 +208,12 @@
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<a href="/about" target="_blank" class="button is-small is-link">About</a> <a href="/about" target="_blank" class="button is-small is-link">About</a>
<button type="button"
class="button is-small is-grey"
@click="showSettings()"
title="Chat Settings">
<i class="fa fa-gear"></i>
</button>
</div> </div>
</div> </div>
</header> </header>
@ -285,7 +401,7 @@
</div> </div>
<div class="media-content"> <div class="media-content">
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column"> <div class="column is-narrow">
<label class="label" <label class="label"
:class="{'has-text-success is-dark': msg.isChatServer, :class="{'has-text-success is-dark': msg.isChatServer,
'has-text-warning is-dark': msg.isAdmin, 'has-text-warning is-dark': msg.isAdmin,
@ -293,10 +409,13 @@
[[msg.username]] [[msg.username]]
</label> </label>
</div> </div>
<div class="column is-narrow" <div class="column is-narrow">
v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username)"> <small class="has-text-grey" :title="msg.at">[[prettyDate(msg.at)]]</small>
</div>
<div class="column"
v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username || isDM)">
<button type="button" <button type="button"
class="button is-dark is-outlined is-small" class="button is-grey is-outlined is-small px-2"
@click="openDMs({username: msg.username})"> @click="openDMs({username: msg.username})">
<i class="fa fa-message"></i> <i class="fa fa-message"></i>
</button> </button>
@ -420,6 +539,7 @@ const UserJWTClaims = {{.JWTClaims.ToJSON}};
</script> </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/sounds.js?{{.CacheHash}}"></script>
<script src="/static/js/BareRTC.js?{{.CacheHash}}"></script> <script src="/static/js/BareRTC.js?{{.CacheHash}}"></script>
</body> </body>