b835de2dc4
* Able to authenticate user against IndieAuth. For #1273 * WIP server indieauth endpoint. For https://github.com/owncast/owncast/issues/1272 * Add migration to remove access tokens from user * Add authenticated bool to user for display purposes * Add indieauth modal and auth flair to display names. For #1273 * Validate URLs and display errors * Renames, cleanups * Handle relative auth endpoint paths. Add error handling for missing redirects. * Disallow using display names in use by registered users. Closes #1810 * Verify code verifier via code challenge on callback * Use relative path to authorization_endpoint * Post-rebase fixes * Use a timestamp instead of a bool for authenticated * Propertly handle and display error in modal * Use auth'ed timestamp to derive authenticated flag to display in chat * don't redirect unless a URL is present avoids redirecting to `undefined` if there was an error * improve error message if owncast server URL isn't set * fix IndieAuth PKCE implementation use SHA256 instead of SHA1, generates a longer code verifier (must be 43-128 chars long), fixes URL-safe SHA256 encoding * return real profile data for IndieAuth response * check the code verifier in the IndieAuth server * Linting * Add new chat settings modal anad split up indieauth ui * Remove logging error * Update the IndieAuth modal UI. For #1273 * Add IndieAuth repsonse error checking * Disable IndieAuth client if server URL is not set. * Add explicit error messages for specific error types * Fix bad logic * Return OAuth-keyed error responses for indieauth server * Display IndieAuth error in plain text with link to return to main page * Remove redundant check * Add additional detail to error * Hide IndieAuth details behind disclosure details * Break out migration into two steps because some people have been runing dev in production * Add auth option to user dropdown Co-authored-by: Aaron Parecki <aaron@parecki.com>
230 lines
6.6 KiB
JavaScript
230 lines
6.6 KiB
JavaScript
import { h, Component } from '/js/web_modules/preact.js';
|
|
import htm from '/js/web_modules/htm.js';
|
|
import Mark from '/js/web_modules/markjs/dist/mark.es6.min.js';
|
|
const html = htm.bind(h);
|
|
|
|
import {
|
|
messageBubbleColorForHue,
|
|
textColorForHue,
|
|
} from '../../utils/user-colors.js';
|
|
import { convertToText, checkIsModerator } from '../../utils/chat.js';
|
|
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
|
import { getDiffInDaysFromNow } from '../../utils/helpers.js';
|
|
import ModeratorActions from './moderator-actions.js';
|
|
|
|
export default class ChatMessageView extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
formattedMessage: '',
|
|
moderatorMenuOpen: false,
|
|
};
|
|
}
|
|
|
|
shouldComponentUpdate(nextProps, nextState) {
|
|
const { formattedMessage } = this.state;
|
|
const { formattedMessage: nextFormattedMessage } = nextState;
|
|
|
|
return (
|
|
formattedMessage !== nextFormattedMessage ||
|
|
(!this.props.isModerator && nextProps.isModerator)
|
|
);
|
|
}
|
|
|
|
async componentDidMount() {
|
|
const { message, username } = this.props;
|
|
const { body } = message;
|
|
|
|
if (message && username) {
|
|
const formattedMessage = await formatMessageText(body, username);
|
|
this.setState({
|
|
formattedMessage,
|
|
});
|
|
}
|
|
}
|
|
render() {
|
|
const { message, isModerator, accessToken } = this.props;
|
|
const { user, timestamp } = message;
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const { displayName, displayColor, createdAt, isBot, authenticated } = user;
|
|
const isAuthorModerator = checkIsModerator(message);
|
|
|
|
const isMessageModeratable =
|
|
isModerator && message.type === SOCKET_MESSAGE_TYPES.CHAT;
|
|
|
|
const { formattedMessage } = this.state;
|
|
if (!formattedMessage) {
|
|
return null;
|
|
}
|
|
const formattedTimestamp = `Sent at ${formatTimestamp(timestamp)}`;
|
|
const userMetadata = createdAt
|
|
? `${displayName} first joined ${formatTimestamp(createdAt)}`
|
|
: null;
|
|
|
|
const isSystemMessage = message.type === SOCKET_MESSAGE_TYPES.SYSTEM;
|
|
|
|
const authorTextColor = isSystemMessage
|
|
? { color: '#fff' }
|
|
: { color: textColorForHue(displayColor) };
|
|
const backgroundStyle = isSystemMessage
|
|
? { backgroundColor: '#667eea' }
|
|
: { backgroundColor: messageBubbleColorForHue(displayColor) };
|
|
const messageClassString = isSystemMessage
|
|
? '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'
|
|
: `message relative flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm ${
|
|
isMessageModeratable ? 'moderatable' : ''
|
|
}`;
|
|
|
|
const isModeratorFlair = isAuthorModerator
|
|
? html`<img
|
|
class="flair"
|
|
title="Moderator"
|
|
src="/img/moderator-nobackground.svg"
|
|
/>`
|
|
: null;
|
|
|
|
const isBotFlair = isBot
|
|
? html`<img
|
|
title="Bot"
|
|
class="inline-block mr-1 w-4 h-4 relative"
|
|
style=${{ bottom: '1px' }}
|
|
src="/img/bot.svg"
|
|
/>`
|
|
: null;
|
|
|
|
const authorAuthenticatedFlair = authenticated
|
|
? html`<img
|
|
class="flair"
|
|
title="Authenticated"
|
|
src="/img/authenticated.svg"
|
|
/>`
|
|
: null;
|
|
|
|
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"
|
|
title=${userMetadata}
|
|
>
|
|
${isBotFlair} ${authorAuthenticatedFlair} ${isModeratorFlair}
|
|
${displayName}
|
|
</div>
|
|
${isMessageModeratable &&
|
|
html`<${ModeratorActions}
|
|
message=${message}
|
|
accessToken=${accessToken}
|
|
/>`}
|
|
<div
|
|
class="message-text text-gray-300 font-normal overflow-y-hidden pt-2"
|
|
dangerouslySetInnerHTML=${{ __html: formattedMessage }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
export async function formatMessageText(message, username) {
|
|
let formattedText = getMessageWithEmbeds(message);
|
|
formattedText = convertToMarkup(formattedText);
|
|
return await highlightUsername(formattedText, username);
|
|
}
|
|
|
|
function highlightUsername(message, username) {
|
|
// https://github.com/julmot/mark.js/issues/115
|
|
const node = document.createElement('span');
|
|
node.innerHTML = message;
|
|
return new Promise((res) => {
|
|
new Mark(node).mark(username, {
|
|
element: 'span',
|
|
className: 'highlighted px-1 rounded font-bold bg-orange-500',
|
|
separateWordSearch: false,
|
|
accuracy: {
|
|
value: 'exactly',
|
|
limiters: [',', '.', "'", '?', '@'],
|
|
},
|
|
done() {
|
|
res(node.innerHTML);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
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 (url.indexOf('instagram.com/p/') > -1) {
|
|
embedText += getInstagramEmbedFromURL(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 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 isMessageJustAnchor(message, anchor) {
|
|
return stripTags(message) === stripTags(anchor.innerHTML);
|
|
}
|
|
|
|
function formatTimestamp(sentAt) {
|
|
sentAt = new Date(sentAt);
|
|
if (isNaN(sentAt)) {
|
|
return '';
|
|
}
|
|
|
|
let diffInDays = getDiffInDaysFromNow(sentAt);
|
|
if (diffInDays >= 1) {
|
|
return (
|
|
`at ${sentAt.toLocaleDateString('en-US', {
|
|
dateStyle: 'medium',
|
|
})} at ` + sentAt.toLocaleTimeString()
|
|
);
|
|
}
|
|
|
|
return `${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, '<p></p>');
|
|
}
|
|
|
|
function stripTags(str) {
|
|
return str.replace(/<\/?[^>]+(>|$)/g, '');
|
|
}
|