parent
fb0ac492b2
commit
169c11596c
@ -57,7 +57,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
WebServerIP: config.WebServerIP,
|
WebServerIP: config.WebServerIP,
|
||||||
RTMPServerPort: data.GetRTMPPortNumber(),
|
RTMPServerPort: data.GetRTMPPortNumber(),
|
||||||
ChatDisabled: data.GetChatDisabled(),
|
ChatDisabled: data.GetChatDisabled(),
|
||||||
ChatJoinMessagesEnabled: data.GetChatJoinMessagesEnabled(),
|
ChatJoinMessagesEnabled: data.GetChatJoinPartMessagesEnabled(),
|
||||||
SocketHostOverride: data.GetWebsocketOverrideHost(),
|
SocketHostOverride: data.GetWebsocketOverrideHost(),
|
||||||
VideoServingEndpoint: data.GetVideoServingEndpoint(),
|
VideoServingEndpoint: data.GetVideoServingEndpoint(),
|
||||||
ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(),
|
ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(),
|
||||||
|
@ -167,7 +167,6 @@ func (s *Server) userMessageSent(eventData chatClientEvent) {
|
|||||||
|
|
||||||
SaveUserMessage(event)
|
SaveUserMessage(event)
|
||||||
eventData.client.MessageCount++
|
eventData.client.MessageCount++
|
||||||
_lastSeenCache[event.User.ID] = time.Now()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func logSanitize(userValue string) string {
|
func logSanitize(userValue string) string {
|
||||||
|
@ -8,6 +8,8 @@ const (
|
|||||||
MessageSent EventType = "CHAT"
|
MessageSent EventType = "CHAT"
|
||||||
// UserJoined is the event sent when a chat user join action takes place.
|
// UserJoined is the event sent when a chat user join action takes place.
|
||||||
UserJoined EventType = "USER_JOINED"
|
UserJoined EventType = "USER_JOINED"
|
||||||
|
// UserParted is the event sent when a chat user part action takes place.
|
||||||
|
UserParted EventType = "USER_PARTED"
|
||||||
// UserNameChanged is the event sent when a chat username change takes place.
|
// UserNameChanged is the event sent when a chat username change takes place.
|
||||||
UserNameChanged EventType = "NAME_CHANGE"
|
UserNameChanged EventType = "NAME_CHANGE"
|
||||||
// UserColorChanged is the event sent when a chat user color change takes place.
|
// UserColorChanged is the event sent when a chat user color change takes place.
|
||||||
|
17
core/chat/events/userPartEvent.go
Normal file
17
core/chat/events/userPartEvent.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
// UserPartEvent is the event fired when a user leaves chat.
|
||||||
|
type UserPartEvent struct {
|
||||||
|
Event
|
||||||
|
UserEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBroadcastPayload will return the object to send to all chat users.
|
||||||
|
func (e *UserPartEvent) GetBroadcastPayload() EventPayload {
|
||||||
|
return EventPayload{
|
||||||
|
"type": UserParted,
|
||||||
|
"id": e.ID,
|
||||||
|
"timestamp": e.Timestamp,
|
||||||
|
"user": e.User,
|
||||||
|
}
|
||||||
|
}
|
@ -22,9 +22,6 @@ import (
|
|||||||
|
|
||||||
var _server *Server
|
var _server *Server
|
||||||
|
|
||||||
// a map of user IDs and when they last were active.
|
|
||||||
var _lastSeenCache = map[string]time.Time{}
|
|
||||||
|
|
||||||
// Server represents an instance of the chat server.
|
// Server represents an instance of the chat server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
clients map[uint]*Client
|
clients map[uint]*Client
|
||||||
@ -43,6 +40,9 @@ type Server struct {
|
|||||||
maxSocketConnectionLimit int64
|
maxSocketConnectionLimit int64
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// a map of user IDs and timers that fire for chat part messages.
|
||||||
|
userPartedTimers map[string]*time.Ticker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChat will return a new instance of the chat server.
|
// NewChat will return a new instance of the chat server.
|
||||||
@ -57,6 +57,7 @@ func NewChat() *Server {
|
|||||||
unregister: make(chan uint),
|
unregister: make(chan uint),
|
||||||
maxSocketConnectionLimit: maximumConcurrentConnectionLimit,
|
maxSocketConnectionLimit: maximumConcurrentConnectionLimit,
|
||||||
geoipClient: geoip.NewClient(),
|
geoipClient: geoip.NewClient(),
|
||||||
|
userPartedTimers: map[string]*time.Ticker{},
|
||||||
}
|
}
|
||||||
|
|
||||||
return server
|
return server
|
||||||
@ -67,7 +68,8 @@ func (s *Server) Run() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case clientID := <-s.unregister:
|
case clientID := <-s.unregister:
|
||||||
if _, ok := s.clients[clientID]; ok {
|
if client, ok := s.clients[clientID]; ok {
|
||||||
|
s.handleClientDisconnected(client)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.clients, clientID)
|
delete(s.clients, clientID)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@ -92,18 +94,22 @@ func (s *Server) Addclient(conn *websocket.Conn, user *user.User, accessToken st
|
|||||||
ConnectedAt: time.Now(),
|
ConnectedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not send user re-joined broadcast message if they've been active within 10 minutes.
|
shouldSendJoinedMessages := data.GetChatJoinPartMessagesEnabled()
|
||||||
shouldSendJoinedMessages := data.GetChatJoinMessagesEnabled()
|
|
||||||
if previouslyLastSeen, ok := _lastSeenCache[user.ID]; ok && time.Since(previouslyLastSeen) < time.Minute*10 {
|
|
||||||
shouldSendJoinedMessages = false
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
{
|
{
|
||||||
|
// If there is a pending disconnect timer then clear it.
|
||||||
|
// Do not send user joined message if enough time hasn't passed where the
|
||||||
|
// user chat part message hasn't been sent yet.
|
||||||
|
if ticker, ok := s.userPartedTimers[user.ID]; ok {
|
||||||
|
ticker.Stop()
|
||||||
|
delete(s.userPartedTimers, user.ID)
|
||||||
|
shouldSendJoinedMessages = false
|
||||||
|
}
|
||||||
|
|
||||||
client.Id = s.seq
|
client.Id = s.seq
|
||||||
s.clients[client.Id] = client
|
s.clients[client.Id] = client
|
||||||
s.seq++
|
s.seq++
|
||||||
_lastSeenCache[user.ID] = time.Now()
|
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@ -143,16 +149,43 @@ func (s *Server) sendUserJoinedMessage(c *Client) {
|
|||||||
webhooks.SendChatEventUserJoined(userJoinedEvent)
|
webhooks.SendChatEventUserJoined(userJoinedEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientClosed is fired when a client disconnects or connection is dropped.
|
func (s *Server) handleClientDisconnected(c *Client) {
|
||||||
func (s *Server) ClientClosed(c *Client) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
c.close()
|
|
||||||
|
|
||||||
if _, ok := s.clients[c.Id]; ok {
|
if _, ok := s.clients[c.Id]; ok {
|
||||||
log.Debugln("Deleting", c.Id)
|
log.Debugln("Deleting", c.Id)
|
||||||
delete(s.clients, c.Id)
|
delete(s.clients, c.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
additionalClientCheck, _ := GetClientsForUser(c.User.ID)
|
||||||
|
if len(additionalClientCheck) > 0 {
|
||||||
|
// This user is still connected to chat with another client.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.userPartedTimers[c.User.ID] = time.NewTicker(10 * time.Second)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-s.userPartedTimers[c.User.ID].C
|
||||||
|
s.sendUserPartedMessage(c)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendUserPartedMessage(c *Client) {
|
||||||
|
s.userPartedTimers[c.User.ID].Stop()
|
||||||
|
delete(s.userPartedTimers, c.User.ID)
|
||||||
|
|
||||||
|
userPartEvent := events.UserPartEvent{}
|
||||||
|
userPartEvent.SetDefaults()
|
||||||
|
userPartEvent.User = c.User
|
||||||
|
userPartEvent.ClientID = c.Id
|
||||||
|
|
||||||
|
// If part messages are disabled.
|
||||||
|
if data.GetChatJoinPartMessagesEnabled() {
|
||||||
|
if err := s.Broadcast(userPartEvent.GetBroadcastPayload()); err != nil {
|
||||||
|
log.Errorln("error sending chat part message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Send chat user joined webhook
|
||||||
|
webhooks.SendChatEventUserParted(userPartEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleClientConnection is fired when a single client connects to the websocket.
|
// HandleClientConnection is fired when a single client connects to the websocket.
|
||||||
|
@ -816,8 +816,8 @@ func SetChatJoinMessagesEnabled(enabled bool) error {
|
|||||||
return _datastore.SetBool(chatJoinMessagesEnabledKey, enabled)
|
return _datastore.SetBool(chatJoinMessagesEnabledKey, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChatJoinMessagesEnabled will return if chat join messages are enabled.
|
// GetChatJoinPartMessagesEnabled will return if chat join messages are enabled.
|
||||||
func GetChatJoinMessagesEnabled() bool {
|
func GetChatJoinPartMessagesEnabled() bool {
|
||||||
enabled, err := _datastore.GetBool(chatJoinMessagesEnabledKey)
|
enabled, err := _datastore.GetBool(chatJoinMessagesEnabledKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true
|
return true
|
||||||
|
@ -43,6 +43,16 @@ func SendChatEventUserJoined(event events.UserJoinedEvent) {
|
|||||||
SendEventToWebhooks(webhookEvent)
|
SendEventToWebhooks(webhookEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendChatEventUserParted sends a webhook notifying that a user has parted.
|
||||||
|
func SendChatEventUserParted(event events.UserPartEvent) {
|
||||||
|
webhookEvent := WebhookEvent{
|
||||||
|
Type: events.UserParted,
|
||||||
|
EventData: event,
|
||||||
|
}
|
||||||
|
|
||||||
|
SendEventToWebhooks(webhookEvent)
|
||||||
|
}
|
||||||
|
|
||||||
// SendChatEventSetMessageVisibility sends a webhook notifying that the visibility of one or more
|
// SendChatEventSetMessageVisibility sends a webhook notifying that the visibility of one or more
|
||||||
// messages has changed.
|
// messages has changed.
|
||||||
func SendChatEventSetMessageVisibility(event events.SetMessageVisibilityEvent) {
|
func SendChatEventSetMessageVisibility(event events.SetMessageVisibilityEvent) {
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/owncast/owncast/core/chat/events"
|
||||||
"github.com/owncast/owncast/core/data"
|
"github.com/owncast/owncast/core/data"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
jsonpatch "gopkg.in/evanphx/json-patch.v5"
|
jsonpatch "gopkg.in/evanphx/json-patch.v5"
|
||||||
@ -84,7 +85,7 @@ func TestPublicSend(t *testing.T) {
|
|||||||
|
|
||||||
// Make sure that events are only sent to interested endpoints.
|
// Make sure that events are only sent to interested endpoints.
|
||||||
func TestRouting(t *testing.T) {
|
func TestRouting(t *testing.T) {
|
||||||
eventTypes := []models.EventType{models.ChatActionSent, models.UserJoined}
|
eventTypes := []models.EventType{models.ChatActionSent, models.UserJoined, events.UserParted}
|
||||||
|
|
||||||
calls := map[models.EventType]int{}
|
calls := map[models.EventType]int{}
|
||||||
var lock sync.Mutex
|
var lock sync.Mutex
|
||||||
|
@ -14,6 +14,7 @@ import { ChatTextField } from '../ChatTextField/ChatTextField';
|
|||||||
import { ChatModeratorNotification } from '../ChatModeratorNotification/ChatModeratorNotification';
|
import { ChatModeratorNotification } from '../ChatModeratorNotification/ChatModeratorNotification';
|
||||||
import { ChatSystemMessage } from '../ChatSystemMessage/ChatSystemMessage';
|
import { ChatSystemMessage } from '../ChatSystemMessage/ChatSystemMessage';
|
||||||
import { ChatJoinMessage } from '../ChatJoinMessage/ChatJoinMessage';
|
import { ChatJoinMessage } from '../ChatJoinMessage/ChatJoinMessage';
|
||||||
|
import { ChatPartMessage } from '../ChatPartMessage/ChatPartMessage';
|
||||||
import { ScrollToBotBtn } from './ScrollToBotBtn';
|
import { ScrollToBotBtn } from './ScrollToBotBtn';
|
||||||
import { ChatActionMessage } from '../ChatActionMessage/ChatActionMessage';
|
import { ChatActionMessage } from '../ChatActionMessage/ChatActionMessage';
|
||||||
import { ChatSocialMessage } from '../ChatSocialMessage/ChatSocialMessage';
|
import { ChatSocialMessage } from '../ChatSocialMessage/ChatSocialMessage';
|
||||||
@ -137,6 +138,20 @@ export const ChatContainer: FC<ChatContainerProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUserPartMessage = (message: ChatMessage) => {
|
||||||
|
const {
|
||||||
|
user: { displayName, displayColor },
|
||||||
|
} = message;
|
||||||
|
const isAuthorModerator = checkIsModerator(message);
|
||||||
|
return (
|
||||||
|
<ChatPartMessage
|
||||||
|
displayName={displayName}
|
||||||
|
userColor={displayColor}
|
||||||
|
isAuthorModerator={isAuthorModerator}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getActionMessage = (message: ChatMessage) => {
|
const getActionMessage = (message: ChatMessage) => {
|
||||||
const { body } = message;
|
const { body } = message;
|
||||||
return <ChatActionMessage body={body} />;
|
return <ChatActionMessage body={body} />;
|
||||||
@ -185,6 +200,8 @@ export const ChatContainer: FC<ChatContainerProps> = ({
|
|||||||
return getConnectedInfoMessage(message as ConnectedClientInfoEvent);
|
return getConnectedInfoMessage(message as ConnectedClientInfoEvent);
|
||||||
case MessageType.USER_JOINED:
|
case MessageType.USER_JOINED:
|
||||||
return getUserJoinedMessage(message as ChatMessage);
|
return getUserJoinedMessage(message as ChatMessage);
|
||||||
|
case MessageType.USER_PARTED:
|
||||||
|
return getUserPartMessage(message as ChatMessage);
|
||||||
case MessageType.CHAT_ACTION:
|
case MessageType.CHAT_ACTION:
|
||||||
return getActionMessage(message as ChatMessage);
|
return getActionMessage(message as ChatMessage);
|
||||||
case MessageType.SYSTEM:
|
case MessageType.SYSTEM:
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
.root {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 10px 0;
|
||||||
|
color: var(--theme-color-components-chat-text);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--chat-message-text-size);
|
||||||
|
|
||||||
|
.moderatorBadge,
|
||||||
|
.user {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
padding: 0 var(--chat-notification-icon-padding) 0 16px;
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { ChatPartMessage } from './ChatPartMessage';
|
||||||
|
import Mock from '../../../stories/assets/mocks/chatmessage-action.png';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'owncast/Chat/Messages/Chat Part',
|
||||||
|
component: ChatPartMessage,
|
||||||
|
argTypes: {
|
||||||
|
userColor: {
|
||||||
|
options: ['0', '1', '2', '3', '4', '5', '6', '7'],
|
||||||
|
control: { type: 'select' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
design: {
|
||||||
|
type: 'image',
|
||||||
|
url: Mock,
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: `This is shown when a chat participant parts.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ComponentMeta<typeof ChatPartMessage>;
|
||||||
|
|
||||||
|
const Template: ComponentStory<typeof ChatPartMessage> = args => <ChatPartMessage {...args} />;
|
||||||
|
|
||||||
|
export const Regular = Template.bind({});
|
||||||
|
Regular.args = {
|
||||||
|
displayName: 'RandomChatter',
|
||||||
|
isAuthorModerator: false,
|
||||||
|
userColor: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Moderator = Template.bind({});
|
||||||
|
Moderator.args = {
|
||||||
|
displayName: 'RandomChatter',
|
||||||
|
isAuthorModerator: true,
|
||||||
|
userColor: 2,
|
||||||
|
};
|
42
web/components/chat/ChatPartMessage/ChatPartMessage.tsx
Normal file
42
web/components/chat/ChatPartMessage/ChatPartMessage.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ModerationBadge } from '../ChatUserBadge/ModerationBadge';
|
||||||
|
|
||||||
|
import styles from './ChatPartMessage.module.scss';
|
||||||
|
|
||||||
|
// Lazy loaded components
|
||||||
|
|
||||||
|
const TeamOutlined = dynamic(() => import('@ant-design/icons/TeamOutlined'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChatPartMessageProps = {
|
||||||
|
isAuthorModerator: boolean;
|
||||||
|
userColor: number;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatPartMessage: FC<ChatPartMessageProps> = ({
|
||||||
|
isAuthorModerator,
|
||||||
|
userColor,
|
||||||
|
displayName,
|
||||||
|
}) => {
|
||||||
|
const color = `var(--theme-color-users-${userColor})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<span style={{ color }}>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
<TeamOutlined />
|
||||||
|
</span>
|
||||||
|
<span className={styles.user}>{displayName}</span>
|
||||||
|
{isAuthorModerator && (
|
||||||
|
<span className={styles.moderatorBadge}>
|
||||||
|
<ModerationBadge userColor={userColor} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
left the chat.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -322,6 +322,9 @@ export const ClientConfigStore: FC = () => {
|
|||||||
case MessageType.USER_JOINED:
|
case MessageType.USER_JOINED:
|
||||||
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
||||||
break;
|
break;
|
||||||
|
case MessageType.USER_PARTED:
|
||||||
|
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
||||||
|
break;
|
||||||
case MessageType.SYSTEM:
|
case MessageType.SYSTEM:
|
||||||
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
setChatMessages(currentState => [...currentState, message as ChatEvent]);
|
||||||
break;
|
break;
|
||||||
|
@ -8,6 +8,7 @@ export enum MessageType {
|
|||||||
PONG = 'PONG',
|
PONG = 'PONG',
|
||||||
SYSTEM = 'SYSTEM',
|
SYSTEM = 'SYSTEM',
|
||||||
USER_JOINED = 'USER_JOINED',
|
USER_JOINED = 'USER_JOINED',
|
||||||
|
USER_PARTED = 'USER_PARTED',
|
||||||
CHAT_ACTION = 'CHAT_ACTION',
|
CHAT_ACTION = 'CHAT_ACTION',
|
||||||
FEDIVERSE_ENGAGEMENT_FOLLOW = 'FEDIVERSE_ENGAGEMENT_FOLLOW',
|
FEDIVERSE_ENGAGEMENT_FOLLOW = 'FEDIVERSE_ENGAGEMENT_FOLLOW',
|
||||||
FEDIVERSE_ENGAGEMENT_LIKE = 'FEDIVERSE_ENGAGEMENT_LIKE',
|
FEDIVERSE_ENGAGEMENT_LIKE = 'FEDIVERSE_ENGAGEMENT_LIKE',
|
||||||
|
@ -30,6 +30,7 @@ const DeleteOutlined = dynamic(() => import('@ant-design/icons/DeleteOutlined'),
|
|||||||
const availableEvents = {
|
const availableEvents = {
|
||||||
CHAT: { name: 'Chat messages', description: 'When a user sends a chat message', color: 'purple' },
|
CHAT: { name: 'Chat messages', description: 'When a user sends a chat message', color: 'purple' },
|
||||||
USER_JOINED: { name: 'User joined', description: 'When a user joins the chat', color: 'green' },
|
USER_JOINED: { name: 'User joined', description: 'When a user joins the chat', color: 'green' },
|
||||||
|
USER_PARTED: { name: 'User parted', description: 'When a user leaves the chat', color: 'green' },
|
||||||
NAME_CHANGE: {
|
NAME_CHANGE: {
|
||||||
name: 'User name changed',
|
name: 'User name changed',
|
||||||
description: 'When a user changes their name',
|
description: 'When a user changes their name',
|
||||||
|
Loading…
Reference in New Issue
Block a user