Render text blocks

This commit is contained in:
Ken-Håvard Lieng 2020-06-30 13:24:23 +02:00
parent ca4db66308
commit 307573830a
15 changed files with 662 additions and 345 deletions

View file

@ -0,0 +1,72 @@
import React from 'react';
import stringToRGB from 'utils/color';
function nickStyle(nick, color) {
const style = {
fontWeight: 400
};
if (color) {
style.color = stringToRGB(nick);
}
return style;
}
function renderBlock(block, coloredNick, key) {
switch (block.type) {
case 'text':
return block.text;
case 'link':
return (
<a target="_blank" rel="noopener noreferrer" href={block.url} key={key}>
{block.text}
</a>
);
case 'format':
return (
<span style={block.style} key={key}>
{block.text}
</span>
);
case 'nick':
return (
<span
className="message-sender"
style={nickStyle(block.text, coloredNick)}
key={key}
>
{block.text}
</span>
);
case 'events':
return (
<span className="message-events-more" key={key}>
{block.text}
</span>
);
default:
return null;
}
}
const Text = ({ children, coloredNick }) => {
if (!children) {
return null;
}
if (children.length > 1) {
let key = 0;
return children.map(block => renderBlock(block, coloredNick, key++));
}
if (children.length === 1) {
return renderBlock(children[0], coloredNick);
}
return children;
};
export default Text;

View file

@ -2,6 +2,7 @@ import React, { memo, useState, useEffect, useRef } from 'react';
import Modal from 'react-modal';
import { useSelector, useDispatch } from 'react-redux';
import { FiUsers, FiX } from 'react-icons/fi';
import Text from 'components/Text';
import useModal from 'components/modals/useModal';
import Button from 'components/ui/Button';
import { join } from 'state/channels';
@ -33,7 +34,9 @@ const Channel = memo(({ network, name, topic, userCount, joined }) => {
</Button>
)}
</div>
<p className="modal-channel-topic">{linkify(topic)}</p>
<p className="modal-channel-topic">
<Text>{linkify(topic)}</Text>
</p>
</div>
);
});

View file

@ -2,6 +2,7 @@ import React from 'react';
import Modal from 'react-modal';
import { useSelector } from 'react-redux';
import { FiX } from 'react-icons/fi';
import Text from 'components/Text';
import Button from 'components/ui/Button';
import useModal from 'components/modals/useModal';
import { getSelectedChannel } from 'state/channels';
@ -18,7 +19,9 @@ const Topic = () => {
<h2>Topic in {channel}</h2>
<Button icon={FiX} className="modal-close" onClick={closeModal} />
</div>
<p className="modal-content">{linkify(topic)}</p>
<p className="modal-content">
<Text>{linkify(topic)}</Text>
</p>
</Modal>
);
};

View file

@ -1,5 +1,6 @@
import React, { memo } from 'react';
import classnames from 'classnames';
import Text from 'components/Text';
import stringToRGB from 'utils/color';
const Message = ({ message, coloredNick, onNickClick }) => {
@ -38,7 +39,10 @@ const Message = ({ message, coloredNick, onNickClick }) => {
{message.from}
</span>
)}
<span> {message.content}</span>
<span>
{' '}
<Text coloredNick={coloredNick}>{message.content}</Text>
</span>
</p>
);
};

View file

@ -1,4 +1,5 @@
import React, { memo } from 'react';
import Text from 'components/Text';
import { timestamp, linkify } from 'utils';
const SearchResult = ({ result }) => {
@ -16,7 +17,10 @@ const SearchResult = ({ result }) => {
{' '}
<span className="message-sender">{result.from}</span>
</span>
<span> {linkify(result.content)}</span>
<span>
{' '}
<Text>{linkify(result.content)}</Text>
</span>
</p>
);
};

View file

