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:
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.Setup()
log.Info("Listening at %s", address)
panic(app.ListenAndServe(address))
}

View File

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

View File

@ -108,10 +108,11 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
// Message to be echoed to the channel.
var message = Message{
Action: ActionMessage,
Channel: msg.Channel,
Username: sub.Username,
Message: markdown,
Action: ActionMessage,
Channel: msg.Channel,
Username: sub.Username,
Message: markdown,
Timestamp: time.Now(),
}
// Is this a DM?
@ -119,7 +120,9 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
// Echo the message only to both parties.
s.SendTo(sub.Username, message)
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
}

View File

@ -1,10 +1,13 @@
package barertc
import "time"
type Message struct {
Action string `json:"action,omitempty"`
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
Message string `json:"message,omitempty"`
Action string `json:"action,omitempty"`
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
Message string `json:"message,omitempty"`
Timestamp time.Time `json:"at,omitempty"`
// JWT token for `login` actions.
JWTToken string `json:"jwt,omitempty"`

View File

@ -239,23 +239,26 @@ func (s *Server) Broadcast(msg Message) {
continue
}
sub.SendJSON(Message{
Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username,
Message: msg.Message,
})
sub.SendJSON(msg)
}
}
// 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)
username = strings.TrimPrefix(username, "@")
s.subscribersMu.RLock()
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) {
if sub.Username == username {
found = true
sub.SendJSON(Message{
Action: msg.Action,
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.
@ -292,8 +300,9 @@ func (s *Server) SendWhoList() {
for _, sub := range subscribers {
sub.SendJSON(Message{
Action: ActionWhoList,
WhoList: users,
Action: ActionWhoList,
WhoList: users,
Timestamp: time.Now(),
})
}
}

View File

@ -18,6 +18,13 @@ const app = Vue.createApp({
config: {
channels: PublicChannels,
website: WebsiteURL,
sounds: {
available: SoundEffects,
settings: DefaultSounds,
ready: false,
audioContext: null,
audioTracks: {},
}
},
// User JWT settings if available.
@ -96,9 +103,15 @@ const app = Vue.createApp({
loginModal: {
visible: false,
},
settingsModal: {
visible: false,
},
}
},
mounted() {
this.setupSounds();
this.webcam.elem = document.querySelector("#localVideo");
this.historyScrollbox = document.querySelector("#chatHistory");
@ -280,10 +293,20 @@ const app = Vue.createApp({
// Handle messages sent in chat.
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({
channel: msg.channel,
username: msg.username,
message: msg.message,
at: msg.at,
});
},
@ -293,6 +316,9 @@ const app = Vue.createApp({
if (msg.message.indexOf("has exited the room!") > -1) {
// Clean up data about this user.
this.onUserExited(msg);
this.playSound("Leave");
} else {
this.playSound("Enter");
}
// Push it to the history of all public channels.
@ -302,6 +328,19 @@ const app = Vue.createApp({
action: msg.action,
username: msg.username,
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}`);
if (ev.code !== 1001) {
this.ChatClient("Reconnecting in 1s");
setTimeout(this.dial, 1000);
this.ChatClient("Reconnecting in 5s");
setTimeout(this.dial, 5000);
}
});
@ -345,6 +384,13 @@ const app = Vue.createApp({
}
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) {
case "who":
console.log("Got the Who List: %s", msg);
@ -384,7 +430,12 @@ const app = Vue.createApp({
username: msg.username || 'Internal Server Error',
message: msg.message,
isChatServer: true,
at: new Date(),
});
break;
case "ping":
console.debug("Received ping from server");
break;
default:
console.error("Unexpected action: %s", JSON.stringify(msg));
}
@ -574,6 +625,14 @@ const app = Vue.createApp({
* Front-end web app concerns.
*/
// Settings modal.
showSettings() {
this.settingsModal.visible = true;
},
hideSettings() {
this.settingsModal.visible = false;
},
// Set active chat room.
setChannel(channel) {
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.
if (!channel) {
channel = this.channel;
@ -818,6 +877,7 @@ const app = Vue.createApp({
action: action,
username: username,
message: message,
at: at || new Date(),
isChatServer,
isChatClient,
});
@ -894,6 +954,77 @@ const app = Vue.createApp({
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" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
<title>BareRTC</title>
<title>{{.Config.Title}}</title>
</head>
<body>
<div id="BareRTC-App">
<!-- Sign In modal -->
<div class="modal" :class="{'is-active': loginModal.visible}">
<div class="modal-background"></div>
@ -45,13 +46,122 @@
</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">
<!-- Top header panel -->
<header class="chat-header">
<div class="columns is-mobile">
<div class="column is-narrow">
<strong class="is-6">{{AsHTML .Config.Title}}</strong>
<strong class="is-6">{{AsHTML .Config.Branding}}</strong>
</div>
<div class="column">
<!-- Stop/Start video buttons -->
@ -98,6 +208,12 @@
</div>
<div class="column is-narrow">
<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>
</header>
@ -285,7 +401,7 @@
</div>
<div class="media-content">
<div class="columns is-mobile">
<div class="column">
<div class="column is-narrow">
<label class="label"
:class="{'has-text-success is-dark': msg.isChatServer,
'has-text-warning is-dark': msg.isAdmin,
@ -293,10 +409,13 @@
[[msg.username]]
</label>
</div>
<div class="column is-narrow"
v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username)">
<div class="column is-narrow">
<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"
class="button is-dark is-outlined is-small"
class="button is-grey is-outlined is-small px-2"
@click="openDMs({username: msg.username})">
<i class="fa fa-message"></i>
</button>
@ -420,6 +539,7 @@ const UserJWTClaims = {{.JWTClaims.ToJSON}};
</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>
</body>