From 169c11596cd785e39b29b0c24ca8880d59f3e86d Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sun, 10 Sep 2023 10:58:11 -0700 Subject: [PATCH] feat(chat): add support for chat part messages. Closes #3201 (#3291) --- controllers/admin/serverConfig.go | 2 +- core/chat/events.go | 1 - core/chat/events/eventtype.go | 2 + core/chat/events/userPartEvent.go | 17 +++++ core/chat/server.go | 65 ++++++++++++++----- core/data/config.go | 4 +- core/webhooks/chat.go | 10 +++ core/webhooks/webhooks_test.go | 3 +- .../chat/ChatContainer/ChatContainer.tsx | 17 +++++ .../ChatPartMessage.module.scss | 16 +++++ .../ChatPartMessage.stories.tsx | 42 ++++++++++++ .../chat/ChatPartMessage/ChatPartMessage.tsx | 42 ++++++++++++ web/components/stores/ClientConfigStore.tsx | 3 + web/interfaces/socket-events.ts | 1 + web/pages/admin/webhooks.tsx | 1 + 15 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 core/chat/events/userPartEvent.go create mode 100644 web/components/chat/ChatPartMessage/ChatPartMessage.module.scss create mode 100644 web/components/chat/ChatPartMessage/ChatPartMessage.stories.tsx create mode 100644 web/components/chat/ChatPartMessage/ChatPartMessage.tsx diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index 0a5269c63..2429fdfa1 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -57,7 +57,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { WebServerIP: config.WebServerIP, RTMPServerPort: data.GetRTMPPortNumber(), ChatDisabled: data.GetChatDisabled(), - ChatJoinMessagesEnabled: data.GetChatJoinMessagesEnabled(), + ChatJoinMessagesEnabled: data.GetChatJoinPartMessagesEnabled(), SocketHostOverride: data.GetWebsocketOverrideHost(), VideoServingEndpoint: data.GetVideoServingEndpoint(), ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(), diff --git a/core/chat/events.go b/core/chat/events.go index 309cd368a..3becc413e 100644 --- a/core/chat/events.go +++ b/core/chat/events.go @@ -167,7 +167,6 @@ func (s *Server) userMessageSent(eventData chatClientEvent) { SaveUserMessage(event) eventData.client.MessageCount++ - _lastSeenCache[event.User.ID] = time.Now() } func logSanitize(userValue string) string { diff --git a/core/chat/events/eventtype.go b/core/chat/events/eventtype.go index 3a8869845..2cc05b58f 100644 --- a/core/chat/events/eventtype.go +++ b/core/chat/events/eventtype.go @@ -8,6 +8,8 @@ const ( MessageSent EventType = "CHAT" // UserJoined is the event sent when a chat user join action takes place. 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 EventType = "NAME_CHANGE" // UserColorChanged is the event sent when a chat user color change takes place. diff --git a/core/chat/events/userPartEvent.go b/core/chat/events/userPartEvent.go new file mode 100644 index 000000000..f0ef14b7d --- /dev/null +++ b/core/chat/events/userPartEvent.go @@ -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, + } +} diff --git a/core/chat/server.go b/core/chat/server.go index 06550532a..6644be8dc 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -22,9 +22,6 @@ import ( 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. type Server struct { clients map[uint]*Client @@ -43,6 +40,9 @@ type Server struct { maxSocketConnectionLimit int64 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. @@ -57,6 +57,7 @@ func NewChat() *Server { unregister: make(chan uint), maxSocketConnectionLimit: maximumConcurrentConnectionLimit, geoipClient: geoip.NewClient(), + userPartedTimers: map[string]*time.Ticker{}, } return server @@ -67,7 +68,8 @@ func (s *Server) Run() { for { select { case clientID := <-s.unregister: - if _, ok := s.clients[clientID]; ok { + if client, ok := s.clients[clientID]; ok { + s.handleClientDisconnected(client) s.mu.Lock() delete(s.clients, clientID) s.mu.Unlock() @@ -92,18 +94,22 @@ func (s *Server) Addclient(conn *websocket.Conn, user *user.User, accessToken st ConnectedAt: time.Now(), } - // Do not send user re-joined broadcast message if they've been active within 10 minutes. - shouldSendJoinedMessages := data.GetChatJoinMessagesEnabled() - if previouslyLastSeen, ok := _lastSeenCache[user.ID]; ok && time.Since(previouslyLastSeen) < time.Minute*10 { - shouldSendJoinedMessages = false - } + shouldSendJoinedMessages := data.GetChatJoinPartMessagesEnabled() 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 s.clients[client.Id] = client s.seq++ - _lastSeenCache[user.ID] = time.Now() } s.mu.Unlock() @@ -143,16 +149,43 @@ func (s *Server) sendUserJoinedMessage(c *Client) { webhooks.SendChatEventUserJoined(userJoinedEvent) } -// ClientClosed is fired when a client disconnects or connection is dropped. -func (s *Server) ClientClosed(c *Client) { - s.mu.Lock() - defer s.mu.Unlock() - c.close() - +func (s *Server) handleClientDisconnected(c *Client) { if _, ok := s.clients[c.Id]; ok { log.Debugln("Deleting", 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. diff --git a/core/data/config.go b/core/data/config.go index fe71c9371..c8e4e5bad 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -816,8 +816,8 @@ func SetChatJoinMessagesEnabled(enabled bool) error { return _datastore.SetBool(chatJoinMessagesEnabledKey, enabled) } -// GetChatJoinMessagesEnabled will return if chat join messages are enabled. -func GetChatJoinMessagesEnabled() bool { +// GetChatJoinPartMessagesEnabled will return if chat join messages are enabled. +func GetChatJoinPartMessagesEnabled() bool { enabled, err := _datastore.GetBool(chatJoinMessagesEnabledKey) if err != nil { return true diff --git a/core/webhooks/chat.go b/core/webhooks/chat.go index 1fb5287f3..a7635371d 100644 --- a/core/webhooks/chat.go +++ b/core/webhooks/chat.go @@ -43,6 +43,16 @@ func SendChatEventUserJoined(event events.UserJoinedEvent) { 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 // messages has changed. func SendChatEventSetMessageVisibility(event events.SetMessageVisibilityEvent) { diff --git a/core/webhooks/webhooks_test.go b/core/webhooks/webhooks_test.go index b79f75b0e..e10b02ee8 100644 --- a/core/webhooks/webhooks_test.go +++ b/core/webhooks/webhooks_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/models" 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. 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{} var lock sync.Mutex diff --git a/web/components/chat/ChatContainer/ChatContainer.tsx b/web/components/chat/ChatContainer/ChatContainer.tsx index feaccb2cb..6b5ce9467 100644 --- a/web/components/chat/ChatContainer/ChatContainer.tsx +++ b/web/components/chat/ChatContainer/ChatContainer.tsx @@ -14,6 +14,7 @@ import { ChatTextField } from '../ChatTextField/ChatTextField'; import { ChatModeratorNotification } from '../ChatModeratorNotification/ChatModeratorNotification'; import { ChatSystemMessage } from '../ChatSystemMessage/ChatSystemMessage'; import { ChatJoinMessage } from '../ChatJoinMessage/ChatJoinMessage'; +import { ChatPartMessage } from '../ChatPartMessage/ChatPartMessage'; import { ScrollToBotBtn } from './ScrollToBotBtn'; import { ChatActionMessage } from '../ChatActionMessage/ChatActionMessage'; import { ChatSocialMessage } from '../ChatSocialMessage/ChatSocialMessage'; @@ -137,6 +138,20 @@ export const ChatContainer: FC = ({ ); }; + const getUserPartMessage = (message: ChatMessage) => { + const { + user: { displayName, displayColor }, + } = message; + const isAuthorModerator = checkIsModerator(message); + return ( + + ); + }; + const getActionMessage = (message: ChatMessage) => { const { body } = message; return ; @@ -185,6 +200,8 @@ export const ChatContainer: FC = ({ return getConnectedInfoMessage(message as ConnectedClientInfoEvent); case MessageType.USER_JOINED: return getUserJoinedMessage(message as ChatMessage); + case MessageType.USER_PARTED: + return getUserPartMessage(message as ChatMessage); case MessageType.CHAT_ACTION: return getActionMessage(message as ChatMessage); case MessageType.SYSTEM: diff --git a/web/components/chat/ChatPartMessage/ChatPartMessage.module.scss b/web/components/chat/ChatPartMessage/ChatPartMessage.module.scss new file mode 100644 index 000000000..24128ecfa --- /dev/null +++ b/web/components/chat/ChatPartMessage/ChatPartMessage.module.scss @@ -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; +} diff --git a/web/components/chat/ChatPartMessage/ChatPartMessage.stories.tsx b/web/components/chat/ChatPartMessage/ChatPartMessage.stories.tsx new file mode 100644 index 000000000..92cbb9a0c --- /dev/null +++ b/web/components/chat/ChatPartMessage/ChatPartMessage.stories.tsx @@ -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; + +const Template: ComponentStory = 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, +}; diff --git a/web/components/chat/ChatPartMessage/ChatPartMessage.tsx b/web/components/chat/ChatPartMessage/ChatPartMessage.tsx new file mode 100644 index 000000000..17b47d718 --- /dev/null +++ b/web/components/chat/ChatPartMessage/ChatPartMessage.tsx @@ -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 = ({ + isAuthorModerator, + userColor, + displayName, +}) => { + const color = `var(--theme-color-users-${userColor})`; + + return ( +
+ + + + + {displayName} + {isAuthorModerator && ( + + + + )} + + left the chat. +
+ ); +}; diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index 50c5926c3..37850c5bb 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -322,6 +322,9 @@ export const ClientConfigStore: FC = () => { case MessageType.USER_JOINED: setChatMessages(currentState => [...currentState, message as ChatEvent]); break; + case MessageType.USER_PARTED: + setChatMessages(currentState => [...currentState, message as ChatEvent]); + break; case MessageType.SYSTEM: setChatMessages(currentState => [...currentState, message as ChatEvent]); break; diff --git a/web/interfaces/socket-events.ts b/web/interfaces/socket-events.ts index 4cd9bb872..e94b8cd2e 100644 --- a/web/interfaces/socket-events.ts +++ b/web/interfaces/socket-events.ts @@ -8,6 +8,7 @@ export enum MessageType { PONG = 'PONG', SYSTEM = 'SYSTEM', USER_JOINED = 'USER_JOINED', + USER_PARTED = 'USER_PARTED', CHAT_ACTION = 'CHAT_ACTION', FEDIVERSE_ENGAGEMENT_FOLLOW = 'FEDIVERSE_ENGAGEMENT_FOLLOW', FEDIVERSE_ENGAGEMENT_LIKE = 'FEDIVERSE_ENGAGEMENT_LIKE', diff --git a/web/pages/admin/webhooks.tsx b/web/pages/admin/webhooks.tsx index 19a997fc7..13adbeeca 100644 --- a/web/pages/admin/webhooks.tsx +++ b/web/pages/admin/webhooks.tsx @@ -30,6 +30,7 @@ const DeleteOutlined = dynamic(() => import('@ant-design/icons/DeleteOutlined'), const availableEvents = { 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_PARTED: { name: 'User parted', description: 'When a user leaves the chat', color: 'green' }, NAME_CHANGE: { name: 'User name changed', description: 'When a user changes their name',