diff --git a/core/chat/persistence.go b/core/chat/persistence.go
index 65bb65930..dcf4c8d0e 100644
--- a/core/chat/persistence.go
+++ b/core/chat/persistence.go
@@ -70,7 +70,7 @@ func getChatHistory() []models.ChatMessage {
history := make([]models.ChatMessage, 0)
// Get all messages sent within the past day
- rows, err := _db.Query("SELECT * FROM messages WHERE visible = 1 AND datetime(timestamp) >=datetime('now', '-1 Day')")
+ rows, err := _db.Query("SELECT * FROM messages WHERE visible = 1 AND messageType != 'SYSTEM' AND datetime(timestamp) >=datetime('now', '-1 Day')")
if err != nil {
log.Fatal(err)
}
diff --git a/core/chat/server.go b/core/chat/server.go
index ad70972d5..15c6a37c2 100644
--- a/core/chat/server.go
+++ b/core/chat/server.go
@@ -140,7 +140,7 @@ func (s *server) sendWelcomeMessageToClient(c *Client) {
time.Sleep(7 * time.Second)
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary)
- initialMessage := models.ChatMessage{"owncast-server", config.Config.InstanceDetails.Name, initialChatMessageText, "initial-message-1", "CHAT", true, time.Now()}
+ initialMessage := models.ChatMessage{"owncast-server", config.Config.InstanceDetails.Name, initialChatMessageText, "initial-message-1", "SYSTEM", true, time.Now()}
c.Write(initialMessage)
}()
diff --git a/webroot/js/components/chat/chat-message-view.js b/webroot/js/components/chat/chat-message-view.js
new file mode 100644
index 000000000..04eb6bd84
--- /dev/null
+++ b/webroot/js/components/chat/chat-message-view.js
@@ -0,0 +1,179 @@
+import { h, Component } from '/js/web_modules/preact.js';
+import htm from '/js/web_modules/htm.js';
+const html = htm.bind(h);
+
+import {
+ messageBubbleColorForString,
+ textColorForString,
+} from '../../utils/user-colors.js';
+import { convertToText } from '../../utils/chat.js';
+import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
+
+export default class ChatMessageView extends Component {
+ render() {
+ const { message, username } = this.props;
+ const { author, body, timestamp } = message;
+
+ const formattedMessage = formatMessageText(body, username);
+ const formattedTimestamp = formatTimestamp(timestamp);
+
+ const isSystemMessage = message.type === SOCKET_MESSAGE_TYPES.SYSTEM;
+ const authorColor = textColorForString(author);
+ const backgroundColor = messageBubbleColorForString(author);
+ const authorTextColor = isSystemMessage ? { color: 'white' } : { color: authorColor };
+ const backgroundStyle = isSystemMessage
+ ? { backgroundColor: '#667eea' }
+ : { backgroundColor: backgroundColor };
+ const classString = isSystemMessage ? getSystemMessageClassString() : getChatMessageClassString();
+
+ return html`
+
+ `;
+ }
+}
+
+function getSystemMessageClassString() {
+ return 'message flex flex-row items-start p-4 m-2 rounded-lg shadow-l border-solid border-indigo-700 border-2 border-opacity-60 text-l';
+}
+
+function getChatMessageClassString() {
+ return 'message flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm';
+}
+
+export function formatMessageText(message, username) {
+ let formattedText = highlightUsername(message, username);
+ formattedText = getMessageWithEmbeds(formattedText);
+ return convertToMarkup(formattedText);
+}
+
+function highlightUsername(message, username) {
+ const pattern = new RegExp(
+ '@?' + username.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'),
+ 'gi'
+ );
+ return message.replace(
+ pattern,
+ '$&'
+ );
+}
+
+function getMessageWithEmbeds(message) {
+ var embedText = '';
+ // Make a temporary element so we can actually parse the html and pull anchor tags from it.
+ // This is a better approach than regex.
+ var container = document.createElement('p');
+ container.innerHTML = message;
+
+ var anchors = container.getElementsByTagName('a');
+ for (var i = 0; i < anchors.length; i++) {
+ const url = anchors[i].href;
+ if (getYoutubeIdFromURL(url)) {
+ const youtubeID = getYoutubeIdFromURL(url);
+ embedText += getYoutubeEmbedFromID(youtubeID);
+ } else if (url.indexOf('instagram.com/p/') > -1) {
+ embedText += getInstagramEmbedFromURL(url);
+ } else if (isImage(url)) {
+ embedText += getImageForURL(url);
+ }
+ }
+
+ // If this message only consists of a single embeddable link
+ // then only return the embed and strip the link url from the text.
+ if (
+ embedText !== '' &&
+ anchors.length == 1 &&
+ isMessageJustAnchor(message, anchors[0])
+ ) {
+ return embedText;
+ }
+ return message + embedText;
+}
+
+function getYoutubeIdFromURL(url) {
+ try {
+ var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
+ var match = url.match(regExp);
+
+ if (match && match[2].length == 11) {
+ return match[2];
+ } else {
+ return null;
+ }
+ } catch (e) {
+ console.log(e);
+ return null;
+ }
+}
+
+function getYoutubeEmbedFromID(id) {
+ return `
`;
+}
+
+function getInstagramEmbedFromURL(url) {
+ const urlObject = new URL(url.replace(/\/$/, ''));
+ urlObject.pathname += '/embed';
+ return ``;
+}
+
+function isImage(url) {
+ const re = /\.(jpe?g|png|gif)$/i;
+ return re.test(url);
+}
+
+function getImageForURL(url) {
+ return `
`;
+}
+
+function isMessageJustAnchor(message, anchor) {
+ return stripTags(message) === stripTags(anchor.innerHTML);
+}
+
+function formatTimestamp(sentAt) {
+ sentAt = new Date(sentAt);
+ if (isNaN(sentAt)) {
+ return '';
+ }
+
+ let diffInDays = (new Date() - sentAt) / (24 * 3600 * 1000);
+ if (diffInDays >= 1) {
+ return (
+ `Sent at ${sentAt.toLocaleDateString('en-US', {
+ dateStyle: 'medium',
+ })} at ` + sentAt.toLocaleTimeString()
+ );
+ }
+
+ return `Sent at ${sentAt.toLocaleTimeString()}`;
+}
+
+/*
+ You would call this when receiving a plain text
+ value back from an API, and before inserting the
+ text into the `contenteditable` area on a page.
+*/
+function convertToMarkup(str = '') {
+ return convertToText(str).replace(/\n/g, '
');
+}
+
+function stripTags(str) {
+ return str.replace(/<\/?[^>]+(>|$)/g, '');
+}
+
+