[New Feature] Comment Emotes (#125)

* Refactor form-field

* Create new Emote Menu

* Add Emotes

* Add Emote Selector and Emote Comment creation ability

* Fix and Split CSS
This commit is contained in:
saltrafael 2021-10-27 15:20:47 -03:00 committed by GitHub
parent 762bddb158
commit c24153c6ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 632 additions and 319 deletions

View file

@ -0,0 +1,68 @@
// @flow
import 'scss/component/_emote-selector.scss';
import { EMOTES_24px as EMOTES } from 'constants/emotes';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import EMOJIS from 'emoji-dictionary';
import OptimizedImage from 'component/optimizedImage';
import React from 'react';
const OLD_QUICK_EMOJIS = [
EMOJIS.getUnicode('rocket'),
EMOJIS.getUnicode('jeans'),
EMOJIS.getUnicode('fire'),
EMOJIS.getUnicode('heart'),
EMOJIS.getUnicode('open_mouth'),
];
type Props = { commentValue: string, setCommentValue: (string) => void, closeSelector: () => void };
export default function EmoteSelector(props: Props) {
const { commentValue, setCommentValue, closeSelector } = props;
function addEmoteToComment(emote: string) {
setCommentValue(
commentValue + (commentValue && commentValue.charAt(commentValue.length - 1) !== ' ' ? ` ${emote} ` : `${emote} `)
);
}
return (
<div className="emoteSelector">
<Button button="close" icon={ICONS.REMOVE} onClick={closeSelector} />
<div className="emoteSelector__list">
<div className="emoteSelector__listRow">
<div className="emoteSelector__listRowTitle">{__('Global Emotes')}</div>
<div className="emoteSelector__listRowItems">
{OLD_QUICK_EMOJIS.map((emoji) => (
<Button
key={emoji}
label={emoji}
title={`:${EMOJIS.getName(emoji)}:`}
button="alt"
className="button--file-action"
onClick={() => addEmoteToComment(emoji)}
/>
))}
{EMOTES.map((emote) => {
const emoteName = emote.name.toLowerCase();
return (
<Button
key={String(emote)}
title={emoteName}
button="alt"
className="button--file-action"
onClick={() => addEmoteToComment(emoteName)}
>
<OptimizedImage src={emote.url} waitLoad />
</Button>
);
})}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,4 +1,5 @@
// @flow // @flow
import 'scss/component/_comment-create.scss';
import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field'; import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field';
import { FormField, Form } from 'component/common/form'; import { FormField, Form } from 'component/common/form';
import { getChannelIdFromClaim } from 'util/claim'; import { getChannelIdFromClaim } from 'util/claim';
@ -13,6 +14,7 @@ import ChannelMentionSuggestions from 'component/channelMentionSuggestions';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import classnames from 'classnames'; import classnames from 'classnames';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
import EmoteSelector from './emote-selector';
import Empty from 'component/common/empty'; import Empty from 'component/common/empty';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
@ -106,6 +108,7 @@ export function CommentCreate(props: Props) {
const [deletedComment, setDeletedComment] = React.useState(false); const [deletedComment, setDeletedComment] = React.useState(false);
const [pauseQuickSend, setPauseQuickSend] = React.useState(false); const [pauseQuickSend, setPauseQuickSend] = React.useState(false);
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState(); const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState();
const [showEmotes, setShowEmotes] = React.useState(false);
const selectedMentionIndex = const selectedMentionIndex =
commentValue.indexOf('@', selectionIndex) === selectionIndex commentValue.indexOf('@', selectionIndex) === selectionIndex
@ -205,12 +208,6 @@ export function CommentCreate(props: Props) {
window.removeEventListener('keydown', altEnterListener); window.removeEventListener('keydown', altEnterListener);
} }
function handleSubmit() {
if (activeChannelClaim && commentValue.length) {
handleCreateComment();
}
}
function handleSupportComment() { function handleSupportComment() {
if (!activeChannelClaim) { if (!activeChannelClaim) {
return; return;
@ -368,6 +365,7 @@ export function CommentCreate(props: Props) {
* @param {string} [environment] Optional environment for Stripe (test|live) * @param {string} [environment] Optional environment for Stripe (test|live)
*/ */
function handleCreateComment(txid, payment_intent_id, environment) { function handleCreateComment(txid, payment_intent_id, environment) {
setShowEmotes(false);
setIsSubmitting(true); setIsSubmitting(true);
createComment(commentValue, claimId, parentId, txid, payment_intent_id, environment) createComment(commentValue, claimId, parentId, txid, payment_intent_id, environment)
@ -509,13 +507,20 @@ export function CommentCreate(props: Props) {
return ( return (
<Form <Form
onSubmit={handleSubmit}
className={classnames('comment__create', { className={classnames('comment__create', {
'comment__create--reply': isReply, 'comment__create--reply': isReply,
'comment__create--nested-reply': isNested, 'comment__create--nested-reply': isNested,
'comment__create--bottom': bottom, 'comment__create--bottom': bottom,
})} })}
> >
{showEmotes && (
<EmoteSelector
commentValue={commentValue}
setCommentValue={setCommentValue}
closeSelector={() => setShowEmotes(false)}
/>
)}
{!advancedEditor && ( {!advancedEditor && (
<ChannelMentionSuggestions <ChannelMentionSuggestions
uri={uri} uri={uri}
@ -544,6 +549,7 @@ export function CommentCreate(props: Props) {
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')) !SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
} }
quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)} quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
openEmoteMenu={() => setShowEmotes(!showEmotes)}
onFocus={onTextareaFocus} onFocus={onTextareaFocus}
onBlur={onTextareaBlur} onBlur={onTextareaBlur}
placeholder={__('Say something about this...')} placeholder={__('Say something about this...')}
@ -601,6 +607,7 @@ export function CommentCreate(props: Props) {
: __('Comment --[button to submit something]--') : __('Comment --[button to submit something]--')
} }
requiresAuth={IS_WEB} requiresAuth={IS_WEB}
onClick={() => activeChannelClaim && commentValue.length && handleCreateComment()}
/> />
)} )}
{!supportDisabled && !claimIsMine && ( {!supportDisabled && !claimIsMine && (

View file

@ -1,33 +1,23 @@
// @flow // @flow
import type { ElementRef, Node } from 'react'; import 'easymde/dist/easymde.min.css';
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import MarkdownPreview from 'component/common/markdown-preview';
import React from 'react'; import React from 'react';
import ReactDOMServer from 'react-dom/server'; import ReactDOMServer from 'react-dom/server';
import SimpleMDE from 'react-simplemde-editor'; import SimpleMDE from 'react-simplemde-editor';
import MarkdownPreview from 'component/common/markdown-preview'; import type { ElementRef, Node } from 'react';
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
import 'easymde/dist/easymde.min.css';
import Button from 'component/button';
import emoji from 'emoji-dictionary';
const QUICK_EMOJIS = [
emoji.getUnicode('rocket'),
emoji.getUnicode('jeans'),
emoji.getUnicode('fire'),
emoji.getUnicode('heart'),
emoji.getUnicode('open_mouth'),
];
type Props = { type Props = {
name: string, name: string,
label?: string | Node, label?: string | Node,
render?: () => React$Node,
prefix?: string, prefix?: string,
postfix?: string, postfix?: string,
error?: string | boolean, error?: string | boolean,
helper?: string | React$Node, helper?: string | React$Node,
type?: string, type?: string,
onChange?: (any) => any,
defaultValue?: string | number, defaultValue?: string | number,
placeholder?: string | number, placeholder?: string | number,
children?: React$Node, children?: React$Node,
@ -43,18 +33,17 @@ type Props = {
min?: number, min?: number,
max?: number, max?: number,
quickActionLabel?: string, quickActionLabel?: string,
quickActionHandler?: (any) => any,
disabled?: boolean, disabled?: boolean,
onChange: (any) => void,
value?: string | number, value?: string | number,
noEmojis?: boolean, noEmojis?: boolean,
render?: () => React$Node,
onChange?: (any) => any,
quickActionHandler?: (any) => any,
openEmoteMenu?: () => void,
}; };
export class FormField extends React.PureComponent<Props> { export class FormField extends React.PureComponent<Props> {
static defaultProps = { static defaultProps = { labelOnLeft: false, blockWrap: true };
labelOnLeft: false,
blockWrap: true,
};
input: { current: ElementRef<any> }; input: { current: ElementRef<any> };
@ -67,14 +56,11 @@ export class FormField extends React.PureComponent<Props> {
const { autoFocus } = this.props; const { autoFocus } = this.props;
const input = this.input.current; const input = this.input.current;
if (input && autoFocus) { if (input && autoFocus) input.focus();
input.focus();
}
} }
render() { render() {
const { const {
render,
label, label,
prefix, prefix,
postfix, postfix,
@ -92,11 +78,24 @@ export class FormField extends React.PureComponent<Props> {
charCount, charCount,
textAreaMaxLength, textAreaMaxLength,
quickActionLabel, quickActionLabel,
quickActionHandler,
noEmojis, noEmojis,
render,
quickActionHandler,
openEmoteMenu,
...inputProps ...inputProps
} = this.props; } = this.props;
const errorMessage = typeof error === 'object' ? error.message : error; const errorMessage = typeof error === 'object' ? error.message : error;
// Ideally, the character count should (and can) be appended to the
// SimpleMDE's "options::status" bar. However, I couldn't figure out how
// to pass the current value to it's callback, nor query the current
// text length from the callback. So, we'll use our own widget.
const hasCharCount = charCount !== undefined && charCount >= 0;
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
);
const Wrapper = blockWrap const Wrapper = blockWrap
? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section> ? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section>
: ({ children: innerChildren }) => <span className="radio">{innerChildren}</span>; : ({ children: innerChildren }) => <span className="radio">{innerChildren}</span>;
@ -108,207 +107,164 @@ export class FormField extends React.PureComponent<Props> {
</div> </div>
) : null; ) : null;
let input; const inputSimple = (type: string) => (
if (type) { <>
if (type === 'radio') { <input id={name} type={type} {...inputProps} />
input = ( <label htmlFor={name}>{label}</label>
<Wrapper> </>
<input id={name} type="radio" {...inputProps} /> );
<label htmlFor={name}>{label}</label>
</Wrapper>
);
} else if (type === 'checkbox') {
input = (
<div className="checkbox">
<input id={name} type="checkbox" {...inputProps} />
<label htmlFor={name}>{label}</label>
</div>
);
} else if (type === 'range') {
input = (
<div>
<input id={name} type="range" {...inputProps} />
<label htmlFor={name}>{label}</label>
</div>
);
} else if (type === 'select') {
input = (
<fieldset-section>
{(label || errorMessage) && (
<label htmlFor={name}>{errorMessage ? <span className="error__text">{errorMessage}</span> : label}</label>
)}
<select id={name} {...inputProps}>
{children}
</select>
</fieldset-section>
);
} else if (type === 'select-tiny') {
input = (
<fieldset-section class="select--slim">
{(label || errorMessage) && (
<label htmlFor={name}>{errorMessage ? <span className="error__text">{errorMessage}</span> : label}</label>
)}
<select id={name} {...inputProps}>
{children}
</select>
</fieldset-section>
);
} else if (type === 'markdown') {
const handleEvents = {
contextmenu: openEditorMenu,
};
const getInstance = (editor) => { const inputSelect = (selectClass: string) => (
// SimpleMDE max char check <fieldset-section class={selectClass}>
editor.codemirror.on('beforeChange', (instance, changes) => { {(label || errorMessage) && (
if (textAreaMaxLength && changes.update) { <label htmlFor={name}>{errorMessage ? <span className="error__text">{errorMessage}</span> : label}</label>
var str = changes.text.join('\n'); )}
var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from)); <select id={name} {...inputProps}>
if (delta <= 0) { {children}
return; </select>
} </fieldset-section>
delta = instance.getValue().length + delta - textAreaMaxLength; );
if (delta > 0) {
str = str.substr(0, str.length - delta);
changes.update(changes.from, changes.to, str.split('\n'));
}
}
});
// "Create Link (Ctrl-K)": highlight URL instead of label: const input = () => {
editor.codemirror.on('changes', (instance, changes) => { switch (type) {
try { case 'radio':
// Grab the last change from the buffered list. I assume the return <Wrapper>{inputSimple('radio')}</Wrapper>;
// buffered one ('changes', instead of 'change') is more efficient, case 'checkbox':
// and that "Create Link" will always end up last in the list. return <div className="checkbox">{inputSimple('checkbox')}</div>;
const lastChange = changes[changes.length - 1]; case 'range':
if (lastChange.origin === '+input') { return <div>{inputSimple('range')}</div>;
// https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765 case 'select':
const EASYMDE_URL_PLACEHOLDER = '(https://)'; return inputSelect('');
case 'select-tiny':
return inputSelect('select--slim');
case 'markdown':
const handleEvents = { contextmenu: openEditorMenu };
// The URL placeholder is always placed last, so just look at the const getInstance = (editor) => {
// last text in the array to also cover the multi-line case: // SimpleMDE max char check
const urlLineText = lastChange.text[lastChange.text.length - 1]; editor.codemirror.on('beforeChange', (instance, changes) => {
if (textAreaMaxLength && changes.update) {
var str = changes.text.join('\n');
var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from));
if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) { if (delta <= 0) return;
const from = lastChange.from;
const to = lastChange.to;
const isSelectionMultiline = lastChange.text.length > 1;
const baseIndex = isSelectionMultiline ? 0 : from.ch;
// Everything works fine for the [Ctrl-K] case, but for the delta = instance.getValue().length + delta - textAreaMaxLength;
// [Button] case, this handler happens before the original if (delta > 0) {
// code, thus our change got wiped out. str = str.substr(0, str.length - delta);
// Add a small delay to handle that case. changes.update(changes.from, changes.to, str.split('\n'));
setTimeout(() => {
instance.setSelection(
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 },
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') }
);
}, 25);
} }
} }
} catch (err) { });
// Do nothing (revert to original behavior)
}
});
};
// Ideally, the character count should (and can) be appended to the // "Create Link (Ctrl-K)": highlight URL instead of label:
// SimpleMDE's "options::status" bar. However, I couldn't figure out how editor.codemirror.on('changes', (instance, changes) => {
// to pass the current value to it's callback, nor query the current try {
// text length from the callback. So, we'll use our own widget. // Grab the last change from the buffered list. I assume the
const hasCharCount = charCount !== undefined && charCount >= 0; // buffered one ('changes', instead of 'change') is more efficient,
const countInfo = hasCharCount && textAreaMaxLength !== undefined && ( // and that "Create Link" will always end up last in the list.
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span> const lastChange = changes[changes.length - 1];
); if (lastChange.origin === '+input') {
// https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765
const EASYMDE_URL_PLACEHOLDER = '(https://)';
input = ( // The URL placeholder is always placed last, so just look at the
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}> // last text in the array to also cover the multi-line case:
const urlLineText = lastChange.text[lastChange.text.length - 1];
if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) {
const from = lastChange.from;
const to = lastChange.to;
const isSelectionMultiline = lastChange.text.length > 1;
const baseIndex = isSelectionMultiline ? 0 : from.ch;
// Everything works fine for the [Ctrl-K] case, but for the
// [Button] case, this handler happens before the original
// code, thus our change got wiped out.
// Add a small delay to handle that case.
setTimeout(() => {
instance.setSelection(
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 },
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') }
);
}, 25);
}
}
} catch (e) {} // Do nothing (revert to original behavior)
});
};
return (
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
<fieldset-section>
<div className="form-field__two-column">
<div>
<label htmlFor={name}>{label}</label>
</div>
{quickAction}
</div>
<SimpleMDE
{...inputProps}
id={name}
type="textarea"
events={handleEvents}
getMdeInstance={getInstance}
options={{
spellChecker: true,
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
previewRender(plainText) {
const preview = <MarkdownPreview content={plainText} noDataStore />;
return ReactDOMServer.renderToString(preview);
},
}}
/>
{countInfo}
</fieldset-section>
</div>
);
case 'textarea':
return (
<fieldset-section> <fieldset-section>
<div className="form-field__two-column"> {(label || quickAction) && (
<div> <div className="form-field__two-column">
<label htmlFor={name}>{label}</label> <label htmlFor={name}>{label}</label>
</div> {quickAction}
{quickAction}
</div>
<SimpleMDE
{...inputProps}
id={name}
type="textarea"
events={handleEvents}
getMdeInstance={getInstance}
options={{
spellChecker: true,
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
previewRender(plainText) {
const preview = <MarkdownPreview content={plainText} noDataStore />;
return ReactDOMServer.renderToString(preview);
},
}}
/>
{countInfo}
</fieldset-section>
</div>
);
} else if (type === 'textarea') {
const hasCharCount = charCount !== undefined && charCount >= 0;
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
<span className="comment__char-count">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
);
input = (
<fieldset-section>
{(label || quickAction) && (
<div className="form-field__two-column">
<div>
<label htmlFor={name}>{label}</label>
</div>
{quickAction}
</div>
)}
<textarea
type={type}
id={name}
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
ref={this.input}
{...inputProps}
/>
<div className="form-field__textarea-info">
{!noEmojis && (
<div className="form-field__quick-emojis">
{QUICK_EMOJIS.map((emoji) => (
<Button
key={emoji}
disabled={inputProps.disabled}
type="button"
className="button--emoji"
label={emoji}
onClick={() => {
inputProps.onChange({
target: { value: inputProps.value ? `${inputProps.value} ${emoji}` : emoji },
});
}}
/>
))}
</div> </div>
)} )}
{countInfo} <textarea
</div> type={type}
</fieldset-section> id={name}
); maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
} else { ref={this.input}
const inputElement = <input type={type} id={name} {...inputProps} ref={this.input} />; {...inputProps}
const inner = inputButton ? ( />
<input-submit> <div className="form-field__textarea-info">
{inputElement} {!noEmojis && (
{inputButton} <Button
</input-submit> type="alt"
) : ( className="button--file-action"
inputElement title="Emotes"
); onClick={openEmoteMenu}
icon={ICONS.EMOJI}
iconSize={20}
/>
)}
{countInfo}
</div>
</fieldset-section>
);
default:
const inputElement = <input type={type} id={name} {...inputProps} ref={this.input} />;
const inner = inputButton ? (
<input-submit>
{inputElement}
{inputButton}
</input-submit>
) : (
inputElement
);
input = ( return (
<React.Fragment>
<fieldset-section> <fieldset-section>
{(label || errorMessage) && ( {(label || errorMessage) && (
<label htmlFor={name}> <label htmlFor={name}>
@ -318,17 +274,15 @@ export class FormField extends React.PureComponent<Props> {
{prefix && <label htmlFor={name}>{prefix}</label>} {prefix && <label htmlFor={name}>{prefix}</label>}
{inner} {inner}
</fieldset-section> </fieldset-section>
</React.Fragment> );
);
} }
} };
return ( return (
<React.Fragment> <>
{input} {type && input()}
{helper && <div className="form-field__help">{helper}</div>} {helper && <div className="form-field__help">{helper}</div>}
</React.Fragment> </>
); );
} }
} }

View file

@ -2526,4 +2526,12 @@ export const icons = {
<line x1="19" y1="20.5" x2="20" y2="20.5" /> <line x1="19" y1="20.5" x2="20" y2="20.5" />
</g> </g>
), ),
[ICONS.EMOJI]: buildIcon(
<g>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</g>
),
}; };