@ -14,7 +14,6 @@ function createContext({ dispatch, getState }, { network, channel }) {
};
}
// TODO: Pull this out as convenience action
function process({ dispatch, network, channel }, result) {
if (typeof result === 'string') {
dispatch(inform(result, network, channel));

View file

@ -20,7 +20,7 @@ describe('message reducer', () => {
'#chan1': [
{
from: 'foo',
content: 'msg'
content: [{ type: 'text', text: 'msg' }]
}
]
}
@ -54,17 +54,17 @@ describe('message reducer', () => {
'#chan1': [
{
from: 'foo',
content: 'msg'
content: [{ type: 'text', text: 'msg' }]
},
{
from: 'bar',
content: 'msg'
content: [{ type: 'text', text: 'msg' }]
}
],
'#chan2': [
{
from: 'foo',
content: 'msg'
content: [{ type: 'text', text: 'msg' }]
}
]
}
@ -197,9 +197,13 @@ describe('message reducer', () => {
expect(messages.srv).not.toHaveProperty('srv');
expect(messages.srv['#chan1']).toHaveLength(1);
expect(messages.srv['#chan1'][0].content).toBe('test');
expect(messages.srv['#chan1'][0].content).toMatchObject([
{ type: 'text', text: 'test' }
]);
expect(messages.srv['#chan3']).toHaveLength(1);
expect(messages.srv['#chan3'][0].content).toBe('test');
expect(messages.srv['#chan3'][0].content).toMatchObject([
{ type: 'text', text: 'test' }
]);
});
it('deletes all messages related to network when disconnecting', () => {

View file

@ -1,4 +1,3 @@
import React from 'react';
import { createSelector } from 'reselect';
import has from 'lodash/has';
import {
@ -10,7 +9,6 @@ import {
formatDate,
unix
} from 'utils';
import stringToRGB from 'utils/color';
import colorify from 'utils/colorify';
import createReducer from 'utils/createReducer';
import { getApp } from './app';
@ -48,6 +46,12 @@ function init(state, network, tab) {
}
}
function initNetworks(state, networks = []) {
networks.forEach(({ host }) => {
state[host] = {};
});
}
const collapsedEvents = ['join', 'part', 'quit'];
function shouldCollapse(msg1, msg2) {
@ -59,54 +63,36 @@ function shouldCollapse(msg1, msg2) {
);
}
const eventVerbs = {
join: 'joined the channel',
part: 'left the channel',
quit: 'quit'
const blocks = {
nick: nick => ({ type: 'nick', text: nick }),
text: text => ({ type: 'text', text }),
events: count => ({ type: 'events', text: `${count} more` })
};
function renderNick(nick, type = '') {
const style = {
color: stringToRGB(nick),
fontWeight: 400
};
return (
<span className="message-sender" style={style} key={`${nick} ${type}`}>
{nick}
</span>
);
}
function renderMore(count, type) {
return (
<span
className="message-events-more"
key={`more ${type}`}
>{`${count} more`}</span>
);
}
const eventVerbs = {
join: 'joined',
part: 'left',
quit: 'quit'
};
function renderEvent(event, type, nicks) {
const ending = eventVerbs[type];
if (nicks.length === 1) {
event.push(renderNick(nicks[0], type));
event.push(` ${ending}`);
}
if (nicks.length === 2) {
event.push(renderNick(nicks[0], type));
event.push(' and ');
event.push(renderNick(nicks[1], type));
event.push(` ${ending}`);
}
if (nicks.length > 2) {
event.push(renderNick(nicks[0], type));
event.push(', ');
event.push(renderNick(nicks[1], type));
event.push(' and ');
event.push(renderMore(nicks.length - 2, type));
event.push(` ${ending}`);
event.push(blocks.nick(nicks[0]));
event.push(blocks.text(` ${ending}`));
} else if (nicks.length === 2) {
event.push(blocks.nick(nicks[0]));
event.push(blocks.text(' and '));
event.push(blocks.nick(nicks[1]));
event.push(blocks.text(` ${ending}`));
} else if (nicks.length > 2) {
event.push(blocks.nick(nicks[0]));
event.push(blocks.text(', '));
event.push(blocks.nick(nicks[1]));
event.push(blocks.text(' and '));
event.push(blocks.events(nicks.length - 2));
event.push(blocks.text(` ${ending}`));
}
}
@ -115,32 +101,31 @@ function renderEvents(events) {
if (first.type === 'nick') {
const [oldNick, newNick] = first.params;
return [renderNick(oldNick), ' changed nick to ', renderNick(newNick)];
return [
blocks.nick(oldNick),
blocks.text(' changed nick to '),
blocks.nick(newNick)
];
}
if (first.type === 'kick') {
const [kicked, by] = first.params;
return [renderNick(by), ' kicked ', renderNick(kicked)];
return [blocks.nick(by), blocks.text(' kicked '), blocks.nick(kicked)];
}
if (first.type === 'topic') {
const [nick, newTopic] = first.params;
const topic = colorify(linkify(newTopic));
const [nick, topic] = first.params;
if (!topic) {
return [renderNick(nick), ' cleared the topic'];
return [blocks.nick(nick), blocks.text(' cleared the topic')];
}
const result = [renderNick(nick), ' changed the topic to: '];
if (Array.isArray(topic)) {
result.push(...topic);
} else {
result.push(topic);
}
return result;
return [
blocks.nick(nick),
blocks.text(' changed the topic to: '),
...colorify(linkify(topic))
];
}
const byType = {};
@ -163,14 +148,14 @@ function renderEvents(events) {
if (byType.part) {
if (result.length > 1) {
result[result.length - 1] += ', ';
result[result.length - 1].text += ', ';
}
renderEvent(result, 'part', byType.part);
}
if (byType.quit) {
if (result.length > 1) {
result[result.length - 1] += ', ';
result[result.length - 1].text += ', ';
}
renderEvent(result, 'quit', byType.quit);
}
@ -448,12 +433,12 @@ export default createReducer(
);
},
[actions.INIT](state, { networks }) {
initNetworks(state, networks);
},
[actions.socket.NETWORKS](state, { data }) {
if (data) {
data.forEach(({ host }) => {
state[host] = {};
});
}
initNetworks(state, data);
}
}
);

View file

@ -1,5 +1,3 @@
import React from 'react';
import TestRenderer from 'react-test-renderer';
import {
trimPrefixChar,
isChannel,
@ -9,8 +7,6 @@ import {
} from '..';
import linkify from '../linkify';
const render = el => TestRenderer.create(el).toJSON();
describe('trimPrefixChar()', () => {
it('trims prefix characters', () => {
expect(trimPrefixChar('##chan', '#')).toBe('chan');
@ -95,21 +91,31 @@ describe('isValidUsername()', () => {
describe('linkify()', () => {
const proto = href => (href.indexOf('http') !== 0 ? `http://${href}` : href);
const linkTo = href =>
render(
<a href={proto(href)} rel="noopener noreferrer" target="_blank">
{href}
</a>
);
const linkTo = href => ({
type: 'link',
url: proto(href),
text: href
});
const buildText = arr => {
for (let i = 0; i < arr.length; i++) {
if (typeof arr[i] === 'string') {
arr[i] = {
type: 'text',
text: arr[i]
};
}
}
return arr;
};
it('returns the arg when no matches are found', () =>
[null, undefined, 10, false, true, 'just some text', ''].forEach(input =>
expect(linkify(input)).toBe(input)
it('returns a text block when no matches are found', () =>
['just some text', ''].forEach(input =>
expect(linkify(input)).toStrictEqual([{ type: 'text', text: input }])
));
it('linkifies text', () =>
Object.entries({
'google.com': linkTo('google.com'),
'google.com': [linkTo('google.com')],
'google.com stuff': [linkTo('google.com'), ' stuff'],
'cake google.com stuff': ['cake ', linkTo('google.com'), ' stuff'],
'cake google.com stuff https://google.com': [
@ -129,6 +135,6 @@ describe('linkify()', () => {
'google.com ': [linkTo('google.com'), ' '],
'/google.com?': ['/', linkTo('google.com'), '?']
}).forEach(([input, expected]) =>
expect(render(linkify(input))).toEqual(expected)
expect(linkify(input)).toEqual(buildText(expected))
));
});

View file

@ -1,6 +1,4 @@
import React from 'react';
const formatChars = {
export const formatChars = {
bold: 0x02,
italic: 0x1d,
underline: 0x1f,
@ -10,7 +8,7 @@ const formatChars = {
reset: 0x0f
};
const colors = {
export const colors = {
0: 'white',
1: 'black',
2: 'blue',
@ -234,7 +232,7 @@ function tokenize(str) {
function colorifyString(str, state = {}) {
const tokens = tokenize(str);
if (typeof tokens === 'string') {
if (tokens === str) {
return [tokens, state];
}
@ -309,10 +307,17 @@ function colorifyString(str, state = {}) {
case 'text':
if (Object.keys(style).length > 0) {
result.push(<span style={style}>{token.content}</span>);
result.push({
type: 'format',
style,
text: token.content
});
style = { ...style };
} else {
result.push(token.content);
result.push({
type: 'text',
text: token.content
});
}
break;
@ -323,34 +328,25 @@ function colorifyString(str, state = {}) {
return [result, { style, reverse }];
}
export default function colorify(input) {
if (typeof input === 'string') {
const [colored] = colorifyString(input);
return colored;
}
export default function colorify(blocks) {
const result = [];
let colored;
let state;
if (Array.isArray(input)) {
const result = [];
let state;
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
for (let i = 0; i < input.length; i++) {
if (typeof input[i] === 'string') {
const [colored, nextState] = colorifyString(input[i], state);
if (typeof colored === 'string') {
result.push(colored);
} else {
result.push(...colored);
}
state = nextState;
if (block.type === 'text') {
[colored, state] = colorifyString(block.text, state);
if (colored !== block.text) {
result.push(...colored);
} else {
result.push(input[i]);
result.push(block);
}
} else {
result.push(block);
}
return result;
}
return input;
return result;
}

View file

@ -1,20 +1,44 @@
import Autolinker from 'autolinker';
import React from 'react';
const autolinker = new Autolinker({
stripPrefix: false,
stripTrailingSlash: false
});
function pushText(arr, text) {
const last = arr[arr.length - 1];
if (last?.type === 'text') {
last.text += text;
} else {
arr.push({
type: 'text',
text
});
}
}
function pushLink(arr, url, text) {
arr.push({
type: 'link',
url,
text
});
}
export default function linkify(text) {
if (!text) {
if (typeof text !== 'string') {
return text;
}
let matches = autolinker.parseText(text);
if (matches.length === 0) {
return text;
return [
{
type: 'text',
text
}
];
}
const result = [];
@ -26,46 +50,27 @@ export default function linkify(text) {
if (match.getType() === 'url') {
if (match.offset > pos) {
if (typeof result[result.length - 1] === 'string') {
result[result.length - 1] += text.slice(pos, match.offset);
} else {
result.push(text.slice(pos, match.offset));
}
pushText(result, text.slice(pos, match.offset));
}
result.push(
<a
target="_blank"
rel="noopener noreferrer"
href={match.getAnchorHref()}
key={i}
>
{match.matchedText}
</a>
);
} else if (typeof result[result.length - 1] === 'string') {
result[result.length - 1] += text.slice(
pos,
match.offset + match.matchedText.length
);
pushLink(result, match.getAnchorHref(), match.matchedText);
} else {
result.push(text.slice(pos, match.offset + match.matchedText.length));
pushText(
result,
text.slice(pos, match.offset + match.matchedText.length)
);
}
pos = match.offset + match.matchedText.length;
}
if (pos < text.length) {
if (typeof result[result.length - 1] === 'string') {
result[result.length - 1] += text.slice(pos);
if (result[result.length - 1]?.type === 'text') {
result[result.length - 1].text += text.slice(pos);
} else {
result.push(text.slice(pos));
pushText(result, text.slice(pos));
}
}
if (result.length === 1) {
return result[0];
}
return result;
}

View file

@ -2,37 +2,24 @@ const lineHeight = 24;
const userListWidth = 200;
const smallScreen = 600;
function findBreakpointsString(text, breakpoints, index) {
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
if (char === ' ') {
breakpoints.push({ end: i + index, next: i + 1 + index });
} else if (i !== text.length - 1 && (char === '-' || char === '?')) {
breakpoints.push({ end: i + 1 + index, next: i + 1 + index });
}
}
}
export function findBreakpoints(text) {
export function findBreakpoints(blocks) {
const breakpoints = [];
let length = 0;
if (typeof text === 'string') {
findBreakpointsString(text, breakpoints, length);
length = text.length;
} else if (Array.isArray(text)) {
for (let i = 0; i < text.length; i++) {
const node = text[i];
for (let j = 0; j < blocks.length; j++) {
const {text} = blocks[j];
if (typeof node === 'string') {
findBreakpointsString(node, breakpoints, length);
length += node.length;
} else {
findBreakpointsString(node.props.children, breakpoints, length);
length += node.props.children.length;
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
if (char === ' ') {
breakpoints.push({ end: length + i, next: length + i + 1 });
} else if (i !== text.length - 1 && (char === '-' || char === '?')) {
breakpoints.push({ end: length + i + 1, next: length + i + 1 });
}
}
length += text.length;
}
return [breakpoints, length];