Merge pull request #264 from owncast/gek/update-chat-styling
Update chat styling Look great.
This commit is contained in:
@@ -70,7 +70,7 @@ func getChatHistory() []models.ChatMessage {
|
|||||||
history := make([]models.ChatMessage, 0)
|
history := make([]models.ChatMessage, 0)
|
||||||
|
|
||||||
// Get all messages sent within the past day
|
// 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 {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func (s *server) sendWelcomeMessageToClient(c *Client) {
|
|||||||
time.Sleep(7 * time.Second)
|
time.Sleep(7 * time.Second)
|
||||||
|
|
||||||
initialChatMessageText := fmt.Sprintf("Welcome to %s! %s", config.Config.InstanceDetails.Title, config.Config.InstanceDetails.Summary)
|
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)
|
c.Write(initialMessage)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ for (var i = 0; i < 20; i++) {
|
|||||||
console.log(`</body>`);
|
console.log(`</body>`);
|
||||||
|
|
||||||
function generateElement(string) {
|
function generateElement(string) {
|
||||||
const color = messageBubbleColorForString(string);
|
const bgColor = messageBubbleColorForString(string);
|
||||||
return `<div style="color: ${color}">${string}</div>`
|
const fgColor = textColorForString(string);
|
||||||
|
return `<div style="background-color: ${bgColor}; color: ${fgColor}; padding: 30px;">${string}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomString(length) {
|
function randomString(length) {
|
||||||
@@ -27,9 +28,25 @@ function messageBubbleColorForString(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tweak these to adjust the result of the color
|
// Tweak these to adjust the result of the color
|
||||||
const saturation = 75;
|
const saturation = 25;
|
||||||
const lightness = 65;
|
const lightness = 45;
|
||||||
const alpha = 1.0;
|
const alpha = 0.3;
|
||||||
|
const hue = parseInt(Math.abs(hash), 16) % 360;
|
||||||
|
|
||||||
|
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textColorForString(str) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweak these to adjust the result of the color
|
||||||
|
const saturation = 80;
|
||||||
|
const lightness = 80;
|
||||||
|
const alpha = 0.8;
|
||||||
const hue = parseInt(Math.abs(hash), 16) % 360;
|
const hue = parseInt(Math.abs(hash), 16) % 360;
|
||||||
|
|
||||||
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||||
|
|||||||
182
webroot/js/components/chat/chat-message-view.js
Normal file
182
webroot/js/components/chat/chat-message-view.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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 authorTextColor = isSystemMessage
|
||||||
|
? { color: '#fff' }
|
||||||
|
: { color: textColorForString(author) };
|
||||||
|
const backgroundStyle = isSystemMessage
|
||||||
|
? { backgroundColor: '#667eea' }
|
||||||
|
: { backgroundColor: messageBubbleColorForString(author) };
|
||||||
|
const messageClassString = isSystemMessage
|
||||||
|
? getSystemMessageClassString()
|
||||||
|
: getChatMessageClassString();
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
style=${backgroundStyle}
|
||||||
|
class=${messageClassString}
|
||||||
|
title=${formattedTimestamp}
|
||||||
|
>
|
||||||
|
<div class="message-content break-words w-full">
|
||||||
|
<div
|
||||||
|
style=${authorTextColor}
|
||||||
|
class="message-author font-bold"
|
||||||
|
>
|
||||||
|
${author}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="message-text text-gray-300 font-normal overflow-y-hidden pt-2"
|
||||||
|
dangerouslySetInnerHTML=${{ __html: formattedMessage }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
'<span class="highlighted px-1 rounded font-bold bg-orange-500">$&</span>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="chat-embed youtube-embed"><lite-youtube videoid="${id}" /></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstagramEmbedFromURL(url) {
|
||||||
|
const urlObject = new URL(url.replace(/\/$/, ''));
|
||||||
|
urlObject.pathname += '/embed';
|
||||||
|
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(url) {
|
||||||
|
const re = /\.(jpe?g|png|gif)$/i;
|
||||||
|
return re.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageForURL(url) {
|
||||||
|
return `<a target="_blank" href="${url}"><img class="chat-embed embedded-image" src="${url}" /></a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTags(str) {
|
||||||
|
return str.replace(/<\/?[^>]+(>|$)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2,38 +2,17 @@ import { h, Component } from '/js/web_modules/preact.js';
|
|||||||
import htm from '/js/web_modules/htm.js';
|
import htm from '/js/web_modules/htm.js';
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
import ChatMessageView from './chat-message-view.js';
|
||||||
|
|
||||||
import { messageBubbleColorForString } from '../../utils/user-colors.js';
|
import { messageBubbleColorForString } from '../../utils/user-colors.js';
|
||||||
import { convertToText } from '../../utils/chat.js';
|
|
||||||
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||||
|
|
||||||
export default class Message extends Component {
|
export default class Message extends Component {
|
||||||
render(props) {
|
render(props) {
|
||||||
const { message, username } = props;
|
const { message } = props;
|
||||||
const { type } = message;
|
const { type } = message;
|
||||||
if (type === SOCKET_MESSAGE_TYPES.CHAT) {
|
if (type === SOCKET_MESSAGE_TYPES.CHAT || type === SOCKET_MESSAGE_TYPES.SYSTEM) {
|
||||||
const { author, body, timestamp } = message;
|
return html`<${ChatMessageView} ...${props} />`;
|
||||||
const formattedMessage = formatMessageText(body, username);
|
|
||||||
const formattedTimestamp = formatTimestamp(timestamp);
|
|
||||||
|
|
||||||
const authorColor = messageBubbleColorForString(author);
|
|
||||||
const authorTextColor = { color: authorColor };
|
|
||||||
return (
|
|
||||||
html`
|
|
||||||
<div class="message flex flex-row items-start p-3">
|
|
||||||
<div class="message-content text-sm break-words w-full">
|
|
||||||
<div class="message-author text-white font-bold" style=${authorTextColor}>
|
|
||||||
${author}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="message-text text-gray-300 font-normal overflow-y-hidden"
|
|
||||||
title=${formattedTimestamp}
|
|
||||||
dangerouslySetInnerHTML=${
|
|
||||||
{ __html: formattedMessage }
|
|
||||||
}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
||||||
const { oldName, newName } = message;
|
const { oldName, newName } = message;
|
||||||
return (
|
return (
|
||||||
@@ -47,121 +26,9 @@ export default class Message extends Component {
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
'<span class="highlighted px-1 rounded font-bold bg-orange-500">$&</span>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
return null;
|
console.log("Unknown message type:", type);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getYoutubeEmbedFromID(id) {
|
|
||||||
return `<div class="chat-embed youtube-embed"><lite-youtube videoid="${id}" /></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInstagramEmbedFromURL(url) {
|
|
||||||
const urlObject = new URL(url.replace(/\/$/, ''));
|
|
||||||
urlObject.pathname += '/embed';
|
|
||||||
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImage(url) {
|
|
||||||
const re = /\.(jpe?g|png|gif)$/i;
|
|
||||||
return re.test(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageForURL(url) {
|
|
||||||
return `<a target="_blank" href="${url}"><img class="chat-embed embedded-image" src="${url}" /></a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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, '<br>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripTags(str) {
|
|
||||||
return str.replace(/<\/?[^>]+(>|$)/g, '');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export function extraUserNamesFromMessageHistory(messages) {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// utils from https://gist.github.com/nathansmith/86b5d4b23ed968a92fd4
|
// utils from https://gist.github.com/nathansmith/86b5d4b23ed968a92fd4
|
||||||
/*
|
/*
|
||||||
You would call this after getting an element's
|
You would call this after getting an element's
|
||||||
|
|||||||
@@ -6,9 +6,25 @@ export function messageBubbleColorForString(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tweak these to adjust the result of the color
|
// Tweak these to adjust the result of the color
|
||||||
const saturation = 75;
|
const saturation = 25;
|
||||||
const lightness = 65;
|
const lightness = 45;
|
||||||
const alpha = 1.0;
|
const alpha = 0.3;
|
||||||
|
const hue = parseInt(Math.abs(hash), 16) % 360;
|
||||||
|
|
||||||
|
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textColorForString(str) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweak these to adjust the result of the color
|
||||||
|
const saturation = 80;
|
||||||
|
const lightness = 80;
|
||||||
|
const alpha = 0.8;
|
||||||
const hue = parseInt(Math.abs(hash), 16) % 360;
|
const hue = parseInt(Math.abs(hash), 16) % 360;
|
||||||
|
|
||||||
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export const SOCKET_MESSAGE_TYPES = {
|
|||||||
CHAT: 'CHAT',
|
CHAT: 'CHAT',
|
||||||
PING: 'PING',
|
PING: 'PING',
|
||||||
NAME_CHANGE: 'NAME_CHANGE',
|
NAME_CHANGE: 'NAME_CHANGE',
|
||||||
PONG: 'PONG'
|
PONG: 'PONG',
|
||||||
|
SYSTEM: 'SYSTEM'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CALLBACKS = {
|
export const CALLBACKS = {
|
||||||
|
|||||||
Reference in New Issue
Block a user