summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabe Kangas <gabek@real-ity.com>2023-09-03 18:35:29 -0700
committerGabe Kangas <gabek@real-ity.com>2023-09-03 19:01:04 -0700
commit88b755714a040af3862dc3e91452f510e07c085b (patch)
tree1b44ec2f22a146830c1208b3deb95b4ac5a0c456
parent4194a126ebff311ab19fba168d21e0bdfdd07393 (diff)
feat(chat): add support for chat part messages. Closes #3201gek/chat-user-part-events
-rw-r--r--controllers/admin/serverConfig.go2
-rw-r--r--core/chat/events.go1
-rw-r--r--core/chat/events/eventtype.go2
-rw-r--r--core/chat/events/userPartEvent.go17
-rw-r--r--core/chat/server.go65
-rw-r--r--core/data/config.go4
-rw-r--r--core/webhooks/chat.go10
-rw-r--r--core/webhooks/webhooks_test.go3
-rw-r--r--web/components/chat/ChatContainer/ChatContainer.tsx17
-rw-r--r--web/components/chat/ChatPartMessage/ChatPartMessage.module.scss16
-rw-r--r--web/components/chat/ChatPartMessage/ChatPartMessage.stories.tsx42
-rw-r--r--web/components/chat/ChatPartMessage/ChatPartMessage.tsx42
-rw-r--r--web/components/stores/ClientConfigStore.tsx3
-rw-r--r--web/interfaces/socket-events.ts1
-rw-r--r--web/pages/admin/webhooks.tsx1
15 files changed, 205 insertions, 21 deletions
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<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 { body } = message;
return <ChatActionMessage body={body} />;
@@ -185,6 +200,8 @@ export const ChatContainer: FC<ChatContainerProps> = ({
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<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,
+};
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<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>
+ );
+};
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',