mirror of
https://github.com/urbit/shrub.git
synced 2024-12-20 17:32:11 +03:00
Merge pull request #3435 from tylershuster/rich-text-comments
links, publish: display rich text comments
This commit is contained in:
commit
2fbf988ad6
67
pkg/interface/src/logic/lib/tokenizeMessage.js
Normal file
67
pkg/interface/src/logic/lib/tokenizeMessage.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
|
||||||
|
|
||||||
|
const isUrl = (string) => {
|
||||||
|
try {
|
||||||
|
return URL_REGEX.test(string);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenizeMessage = (text) => {
|
||||||
|
let messages = [];
|
||||||
|
let message = [];
|
||||||
|
let isInCodeBlock = false;
|
||||||
|
let endOfCodeBlock = false;
|
||||||
|
text.split(/\r?\n/).forEach((line, index) => {
|
||||||
|
if (index !== 0) {
|
||||||
|
message.push('\n');
|
||||||
|
}
|
||||||
|
// A line of backticks enters and exits a codeblock
|
||||||
|
if (line.startsWith('```')) {
|
||||||
|
// But we need to check if we've ended a codeblock
|
||||||
|
endOfCodeBlock = isInCodeBlock;
|
||||||
|
isInCodeBlock = (!isInCodeBlock);
|
||||||
|
} else {
|
||||||
|
endOfCodeBlock = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInCodeBlock || endOfCodeBlock) {
|
||||||
|
message.push(line);
|
||||||
|
} else {
|
||||||
|
line.split(/\s/).forEach((str) => {
|
||||||
|
if (
|
||||||
|
(str.startsWith('`') && str !== '`')
|
||||||
|
|| (str === '`' && !isInCodeBlock)
|
||||||
|
) {
|
||||||
|
isInCodeBlock = true;
|
||||||
|
} else if (
|
||||||
|
(str.endsWith('`') && str !== '`')
|
||||||
|
|| (str === '`' && isInCodeBlock)
|
||||||
|
) {
|
||||||
|
isInCodeBlock = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUrl(str) && !isInCodeBlock) {
|
||||||
|
if (message.length > 0) {
|
||||||
|
// If we're in the middle of a message, add it to the stack and reset
|
||||||
|
messages.push(message);
|
||||||
|
message = [];
|
||||||
|
}
|
||||||
|
messages.push([str]);
|
||||||
|
message = [];
|
||||||
|
} else {
|
||||||
|
message.push(str);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message.length) {
|
||||||
|
// Add any remaining message
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { tokenizeMessage as default, isUrl, URL_REGEX };
|
@ -4,9 +4,10 @@ import { S3Upload } from '~/views/components/s3-upload'
|
|||||||
;
|
;
|
||||||
import { uxToHex } from '~/logic/lib/util';
|
import { uxToHex } from '~/logic/lib/util';
|
||||||
import { Sigil } from '~/logic/lib/sigil';
|
import { Sigil } from '~/logic/lib/sigil';
|
||||||
|
import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
|
|
||||||
|
|
||||||
export class ChatInput extends Component {
|
export class ChatInput extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -52,7 +53,7 @@ export class ChatInput extends Component {
|
|||||||
return {
|
return {
|
||||||
me: letter
|
me: letter
|
||||||
};
|
};
|
||||||
} else if (this.isUrl(letter)) {
|
} else if (isUrl(letter)) {
|
||||||
return {
|
return {
|
||||||
url: letter
|
url: letter
|
||||||
};
|
};
|
||||||
@ -63,13 +64,7 @@ export class ChatInput extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isUrl(string) {
|
|
||||||
try {
|
|
||||||
return URL_REGEX.test(string);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(text) {
|
submit(text) {
|
||||||
const { props, state } = this;
|
const { props, state } = this;
|
||||||
@ -91,58 +86,7 @@ export class ChatInput extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = [];
|
const messages = tokenizeMessage(text);
|
||||||
let message = [];
|
|
||||||
let isInCodeBlock = false;
|
|
||||||
let endOfCodeBlock = false;
|
|
||||||
text.split(/\r?\n/).forEach((line, index) => {
|
|
||||||
if (index !== 0) {
|
|
||||||
message.push('\n');
|
|
||||||
}
|
|
||||||
// A line of backticks enters and exits a codeblock
|
|
||||||
if (line.startsWith('```')) {
|
|
||||||
// But we need to check if we've ended a codeblock
|
|
||||||
endOfCodeBlock = isInCodeBlock;
|
|
||||||
isInCodeBlock = (!isInCodeBlock);
|
|
||||||
} else {
|
|
||||||
endOfCodeBlock = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInCodeBlock || endOfCodeBlock) {
|
|
||||||
message.push(line);
|
|
||||||
} else {
|
|
||||||
line.split(/\s/).forEach((str) => {
|
|
||||||
if (
|
|
||||||
(str.startsWith('`') && str !== '`')
|
|
||||||
|| (str === '`' && !isInCodeBlock)
|
|
||||||
) {
|
|
||||||
isInCodeBlock = true;
|
|
||||||
} else if (
|
|
||||||
(str.endsWith('`') && str !== '`')
|
|
||||||
|| (str === '`' && isInCodeBlock)
|
|
||||||
) {
|
|
||||||
isInCodeBlock = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isUrl(str) && !isInCodeBlock) {
|
|
||||||
if (message.length > 0) {
|
|
||||||
// If we're in the middle of a message, add it to the stack and reset
|
|
||||||
messages.push(message);
|
|
||||||
message = [];
|
|
||||||
}
|
|
||||||
messages.push([str]);
|
|
||||||
message = [];
|
|
||||||
} else {
|
|
||||||
message.push(str);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (message.length) {
|
|
||||||
// Add any remaining message
|
|
||||||
messages.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
props.deleteMessage();
|
props.deleteMessage();
|
||||||
|
|
||||||
@ -157,25 +101,6 @@ export class ChatInput extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// perf testing:
|
|
||||||
/*let closure = () => {
|
|
||||||
let x = 0;
|
|
||||||
for (var i = 0; i < 30; i++) {
|
|
||||||
x++;
|
|
||||||
props.api.chat.message(
|
|
||||||
props.station,
|
|
||||||
`~${window.ship}`,
|
|
||||||
Date.now(),
|
|
||||||
{
|
|
||||||
text: `${x}`
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setTimeout(closure, 1000);
|
|
||||||
};
|
|
||||||
this.closure = closure.bind(this);
|
|
||||||
setTimeout(this.closure, 2000);*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadSuccess(url) {
|
uploadSuccess(url) {
|
||||||
|
@ -3,6 +3,7 @@ import { Sigil } from '~/logic/lib/sigil';
|
|||||||
import { cite } from '~/logic/lib/util';
|
import { cite } from '~/logic/lib/util';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { Box, Text, Row } from '@tlon/indigo-react';
|
import { Box, Text, Row } from '@tlon/indigo-react';
|
||||||
|
import RichText from '~/views/components/RichText';
|
||||||
|
|
||||||
export class CommentItem extends Component {
|
export class CommentItem extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -60,7 +61,7 @@ export class CommentItem extends Component {
|
|||||||
</Text>
|
</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
<Text display="block" py={3} fontSize={1}>{props.content}</Text>
|
<Text display="block" py={3} fontSize={1}><RichText remoteContentPolicy={props.remoteContentPolicy}>{props.content}</RichText></Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -40,7 +40,7 @@ export class Comments extends Component {
|
|||||||
? props.comments.totalPages
|
? props.comments.totalPages
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
const { hideNicknames, hideAvatars } = props;
|
const { hideNicknames, hideAvatars, remoteContentPolicy } = props;
|
||||||
|
|
||||||
const commentsList = Object.keys(commentsPage)
|
const commentsList = Object.keys(commentsPage)
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
@ -68,6 +68,7 @@ export class Comments extends Component {
|
|||||||
member={member}
|
member={member}
|
||||||
hideNicknames={hideNicknames}
|
hideNicknames={hideNicknames}
|
||||||
hideAvatars={hideAvatars}
|
hideAvatars={hideAvatars}
|
||||||
|
remoteContentPolicy={remoteContentPolicy}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -221,6 +221,7 @@ export class LinkDetail extends Component {
|
|||||||
api={props.api}
|
api={props.api}
|
||||||
hideAvatars={props.hideAvatars}
|
hideAvatars={props.hideAvatars}
|
||||||
hideNicknames={props.hideNicknames}
|
hideNicknames={props.hideNicknames}
|
||||||
|
remoteContentPolicy={props.remoteContentPolicy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -63,6 +63,7 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
associations,
|
associations,
|
||||||
hideNicknames,
|
hideNicknames,
|
||||||
hideAvatars,
|
hideAvatars,
|
||||||
|
remoteContentPolicy
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const active = location.pathname.endsWith("/~publish")
|
const active = location.pathname.endsWith("/~publish")
|
||||||
@ -161,6 +162,7 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
api={api}
|
api={api}
|
||||||
hideNicknames={hideNicknames}
|
hideNicknames={hideNicknames}
|
||||||
hideAvatars={hideAvatars}
|
hideAvatars={hideAvatars}
|
||||||
|
remoteContentPolicy={remoteContentPolicy}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import moment from "moment";
|
|
||||||
import { Sigil } from "~/logic/lib/sigil";
|
|
||||||
import CommentInput from "./CommentInput";
|
import CommentInput from "./CommentInput";
|
||||||
import { uxToHex, cite } from "~/logic/lib/util";
|
|
||||||
import { Comment, NoteId } from "~/types/publish-update";
|
import { Comment, NoteId } from "~/types/publish-update";
|
||||||
import { Contacts } from "~/types/contact-update";
|
import { Contacts } from "~/types/contact-update";
|
||||||
import GlobalApi from "~/logic/api/global";
|
import GlobalApi from "~/logic/api/global";
|
||||||
import { Button, Box, Row, Text } from "@tlon/indigo-react";
|
import { Box, Row } from "@tlon/indigo-react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { Author } from "./Author";
|
import { Author } from "./Author";
|
||||||
|
import tokenizeMessage from '~/logic/lib/tokenizeMessage';
|
||||||
|
import RichText from '~/views/components/RichText';
|
||||||
|
|
||||||
const ClickBox = styled(Box)`
|
const ClickBox = styled(Box)`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -28,17 +27,11 @@ interface CommentItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CommentItem(props: CommentItemProps) {
|
export function CommentItem(props: CommentItemProps) {
|
||||||
const { ship, contacts, book, note, api } = props;
|
const { ship, contacts, book, note, api, remoteContentPolicy } = props;
|
||||||
const [editing, setEditing] = useState<boolean>(false);
|
const [editing, setEditing] = useState<boolean>(false);
|
||||||
const commentPath = Object.keys(props.comment)[0];
|
const commentPath = Object.keys(props.comment)[0];
|
||||||
const commentData = props.comment[commentPath];
|
const commentData = props.comment[commentPath];
|
||||||
const content = commentData.content.split("\n").map((line, i) => {
|
const content = tokenizeMessage(commentData.content).flat().join(' ');
|
||||||
return (
|
|
||||||
<Text className="mb2" key={i}>
|
|
||||||
{line}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const disabled = props.pending || window.ship !== commentData.author.slice(1);
|
const disabled = props.pending || window.ship !== commentData.author.slice(1);
|
||||||
|
|
||||||
@ -86,14 +79,13 @@ export function CommentItem(props: CommentItemProps) {
|
|||||||
</Author>
|
</Author>
|
||||||
</Row>
|
</Row>
|
||||||
<Box mb={2}>
|
<Box mb={2}>
|
||||||
{!editing && content}
|
{editing
|
||||||
{editing && (
|
? <CommentInput
|
||||||
<CommentInput
|
|
||||||
onSubmit={onUpdate}
|
onSubmit={onUpdate}
|
||||||
initial={commentData.content}
|
initial={commentData.content}
|
||||||
label="Update"
|
label="Update"
|
||||||
/>
|
/>
|
||||||
)}
|
: <RichText className="f9 white-d" remoteContentPolicy={remoteContentPolicy}>{content}</RichText>}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -8,6 +8,7 @@ import { Contacts } from "~/types/contact-update";
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import GlobalApi from "~/logic/api/global";
|
import GlobalApi from "~/logic/api/global";
|
||||||
import { FormikHelpers } from "formik";
|
import { FormikHelpers } from "formik";
|
||||||
|
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||||
|
|
||||||
interface CommentsProps {
|
interface CommentsProps {
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
@ -21,6 +22,7 @@ interface CommentsProps {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
hideAvatars: boolean;
|
hideAvatars: boolean;
|
||||||
hideNicknames: boolean;
|
hideNicknames: boolean;
|
||||||
|
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Comments(props: CommentsProps) {
|
export function Comments(props: CommentsProps) {
|
||||||
@ -78,6 +80,7 @@ export function Comments(props: CommentsProps) {
|
|||||||
pending={true}
|
pending={true}
|
||||||
hideNicknames={props.hideNicknames}
|
hideNicknames={props.hideNicknames}
|
||||||
hideAvatars={props.hideAvatars}
|
hideAvatars={props.hideAvatars}
|
||||||
|
remoteContentPolicy={props.remoteContentPolicy}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -92,6 +95,7 @@ export function Comments(props: CommentsProps) {
|
|||||||
note={note["note-id"]}
|
note={note["note-id"]}
|
||||||
hideNicknames={props.hideNicknames}
|
hideNicknames={props.hideNicknames}
|
||||||
hideAvatars={props.hideAvatars}
|
hideAvatars={props.hideAvatars}
|
||||||
|
remoteContentPolicy={props.remoteContentPolicy}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
import { Contacts } from "~/types/contact-update";
|
import { Contacts } from "~/types/contact-update";
|
||||||
import GlobalApi from "~/logic/api/global";
|
import GlobalApi from "~/logic/api/global";
|
||||||
import { Author } from "./Author";
|
import { Author } from "./Author";
|
||||||
|
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||||
|
|
||||||
interface NoteProps {
|
interface NoteProps {
|
||||||
ship: string;
|
ship: string;
|
||||||
@ -24,6 +25,7 @@ interface NoteProps {
|
|||||||
api: GlobalApi;
|
api: GlobalApi;
|
||||||
hideAvatars: boolean;
|
hideAvatars: boolean;
|
||||||
hideNicknames: boolean;
|
hideNicknames: boolean;
|
||||||
|
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Note(props: NoteProps & RouteComponentProps) {
|
export function Note(props: NoteProps & RouteComponentProps) {
|
||||||
@ -115,6 +117,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
|||||||
api={props.api}
|
api={props.api}
|
||||||
hideNicknames={props.hideNicknames}
|
hideNicknames={props.hideNicknames}
|
||||||
hideAvatars={props.hideAvatars}
|
hideAvatars={props.hideAvatars}
|
||||||
|
remoteContentPolicy={props.remoteContentPolicy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Spinner
|
<Spinner
|
||||||
|
@ -9,6 +9,7 @@ import { Contacts, Rolodex } from "../../../../types/contact-update";
|
|||||||
import Notebook from "./Notebook";
|
import Notebook from "./Notebook";
|
||||||
import NewPost from "./new-post";
|
import NewPost from "./new-post";
|
||||||
import { NoteRoutes } from './NoteRoutes';
|
import { NoteRoutes } from './NoteRoutes';
|
||||||
|
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||||
|
|
||||||
interface NotebookRoutesProps {
|
interface NotebookRoutesProps {
|
||||||
api: GlobalApi;
|
api: GlobalApi;
|
||||||
@ -21,6 +22,7 @@ interface NotebookRoutesProps {
|
|||||||
groups: Groups;
|
groups: Groups;
|
||||||
hideAvatars: boolean;
|
hideAvatars: boolean;
|
||||||
hideNicknames: boolean;
|
hideNicknames: boolean;
|
||||||
|
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotebookRoutes(
|
export function NotebookRoutes(
|
||||||
@ -74,6 +76,7 @@ export function NotebookRoutes(
|
|||||||
contacts={notebookContacts}
|
contacts={notebookContacts}
|
||||||
hideAvatars={props.hideAvatars}
|
hideAvatars={props.hideAvatars}
|
||||||
hideNicknames={props.hideNicknames}
|
hideNicknames={props.hideNicknames}
|
||||||
|
remoteContentPolicy={props.remoteContentPolicy}
|
||||||
{...routeProps}
|
{...routeProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -56,7 +56,7 @@ export default class RemoteContent extends Component<RemoteContentProps, RemoteC
|
|||||||
wrapInLink(contents) {
|
wrapInLink(contents) {
|
||||||
return (<a
|
return (<a
|
||||||
href={this.props.url}
|
href={this.props.url}
|
||||||
className={`f7 lh-copy v-top word-break-all ${(typeof contents === 'string') ? 'bb b--white-d b--black' : ''}`}
|
className={`word-break-all ${(typeof contents === 'string') ? 'bb b--white-d b--black' : ''}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
40
pkg/interface/src/views/components/RichText.js
Normal file
40
pkg/interface/src/views/components/RichText.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React from "react";
|
||||||
|
import RemoteContent from "~/views/components/RemoteContent";
|
||||||
|
import { hasProvider } from 'oembed-parser';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
|
||||||
|
|
||||||
|
const DISABLED_BLOCK_TOKENS = [
|
||||||
|
'indentedCode',
|
||||||
|
'atxHeading',
|
||||||
|
'thematicBreak',
|
||||||
|
'list',
|
||||||
|
'setextHeading',
|
||||||
|
'html',
|
||||||
|
'definition',
|
||||||
|
'table'
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISABLED_INLINE_TOKENS = [];
|
||||||
|
|
||||||
|
const RichText = React.memo(({remoteContentPolicy, ...props}) => (
|
||||||
|
<ReactMarkdown
|
||||||
|
{...props}
|
||||||
|
renderers={{
|
||||||
|
link: (props) => {
|
||||||
|
if (hasProvider(props.href)) {
|
||||||
|
return <RemoteContent className="mw-100" url={props.href} remoteContentPolicy={remoteContentPolicy}/>;
|
||||||
|
}
|
||||||
|
return <a {...props} className="bb b--white-d b--black">{props.children}</a>
|
||||||
|
},
|
||||||
|
paragraph: (props) => {
|
||||||
|
return <p {...props} className="mb2 lh-copy">{props.children}</p>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
plugins={[[
|
||||||
|
RemarkDisableTokenizers,
|
||||||
|
{ block: DISABLED_BLOCK_TOKENS, inline: DISABLED_INLINE_TOKENS }
|
||||||
|
]]} />
|
||||||
|
));
|
||||||
|
|
||||||
|
export default RichText;
|
Loading…
Reference in New Issue
Block a user