View file

@ -12,10 +12,14 @@ import MarkdownLink from 'component/markdownLink';
import defaultSchema from 'hast-util-sanitize/lib/github.json'; import defaultSchema from 'hast-util-sanitize/lib/github.json';
import { formattedLinks, inlineLinks } from 'util/remark-lbry'; import { formattedLinks, inlineLinks } from 'util/remark-lbry';
import { formattedTimestamp, inlineTimestamp } from 'util/remark-timestamp'; import { formattedTimestamp, inlineTimestamp } from 'util/remark-timestamp';
import { formattedEmote, inlineEmote } from 'util/remark-emote';
import ZoomableImage from 'component/zoomableImage'; import ZoomableImage from 'component/zoomableImage';
import { CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS, SIMPLE_SITE } from 'config'; import { CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS, SIMPLE_SITE } from 'config';
import Button from 'component/button'; import Button from 'component/button';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import OptimizedImage from 'component/optimizedImage';
const RE_EMOTE = /:\+1:|:-1:|:[\w-]+:/;
type SimpleTextProps = { type SimpleTextProps = {
children?: React.Node, children?: React.Node,
@ -94,10 +98,15 @@ const SimpleLink = (props: SimpleLinkProps) => {
const SimpleImageLink = (props: ImageLinkProps) => { const SimpleImageLink = (props: ImageLinkProps) => {
const { src, title, alt, helpText } = props; const { src, title, alt, helpText } = props;
if (!src) { if (!src) {
return null; return null;
} }
if (title && RE_EMOTE.test(title) && src.includes('static.odycdn.com/emoticons')) {
return <OptimizedImage title={title} src={src} />;
}
return ( return (
<Button <Button
button="link" button="link"
@ -248,6 +257,8 @@ const MarkdownPreview = (props: MarkdownProps) => {
.use(disableTimestamps || isMarkdownPost ? null : inlineTimestamp) .use(disableTimestamps || isMarkdownPost ? null : inlineTimestamp)
.use(disableTimestamps || isMarkdownPost ? null : formattedTimestamp) .use(disableTimestamps || isMarkdownPost ? null : formattedTimestamp)
// Emojis // Emojis
.use(inlineEmote)
.use(formattedEmote)
.use(remarkEmoji) .use(remarkEmoji)
// Render new lines without needing spaces. // Render new lines without needing spaces.
.use(remarkBreaks) .use(remarkBreaks)

View file

@ -10,10 +10,11 @@ function scaleToDevicePixelRatio(value: number, window: any) {
type Props = { type Props = {
src: string, src: string,
objectFit?: string, objectFit?: string,
waitLoad?: boolean,
}; };
function OptimizedImage(props: Props) { function OptimizedImage(props: Props) {
const { objectFit, src, ...imgProps } = props; const { objectFit, src, waitLoad, ...imgProps } = props;
const [optimizedSrc, setOptimizedSrc] = React.useState(''); const [optimizedSrc, setOptimizedSrc] = React.useState('');
const ref = React.useRef<any>(); const ref = React.useRef<any>();
@ -101,8 +102,12 @@ function OptimizedImage(props: Props) {
<img <img
ref={ref} ref={ref}
{...imgProps} {...imgProps}
style={{ visibility: waitLoad ? 'hidden' : 'visible' }}
src={optimizedSrc} src={optimizedSrc}
onLoad={() => adjustOptimizationIfNeeded(ref.current, objectFit, src)} onLoad={() => {
if (waitLoad) ref.current.style.visibility = 'visible';
adjustOptimizationIfNeeded(ref.current, objectFit, src);
}}
/> />
); );
} }

96
ui/constants/emotes.js Normal file
View file

@ -0,0 +1,96 @@
// @flow
const buildCDNUrl = (path: string) => `https://static.odycdn.com/emoticons/${path}`;
const buildEmote = (name: string, path: string) => ({
name: __(`:${name}:`),
url: buildCDNUrl(path),
});
const getEmotes = (px: string, multiplier: string) => [
buildEmote('ALIEN', `${px}/Alien${multiplier}.png`),
buildEmote('ANGRY_2', `${px}/angry%202${multiplier}.png`),
buildEmote('ANGRY_3', `${px}/angry%203${multiplier}.png`),
buildEmote('ANGRY_4', `${px}/angry%204${multiplier}.png`),
buildEmote('ANGRY_1', `${px}/angry${multiplier}.png`),
buildEmote('BLIND', `${px}/blind${multiplier}.png`),
buildEmote('BLOCK', `${px}/block${multiplier}.png`),
buildEmote('BOMB', `${px}/bomb${multiplier}.png`),
buildEmote('BRAIN_CHIP', `${px}/Brain%20chip${multiplier}.png`),
buildEmote('CONFIRM', `${px}/CONFIRM${multiplier}.png`),
buildEmote('CONFUSED_1', `${px}/confused-1${multiplier}.png`),
buildEmote('CONFUSED_2', `${px}/confused${multiplier}.png`),
buildEmote('COOKING_SOMETHING_NICE', `${px}/cooking%20something%20nice${multiplier}.png`),
buildEmote('CRY_2', `${px}/cry%202${multiplier}.png`),
buildEmote('CRY_3', `${px}/cry%203${multiplier}.png`),
buildEmote('CRY_4', `${px}/cry%204${multiplier}.png`),
buildEmote('CRY_5', `${px}/cry%205${multiplier}.png`),
buildEmote('CRY_1', `${px}/cry${multiplier}.png`),
buildEmote('SPACE_DOGE', `${px}/doge${multiplier}.png`),
buildEmote('DONUT', `${px}/donut${multiplier}.png`),
buildEmote('EGGPLANT_WITH_CONDOM', `${px}/eggplant%20with%20condom${multiplier}.png`),
buildEmote('EGGPLANT', `${px}/eggplant${multiplier}.png`),
buildEmote('FIRE_UP', `${px}/fire%20up${multiplier}.png`),
buildEmote('FLAT_EARTH', `${px}/Flat%20earth${multiplier}.png`),
buildEmote('FLYING_SAUCER', `${px}/Flying%20saucer${multiplier}.png`),
buildEmote('HEART_CHOPPER', `${px}/heart%20chopper${multiplier}.png`),
buildEmote('HYPER_TROLL', `${px}/HyperTroll${multiplier}.png`),
buildEmote('ICE_CREAM', `${px}/ice%20cream${multiplier}.png`),
buildEmote('IDK', `${px}/IDK${multiplier}.png`),
buildEmote('ILLUMINATI_1', `${px}/Illuminati-1${multiplier}.png`),
buildEmote('ILLUMINATI_2', `${px}/Illuminati${multiplier}.png`),
buildEmote('KISS_2', `${px}/kiss%202${multiplier}.png`),
buildEmote('KISS_1', `${px}/kiss${multiplier}.png`),
buildEmote('LASER_GUN', `${px}/laser%20gun${multiplier}.png`),
buildEmote('LAUGHING_2', `${px}/Laughing 2${multiplier}.png`),
buildEmote('LAUGHING_1', `${px}/Laughing${multiplier}.png`),
buildEmote('LOLLIPOP', `${px}/Lollipop${multiplier}.png`),
buildEmote('LOVE_2', `${px}/Love%202${multiplier}.png`),
buildEmote('LOVE_1', `${px}/Love${multiplier}.png`),
buildEmote('MONSTER', `${px}/Monster${multiplier}.png`),
buildEmote('MUSHROOM', `${px}/mushroom${multiplier}.png`),
buildEmote('NAIL_IT', `${px}/Nail%20It${multiplier}.png`),
buildEmote('NO', `${px}/NO${multiplier}.png`),
buildEmote('OUCH', `${px}/ouch${multiplier}.png`),
buildEmote('PREACE', `${px}/peace${multiplier}.png`),
buildEmote('PIZZA', `${px}/pizza${multiplier}.png`),
buildEmote('RABBIT_HOLE', `${px}/rabbit%20hole${multiplier}.png`),
buildEmote('RAINBOW_PUKE_1', `${px}/rainbow%20puke-1${multiplier}.png`),
buildEmote('RAINBOW_PUKE_2', `${px}/rainbow%20puke${multiplier}.png`),
buildEmote('SPACE_RESITAS', `${px}/resitas${multiplier}.png`),
buildEmote('ROCK', `${px}/ROCK${multiplier}.png`),
buildEmote('SAD', `${px}/sad${multiplier}.png`),
buildEmote('SALTY', `${px}/salty${multiplier}.png`),
buildEmote('SCARY', `${px}/scary${multiplier}.png`),
buildEmote('SLEEP', `${px}/Sleep${multiplier}.png`),
buildEmote('SLIME_DOWN', `${px}/slime%20down${multiplier}.png`),
buildEmote('SMELLY_SOCKS', `${px}/smelly%20socks${multiplier}.png`),
buildEmote('SMILE_2', `${px}/smile%202${multiplier}.png`),
buildEmote('SMILE_1', `${px}/smile${multiplier}.png`),
buildEmote('SPACE_CHAD', `${px}/space%20chad${multiplier}.png`),
buildEmote('SPACE_JULIAN', `${px}/Space%20Julian${multiplier}.png`),
buildEmote('SPACE_TOM', `${px}/space%20Tom${multiplier}.png`),
buildEmote('SPACE_WOJAK_1', `${px}/space%20wojak-1${multiplier}.png`),
buildEmote('ANGRY_3', `${px}/space%20wojak${multiplier}.png`),
buildEmote('SPOCK', `${px}/SPOCK${multiplier}.png`),
buildEmote('STAR', `${px}/Star${multiplier}.png`),
buildEmote('SUNNY_DAY', `${px}/sunny%20day${multiplier}.png`),
buildEmote('SUPRISED', `${px}/surprised${multiplier}.png`),
buildEmote('SWEET', `${px}/sweet${multiplier}.png`),
buildEmote('THINKING_1', `${px}/thinking-1${multiplier}.png`),
buildEmote('THINKING_2', `${px}/thinking${multiplier}.png`),
buildEmote('THUMB_DOWN', `${px}/thumb%20down${multiplier}.png`),
buildEmote('THUMB_UP_1', `${px}/thumb%20up-1${multiplier}.png`),
buildEmote('THUMB_UP_2', `${px}/thumb%20up${multiplier}.png`),
buildEmote('TINFOIL_HAT', `${px}/tin%20hat${multiplier}.png`),
buildEmote('TROLL_KING', `${px}/Troll%20king${multiplier}.png`),
buildEmote('UFO', `${px}/ufo${multiplier}.png`),
buildEmote('WAITING', `${px}/waiting${multiplier}.png`),
buildEmote('WHAT', `${px}/what_${multiplier}.png`),
buildEmote('WOODOO_DOLL', `${px}/woodo%20doll${multiplier}.png`),
];
export const EMOTES_24px = getEmotes('24%20px', '');
export const EMOTES_36px = getEmotes('36px', '@1.5x');
export const EMOTES_48px = getEmotes('48%20px', '@2x');
export const EMOTES_72px = getEmotes('72%20px', '@3x');

View file

@ -179,3 +179,4 @@ export const LIFE = 'Life';
export const ARTISTS = 'Artists'; export const ARTISTS = 'Artists';
export const MYSTERIES = 'Mysteries'; export const MYSTERIES = 'Mysteries';
export const TECHNOLOGY = 'Technology'; export const TECHNOLOGY = 'Technology';
export const EMOJI = 'Emoji';

View file

@ -176,12 +176,12 @@
transition: background 0.2s; transition: background 0.2s;
} }
.vjs-button--autoplay-next.vjs-button[aria-checked=true] { .vjs-button--autoplay-next.vjs-button[aria-checked='true'] {
background: var(--color-primary); background: var(--color-primary);
} }
.vjs-button--autoplay-next.vjs-button::after { .vjs-button--autoplay-next.vjs-button::after {
content: ""; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -193,7 +193,7 @@
transition: transform 0.2s; transition: transform 0.2s;
} }
.vjs-button--autoplay-next.vjs-button[aria-checked=true]::after { .vjs-button--autoplay-next.vjs-button[aria-checked='true']::after {
transform: translateX(12px); transform: translateX(12px);
} }
@ -544,12 +544,6 @@
.button--highlighted { .button--highlighted {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.button--emoji {
font-size: 1.1rem;
border-radius: 3rem;
}
.button__content { .button__content {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -0,0 +1,82 @@
@import '../init/vars';
$thumbnailWidth: 1.5rem;
$thumbnailWidthSmall: 1rem;
.comment__create {
font-size: var(--font-small);
position: relative;
fieldset-section,
.form-field--SimpleMDE {
margin-top: 0;
}
.form-field__two-column {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
}
.comment__create--reply {
margin-top: var(--spacing-m);
position: relative;
}
.content_comment {
position: relative;
}
.comment__create--nested-reply {
margin-top: var(--spacing-s);
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
@media (min-width: $breakpoint-small) {
margin-left: calc((#{$thumbnailWidth} + var(--spacing-m)) * 2 + var(--spacing-m) + 4px);
}
}
.comment__create--bottom {
padding-bottom: 0;
}
.comment-new__label-wrapper {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: baseline;
flex-wrap: wrap;
width: 100%;
@media (min-width: $breakpoint-small) {
fieldset-section {
max-width: 10rem;
}
}
}
.comment-new__label {
white-space: nowrap;
margin-right: var(--spacing-xs);
}
.comment__sc-preview {
display: flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: var(--spacing-s);
margin: var(--spacing-s) 0;
}
.comment__sc-preview-amount {
margin-right: var(--spacing-m);
font-size: var(--font-large);
}
.comment--min-amount-notice {
.icon {
margin-bottom: -3px; // TODO fix few instances of these (find "-2px")
}
}

View file

@ -32,29 +32,6 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.comment__create {
font-size: var(--font-small);
position: relative;
fieldset-section,
.form-field--SimpleMDE {
margin-top: 0;
}
.form-field__two-column {
column-count: 2;
}
}
.comment__create--reply {
margin-top: var(--spacing-m);
position: relative;
}
.comment__create--bottom {
padding-bottom: 0;
}
.comment { .comment {
width: 100%; width: 100%;
display: flex; display: flex;
@ -90,10 +67,6 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.content_comment {
position: relative;
}
.comment__thumbnail-wrapper { .comment__thumbnail-wrapper {
flex: 0; flex: 0;
margin-top: var(--spacing-xxs); margin-top: var(--spacing-xxs);
@ -136,24 +109,10 @@ $thumbnailWidthSmall: 1rem;
opacity: 0.6; opacity: 0.6;
} }
.comment__sc-preview {
display: flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: var(--spacing-s);
margin: var(--spacing-s) 0;
}
.comment__edit-input { .comment__edit-input {
margin-top: var(--spacing-xxs); margin-top: var(--spacing-xxs);
} }
.comment__sc-preview-amount {
margin-right: var(--spacing-m);
font-size: var(--font-large);
}
.comment__threadline { .comment__threadline {
@extend .button--alt; @extend .button--alt;
height: auto; height: auto;
@ -173,26 +132,6 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.comment-new__label-wrapper {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: baseline;
flex-wrap: wrap;
width: 100%;
@media (min-width: $breakpoint-small) {
fieldset-section {
max-width: 10rem;
}
}
}
.comment-new__label {
white-space: nowrap;
margin-right: var(--spacing-xs);
}
.comment--highlighted { .comment--highlighted {
background: var(--color-comment-highlighted); background: var(--color-comment-highlighted);
box-shadow: 0 0 0 5px var(--color-comment-highlighted); box-shadow: 0 0 0 5px var(--color-comment-highlighted);
@ -432,8 +371,7 @@ $thumbnailWidthSmall: 1rem;
@extend .comment__action; @extend .comment__action;
} }
.comment__action--nested, .comment__action--nested {
.comment__create--nested-reply {
margin-top: var(--spacing-s); margin-top: var(--spacing-s);
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px); margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
@ -488,12 +426,6 @@ $thumbnailWidthSmall: 1rem;
opacity: 0.5; opacity: 0.5;
} }
.comment--min-amount-notice {
.icon {
margin-bottom: -3px; // TODO fix few instances of these (find "-2px")
}
}
.comments-own { .comments-own {
.section__actions { .section__actions {
align-items: flex-start; align-items: flex-start;

View file

@ -0,0 +1,40 @@
@import '../init/vars';
.emoteSelector {
animation: menu-animate-in var(--animation-duration) var(--animation-style);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-m);
}
.emoteSelector__list {
display: flex;
flex-wrap: wrap;
overflow-y: scroll;
overflow-x: hidden;
max-height: 25vh;
padding: var(--spacing-s);
.emoteSelector__listRowItems {
display: flex;
flex-wrap: wrap;
.button--file-action {
margin: var(--spacing-xxs);
padding: var(--spacing-xs);
.button__content {
justify-content: center;
align-items: center;
align-content: center;
width: 1.5rem;
height: 1.5rem;
span {
margin: auto;
font-size: var(--font-large);
}
}
}
}
}

View file

@ -440,9 +440,7 @@ fieldset-group {
} }
.form-field__quick-action { .form-field__quick-action {
float: right;
font-size: var(--font-xsmall); font-size: var(--font-xsmall);
margin-top: 2.5%;
} }
.form-field__textarea-info { .form-field__textarea-info {
@ -454,12 +452,6 @@ fieldset-group {
margin-bottom: var(--spacing-s); margin-bottom: var(--spacing-s);
} }
.form-field__quick-emojis {
> *:not(:last-child) {
margin-right: var(--spacing-s);
}
}
fieldset-section { fieldset-section {
.form-field__internal-option { .form-field__internal-option {
margin-top: var(--spacing-s); margin-top: var(--spacing-s);

125
ui/util/remark-emote.js Normal file
View file

@ -0,0 +1,125 @@
import { EMOTES_24px as EMOTES } from 'constants/emotes';
import visit from 'unist-util-visit';
const EMOTE_NODE_TYPE = 'emote';
const RE_EMOTE = /:\+1:|:-1:|:[\w-]+:/;
// ***************************************************************************
// Tokenize emote
// ***************************************************************************
function findNextEmote(value, fromIndex, strictlyFromIndex) {
let begin = 0;
while (begin < value.length) {
const match = value.substring(begin).match(RE_EMOTE);
if (!match) return null;
match.index += begin;
if (strictlyFromIndex && match.index !== fromIndex) {
if (match.index > fromIndex) {
// Already gone past desired index. Skip the rest.
return null;
} else {
// Next match might fit 'fromIndex'.
begin = match.index + match[0].length;
continue;
}
}
if (fromIndex > 0 && fromIndex > match.index && fromIndex < match.index + match[0].length) {
// Skip previously-rejected word
// This assumes that a non-zero 'fromIndex' means that a previous lookup has failed.
begin = match.index + match[0].length;
continue;
}
const str = match[0];
if (EMOTES.some(({ name }) => str.toUpperCase() === name)) {
// Profit!
return { text: str, index: match.index };
}
if (strictlyFromIndex && match.index >= fromIndex) {
return null; // Since it failed and we've gone past the desired index, skip the rest.
}
begin = match.index + match[0].length;
}
return null;
}
function locateEmote(value, fromIndex) {
const emote = findNextEmote(value, fromIndex, false);
return emote ? emote.index : -1;
}
// Generate 'emote' markdown node
const createEmoteNode = (text) => ({
type: EMOTE_NODE_TYPE,
value: text,
children: [{ type: 'text', value: text }],
});
// Generate a markdown image from emote
function tokenizeEmote(eat, value, silent) {
if (silent) return true;
const emote = findNextEmote(value, 0, true);
if (emote) {
try {
const text = emote.text;
return eat(text)(createEmoteNode(text));
} catch (e) {}
}
}
tokenizeEmote.locator = locateEmote;
export function inlineEmote() {
const Parser = this.Parser;
const tokenizers = Parser.prototype.inlineTokenizers;
const methods = Parser.prototype.inlineMethods;
// Add an inline tokenizer (defined in the following example).
tokenizers.emote = tokenizeEmote;
// Run it just before `text`.
methods.splice(methods.indexOf('text'), 0, 'emote');
}
// ***************************************************************************
// Format emote
// ***************************************************************************
const transformer = (node, index, parent) => {
if (node.type === EMOTE_NODE_TYPE && parent && parent.type === 'paragraph') {
const emoteStr = node.value;
const emote = EMOTES.find(({ name }) => emoteStr.toUpperCase() === name);
node.type = 'image';
node.url = emote.url;
node.title = emoteStr;
node.children = [{ type: 'text', value: emoteStr }];
if (!node.data || !node.data.hProperties) {
// Create new node data
node.data = {
hProperties: { emote: true },
};
} else if (node.data.hProperties) {
// Don't overwrite current attributes
node.data.hProperties = {
emote: true,
...node.data.hProperties,
};
}
}
};
const transform = (tree) => visit(tree, [EMOTE_NODE_TYPE], transformer);
export const formattedEmote = () => transform;

View file

@ -440,9 +440,7 @@ fieldset-group {
} }
.form-field__quick-action { .form-field__quick-action {
float: right;
font-size: var(--font-xsmall); font-size: var(--font-xsmall);
margin-top: 2.5%;
} }
.form-field__textarea-info { .form-field__textarea-info {