mirror of
https://github.com/owncast/owncast.git
synced 2025-11-02 20:23:29 +08:00
Chat updates (#92)
* Send PONG responses to PINGs * Split out client IDs for viewer counts vs. websocket IDs * WIP username change event * Display username changes * Revert commented out code * Add support for building from the current branch * Fix PONG * Make username changes have a unique ID * Add a version param to js to cachebust
This commit is contained in:
@ -1,6 +1,7 @@
|
|||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
@ -21,14 +22,23 @@ type Client struct {
|
|||||||
ConnectedAt time.Time
|
ConnectedAt time.Time
|
||||||
MessageCount int
|
MessageCount int
|
||||||
|
|
||||||
id string
|
clientID string // How we identify unique viewers when counting viewer counts.
|
||||||
ws *websocket.Conn
|
socketID string // How we identify a single websocket client.
|
||||||
ch chan models.ChatMessage
|
ws *websocket.Conn
|
||||||
pingch chan models.PingMessage
|
ch chan models.ChatMessage
|
||||||
|
pingch chan models.PingMessage
|
||||||
|
usernameChangeChannel chan models.NameChangeEvent
|
||||||
|
|
||||||
doneCh chan bool
|
doneCh chan bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
CHAT = "CHAT"
|
||||||
|
NAMECHANGE = "NAME_CHANGE"
|
||||||
|
PING = "PING"
|
||||||
|
PONG = "PONG"
|
||||||
|
)
|
||||||
|
|
||||||
//NewClient creates a new chat client
|
//NewClient creates a new chat client
|
||||||
func NewClient(ws *websocket.Conn) *Client {
|
func NewClient(ws *websocket.Conn) *Client {
|
||||||
if ws == nil {
|
if ws == nil {
|
||||||
@ -38,9 +48,12 @@ func NewClient(ws *websocket.Conn) *Client {
|
|||||||
ch := make(chan models.ChatMessage, channelBufSize)
|
ch := make(chan models.ChatMessage, channelBufSize)
|
||||||
doneCh := make(chan bool)
|
doneCh := make(chan bool)
|
||||||
pingch := make(chan models.PingMessage)
|
pingch := make(chan models.PingMessage)
|
||||||
clientID := utils.GenerateClientIDFromRequest(ws.Request())
|
usernameChangeChannel := make(chan models.NameChangeEvent)
|
||||||
|
|
||||||
return &Client{time.Now(), 0, clientID, ws, ch, pingch, doneCh}
|
clientID := utils.GenerateClientIDFromRequest(ws.Request())
|
||||||
|
socketID, _ := shortid.Generate()
|
||||||
|
|
||||||
|
return &Client{time.Now(), 0, clientID, socketID, ws, ch, pingch, usernameChangeChannel, doneCh}
|
||||||
}
|
}
|
||||||
|
|
||||||
//GetConnection gets the connection for the client
|
//GetConnection gets the connection for the client
|
||||||
@ -53,7 +66,7 @@ func (c *Client) Write(msg models.ChatMessage) {
|
|||||||
case c.ch <- msg:
|
case c.ch <- msg:
|
||||||
default:
|
default:
|
||||||
_server.remove(c)
|
_server.remove(c)
|
||||||
_server.err(fmt.Errorf("client %s is disconnected", c.id))
|
_server.err(fmt.Errorf("client %s is disconnected", c.clientID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +92,8 @@ func (c *Client) listenWrite() {
|
|||||||
case msg := <-c.ch:
|
case msg := <-c.ch:
|
||||||
// log.Println("Send:", msg)
|
// log.Println("Send:", msg)
|
||||||
websocket.JSON.Send(c.ws, msg)
|
websocket.JSON.Send(c.ws, msg)
|
||||||
|
case msg := <-c.usernameChangeChannel:
|
||||||
|
websocket.JSON.Send(c.ws, msg)
|
||||||
// receive done request
|
// receive done request
|
||||||
case <-c.doneCh:
|
case <-c.doneCh:
|
||||||
_server.remove(c)
|
_server.remove(c)
|
||||||
@ -102,28 +116,59 @@ func (c *Client) listenRead() {
|
|||||||
|
|
||||||
// read data from websocket connection
|
// read data from websocket connection
|
||||||
default:
|
default:
|
||||||
var msg models.ChatMessage
|
var data []byte
|
||||||
id, err := shortid.Generate()
|
err := websocket.Message.Receive(c.ws, &data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Panicln(err)
|
if err == io.EOF {
|
||||||
|
c.doneCh <- true
|
||||||
|
} else {
|
||||||
|
log.Errorln(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg.ID = id
|
var messageTypeCheck map[string]interface{}
|
||||||
msg.MessageType = "CHAT"
|
err = json.Unmarshal(data, &messageTypeCheck)
|
||||||
msg.Timestamp = time.Now()
|
if err != nil {
|
||||||
msg.Visible = true
|
log.Errorln(err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := websocket.JSON.Receive(c.ws, &msg); err == io.EOF {
|
messageType := messageTypeCheck["type"]
|
||||||
c.doneCh <- true
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
_server.err(err)
|
|
||||||
} else {
|
|
||||||
c.MessageCount++
|
|
||||||
|
|
||||||
msg.ClientID = c.id
|
if messageType == CHAT {
|
||||||
_server.SendToAll(msg)
|
c.chatMessageReceived(data)
|
||||||
|
} else if messageType == NAMECHANGE {
|
||||||
|
c.userChangedName(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) userChangedName(data []byte) {
|
||||||
|
var msg models.NameChangeEvent
|
||||||
|
err := json.Unmarshal(data, &msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln(err)
|
||||||
|
}
|
||||||
|
msg.Type = NAMECHANGE
|
||||||
|
msg.ID = shortid.MustGenerate()
|
||||||
|
_server.usernameChanged(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) chatMessageReceived(data []byte) {
|
||||||
|
var msg models.ChatMessage
|
||||||
|
err := json.Unmarshal(data, &msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := shortid.Generate()
|
||||||
|
msg.ID = id
|
||||||
|
msg.Timestamp = time.Now()
|
||||||
|
msg.Visible = true
|
||||||
|
|
||||||
|
c.MessageCount++
|
||||||
|
|
||||||
|
msg.ClientID = c.clientID
|
||||||
|
_server.SendToAll(msg)
|
||||||
|
}
|
||||||
|
|||||||
@ -64,17 +64,23 @@ func (s *server) sendAll(msg models.ChatMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) ping() {
|
func (s *server) ping() {
|
||||||
ping := models.PingMessage{MessageType: "PING"}
|
ping := models.PingMessage{MessageType: PING}
|
||||||
for _, c := range s.Clients {
|
for _, c := range s.Clients {
|
||||||
c.pingch <- ping
|
c.pingch <- ping
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) usernameChanged(msg models.NameChangeEvent) {
|
||||||
|
for _, c := range s.Clients {
|
||||||
|
c.usernameChangeChannel <- msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) onConnection(ws *websocket.Conn) {
|
func (s *server) onConnection(ws *websocket.Conn) {
|
||||||
client := NewClient(ws)
|
client := NewClient(ws)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
log.Tracef("The client was connected for %s and sent %d messages (%s)", time.Since(client.ConnectedAt), client.MessageCount, client.id)
|
log.Tracef("The client was connected for %s and sent %d messages (%s)", time.Since(client.ConnectedAt), client.MessageCount, client.clientID)
|
||||||
|
|
||||||
if err := ws.Close(); err != nil {
|
if err := ws.Close(); err != nil {
|
||||||
s.errCh <- err
|
s.errCh <- err
|
||||||
@ -96,15 +102,14 @@ func (s *server) Listen() {
|
|||||||
select {
|
select {
|
||||||
// add new a client
|
// add new a client
|
||||||
case c := <-s.addCh:
|
case c := <-s.addCh:
|
||||||
s.Clients[c.id] = c
|
s.Clients[c.socketID] = c
|
||||||
|
s.listener.ClientAdded(c.clientID)
|
||||||
s.listener.ClientAdded(c.id)
|
|
||||||
s.sendWelcomeMessageToClient(c)
|
s.sendWelcomeMessageToClient(c)
|
||||||
|
|
||||||
// remove a client
|
// remove a client
|
||||||
case c := <-s.delCh:
|
case c := <-s.delCh:
|
||||||
delete(s.Clients, c.id)
|
delete(s.Clients, c.socketID)
|
||||||
s.listener.ClientRemoved(c.id)
|
s.listener.ClientRemoved(c.clientID)
|
||||||
|
|
||||||
// broadcast a message to all clients
|
// broadcast a message to all clients
|
||||||
case msg := <-s.sendAllCh:
|
case msg := <-s.sendAllCh:
|
||||||
|
|||||||
10
models/nameChangeEvent.go
Normal file
10
models/nameChangeEvent.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
//NameChangeEvent represents a user changing their name in chat
|
||||||
|
type NameChangeEvent struct {
|
||||||
|
OldName string `json:"oldName"`
|
||||||
|
NewName string `json:"newName"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ fi
|
|||||||
|
|
||||||
[[ -z "${VERSION}" ]] && VERSION='unknownver' || VERSION="${VERSION}"
|
[[ -z "${VERSION}" ]] && VERSION='unknownver' || VERSION="${VERSION}"
|
||||||
GIT_COMMIT=$(git rev-list -1 HEAD)
|
GIT_COMMIT=$(git rev-list -1 HEAD)
|
||||||
|
GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
# Change to the root directory of the repository
|
# Change to the root directory of the repository
|
||||||
cd $(git rev-parse --show-toplevel)
|
cd $(git rev-parse --show-toplevel)
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ build() {
|
|||||||
VERSION=$4
|
VERSION=$4
|
||||||
GIT_COMMIT=$5
|
GIT_COMMIT=$5
|
||||||
|
|
||||||
echo "Building ${NAME} (${OS}/${ARCH}) release..."
|
echo "Building ${NAME} (${OS}/${ARCH}) release from ${GIT_BRANCH}..."
|
||||||
|
|
||||||
mkdir -p dist/${NAME}
|
mkdir -p dist/${NAME}
|
||||||
mkdir -p dist/${NAME}/webroot/static
|
mkdir -p dist/${NAME}/webroot/static
|
||||||
@ -51,7 +51,7 @@ build() {
|
|||||||
|
|
||||||
pushd dist/${NAME} >> /dev/null
|
pushd dist/${NAME} >> /dev/null
|
||||||
|
|
||||||
CGO_ENABLED=1 ~/go/bin/xgo -ldflags "-s -w -X main.GitCommit=${GIT_COMMIT} -X main.BuildVersion=${VERSION} -X main.BuildType=${NAME}" -targets "${OS}/${ARCH}" github.com/gabek/owncast
|
CGO_ENABLED=1 ~/go/bin/xgo --branch ${GIT_BRANCH} -ldflags "-s -w -X main.GitCommit=${GIT_COMMIT} -X main.BuildVersion=${VERSION} -X main.BuildType=${NAME}" -targets "${OS}/${ARCH}" github.com/gabek/owncast
|
||||||
mv owncast-*-${ARCH} owncast
|
mv owncast-*-${ARCH} owncast
|
||||||
|
|
||||||
zip -r -q -8 ../owncast-$NAME-$VERSION.zip .
|
zip -r -q -8 ../owncast-$NAME-$VERSION.zip .
|
||||||
|
|||||||
@ -123,7 +123,8 @@
|
|||||||
<div id="chat-container" class="bg-gray-800">
|
<div id="chat-container" class="bg-gray-800">
|
||||||
<div id="messages-container">
|
<div id="messages-container">
|
||||||
<div v-for="message in messages" v-cloak>
|
<div v-for="message in messages" v-cloak>
|
||||||
<div class="message flex">
|
<!-- Regular user chat message-->
|
||||||
|
<div class="message flex" v-if="message.type === 'CHAT'">
|
||||||
<div class="message-avatar rounded-full flex items-center justify-center" v-bind:style="{ backgroundColor: message.userColor() }">
|
<div class="message-avatar rounded-full flex items-center justify-center" v-bind:style="{ backgroundColor: message.userColor() }">
|
||||||
<img
|
<img
|
||||||
v-bind:src="message.image"
|
v-bind:src="message.image"
|
||||||
@ -134,6 +135,19 @@
|
|||||||
<p class="message-text text-gray-400 font-thin " v-html="message.formatText()"></p>
|
<p class="message-text text-gray-400 font-thin " v-html="message.formatText()"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Username change message -->
|
||||||
|
<div class="message flex" v-else-if="message.type === 'NAME_CHANGE'">
|
||||||
|
<img
|
||||||
|
class="mr-2"
|
||||||
|
width="30px"
|
||||||
|
v-bind:src="message.image"
|
||||||
|
/>
|
||||||
|
<div class="text-white text-center">
|
||||||
|
<span class="font-bold">{{ message.oldName }}</span> is now known as <span class="font-bold">{{ message.newName }}</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -166,12 +180,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/usercolors.js"></script>
|
<script src="js/usercolors.js"></script>
|
||||||
<script src="js/utils.js"></script>
|
<script src="js/utils.js?v=2"></script>
|
||||||
<script src="js/message.js"></script>
|
<script src="js/message.js?v=2"></script>
|
||||||
<script src="js/social.js"></script>
|
<script src="js/social.js"></script>
|
||||||
<script src="js/components.js"></script>
|
<script src="js/components.js"></script>
|
||||||
<script src="js/player.js"></script>
|
<script src="js/player.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js?v=2"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const app = new Owncast();
|
const app = new Owncast();
|
||||||
|
|||||||
@ -114,6 +114,11 @@ class Owncast {
|
|||||||
if (this.websocketReconnectTimer) {
|
if (this.websocketReconnectTimer) {
|
||||||
clearTimeout(this.websocketReconnectTimer);
|
clearTimeout(this.websocketReconnectTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're "online" then enable the chat.
|
||||||
|
if (this.streamStatus && this.streamStatus.online) {
|
||||||
|
this.messagingInterface.enableChat();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
ws.onclose = (e) => {
|
ws.onclose = (e) => {
|
||||||
// connection closed, discard old websocket and create a new one in 5s
|
// connection closed, discard old websocket and create a new one in 5s
|
||||||
@ -124,22 +129,36 @@ class Owncast {
|
|||||||
};
|
};
|
||||||
// On ws error just close the socket and let it re-connect again for now.
|
// On ws error just close the socket and let it re-connect again for now.
|
||||||
ws.onerror = e => {
|
ws.onerror = e => {
|
||||||
this.handleNetworkingError(`Stream status: ${e}`);
|
this.handleNetworkingError(`Socket error: ${JSON.parse(e)}`);
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const model = JSON.parse(e.data);
|
const model = JSON.parse(e.data);
|
||||||
// Ignore non-chat messages (such as keepalive PINGs)
|
|
||||||
if (model.type !== SOCKET_MESSAGE_TYPES.CHAT) {
|
// Send PONGs
|
||||||
return;
|
if (model.type === SOCKET_MESSAGE_TYPES.PING) {
|
||||||
|
this.sendPong(ws);
|
||||||
|
return;
|
||||||
|
} else if (model.type === SOCKET_MESSAGE_TYPES.CHAT) {
|
||||||
|
const message = new Message(model);
|
||||||
|
this.addMessage(message);
|
||||||
|
} else if (model.type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
||||||
|
this.addMessage(model);
|
||||||
}
|
}
|
||||||
const message = new Message(model);
|
|
||||||
this.addMessage(message);
|
|
||||||
};
|
};
|
||||||
this.websocket = ws;
|
this.websocket = ws;
|
||||||
this.messagingInterface.setWebsocket(this.websocket);
|
this.messagingInterface.setWebsocket(this.websocket);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sendPong(ws) {
|
||||||
|
try {
|
||||||
|
const pong = { type: SOCKET_MESSAGE_TYPES.PONG };
|
||||||
|
ws.send(JSON.stringify(pong));
|
||||||
|
} catch (e) {
|
||||||
|
console.log('PONG error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addMessage(message) {
|
addMessage(message) {
|
||||||
const existing = this.vueApp.messages.filter(function (item) {
|
const existing = this.vueApp.messages.filter(function (item) {
|
||||||
return item.id === message.id;
|
return item.id === message.id;
|
||||||
|
|||||||
@ -142,6 +142,7 @@ class MessagingInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleUpdateUsername() {
|
handleUpdateUsername() {
|
||||||
|
const oldName = this.username;
|
||||||
var newValue = this.inputChangeUserName.value;
|
var newValue = this.inputChangeUserName.value;
|
||||||
newValue = newValue.trim();
|
newValue = newValue.trim();
|
||||||
// do other string cleanup?
|
// do other string cleanup?
|
||||||
@ -154,6 +155,10 @@ class MessagingInterface {
|
|||||||
setLocalStorage(KEY_AVATAR, this.imgUsernameAvatar.src);
|
setLocalStorage(KEY_AVATAR, this.imgUsernameAvatar.src);
|
||||||
}
|
}
|
||||||
this.handleHideChangeNameForm();
|
this.handleHideChangeNameForm();
|
||||||
|
|
||||||
|
if (oldName !== newValue) {
|
||||||
|
this.sendUsernameChange(oldName, newValue, this.imgUsernameAvatar.src);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUsernameKeydown(event) {
|
handleUsernameKeydown(event) {
|
||||||
@ -164,6 +169,19 @@ class MessagingInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendUsernameChange(oldName, newName, image) {
|
||||||
|
const nameChange = {
|
||||||
|
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
||||||
|
oldName: oldName,
|
||||||
|
newName: newName,
|
||||||
|
image: image,
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonMessage = JSON.stringify(nameChange);
|
||||||
|
|
||||||
|
this.websocket.send(jsonMessage)
|
||||||
|
}
|
||||||
|
|
||||||
handleMessageInputKeydown(event) {
|
handleMessageInputKeydown(event) {
|
||||||
var okCodes = [37,38,39,40,16,91,18,46,8];
|
var okCodes = [37,38,39,40,16,91,18,46,8];
|
||||||
var value = this.formMessageInput.value.trim();
|
var value = this.formMessageInput.value.trim();
|
||||||
@ -213,6 +231,7 @@ class MessagingInterface {
|
|||||||
body: content,
|
body: content,
|
||||||
author: this.username,
|
author: this.username,
|
||||||
image: this.imgUsernameAvatar.src,
|
image: this.imgUsernameAvatar.src,
|
||||||
|
type: SOCKET_MESSAGE_TYPES.CHAT,
|
||||||
});
|
});
|
||||||
const messageJSON = JSON.stringify(message);
|
const messageJSON = JSON.stringify(message);
|
||||||
if (this.websocket) {
|
if (this.websocket) {
|
||||||
|
|||||||
@ -84,5 +84,5 @@ function messageBubbleColorForString(str) {
|
|||||||
g: parseInt(result[2], 16),
|
g: parseInt(result[2], 16),
|
||||||
b: parseInt(result[3], 16),
|
b: parseInt(result[3], 16),
|
||||||
} : null;
|
} : null;
|
||||||
return 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ', 0.3)';
|
return 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ', 0.4)';
|
||||||
}
|
}
|
||||||
@ -6,7 +6,7 @@ const URL_PREFIX = LOCAL_TEST ? 'http://localhost:8080' : '';
|
|||||||
const URL_STATUS = `${URL_PREFIX}/status`;
|
const URL_STATUS = `${URL_PREFIX}/status`;
|
||||||
const URL_CHAT_HISTORY = `${URL_PREFIX}/chat`;
|
const URL_CHAT_HISTORY = `${URL_PREFIX}/chat`;
|
||||||
const URL_STREAM = `${URL_PREFIX}/hls/stream.m3u8`;
|
const URL_STREAM = `${URL_PREFIX}/hls/stream.m3u8`;
|
||||||
const URL_WEBSOCKET = LOCAL_TEST
|
const URL_WEBSOCKET = LOCAL_TEST
|
||||||
? 'wss://goth.land/entry'
|
? 'wss://goth.land/entry'
|
||||||
: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
||||||
|
|
||||||
@ -21,7 +21,9 @@ const URL_OWNCAST = 'https://github.com/gabek/owncast'; // used in footer
|
|||||||
// Webscoket setup
|
// Webscoket setup
|
||||||
const SOCKET_MESSAGE_TYPES = {
|
const SOCKET_MESSAGE_TYPES = {
|
||||||
CHAT: 'CHAT',
|
CHAT: 'CHAT',
|
||||||
PING: 'PING'
|
PING: 'PING',
|
||||||
|
NAME_CHANGE: 'NAME_CHANGE',
|
||||||
|
PONG: 'PONG'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video setup
|
// Video setup
|
||||||
|
|||||||
Reference in New Issue
Block a user