chat-fe: MVP graphification

This commit is contained in:
Liam Fitzgerald 2020-11-23 15:57:43 +10:00
parent a1cf88faba
commit 403ef0a275
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
11 changed files with 142 additions and 160 deletions

View File

@ -67,6 +67,9 @@ function moduleToMark(mod: string): string | undefined {
if(mod === 'publish') {
return 'graph-validator-publish';
}
if(mod === 'chat') {
return 'graph-validator-chat';
}
return undefined;
}

View File

@ -1,3 +1,5 @@
import urbitOb from 'urbit-ob';
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
const isUrl = (string) => {
@ -45,11 +47,20 @@ const tokenizeMessage = (text) => {
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);
messages.push({ text: message.join('') });
message = [];
}
messages.push([str]);
messages.push({ url: str });
message = [];
} else if(urbitOb.isValidPatp(str) && !isInCodeBlock) {
if (message.length > 0) {
// If we're in the middle of a message, add it to the stack and reset
messages.push({ text: message.join('') });
message = [];
}
messages.push({ mention: str });
message = [];
} else {
message.push(str);
}
@ -59,9 +70,9 @@ const tokenizeMessage = (text) => {
if (message.length) {
// Add any remaining message
messages.push(message);
messages.push({ text: message.join('') });
}
return messages;
};
export { tokenizeMessage as default, isUrl, URL_REGEX };
export { tokenizeMessage as default, isUrl, URL_REGEX };

View File

@ -8,9 +8,12 @@ export interface UrlContent {
url: string;
}
export interface CodeContent {
expresssion: string;
output: string;
code: {
expresssion: string;
output: string | undefined;
}
}
export interface ReferenceContent {
uid: string;
}

View File

@ -11,6 +11,7 @@ import ChatInput from './components/ChatInput';
import GlobalApi from '~/logic/api/global';
import { SubmitDragger } from '~/views/components/s3-upload';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import {Loading} from '~/views/components/Loading';
type ChatResourceProps = StoreState & {
association: Association;
@ -31,6 +32,8 @@ export function ChatResource(props: ChatResourceProps) {
const group = props.groups[groupPath];
const contacts = props.contacts[groupPath] || {};
const graph = props.graphs[station.slice(7)];
const pendingMessages = (props.pendingMessages.get(station) || []).map(
value => ({
...value,
@ -38,12 +41,7 @@ export function ChatResource(props: ChatResourceProps) {
})
);
const isChatMissing =
(props.chatInitialized &&
!(station in props.inbox) &&
props.chatSynced &&
!(station in props.chatSynced)) ||
false;
const isChatMissing = !props.graphKeys.has(station.slice(7));
const isChatLoading =
(props.chatInitialized &&
@ -61,12 +59,16 @@ export function ChatResource(props: ChatResourceProps) {
const unreadCount = length - read;
const unreadMsg = unreadCount > 0 && envelopes[unreadCount - 1];
const [, owner, name] = station.split('/');
const [,, owner, name] = station.split('/');
const ourContact = contacts?.[window.ship];
const lastMsgNum = envelopes.length || 0;
const chatInput = useRef<ChatInput>();
useEffect(() => {
props.api.graph.getGraph(owner,name);
}, [station]);
const onFileDrag = useCallback(
(files: FileList) => {
if (!chatInput.current) {
@ -102,21 +104,26 @@ export function ChatResource(props: ChatResourceProps) {
return clear;
}, [station]);
if(!graph) {
return <Loading />;
}
return (
<Col {...bind} height="100%" overflow="hidden" position="relative">
{dragging && <SubmitDragger />}
<ChatWindow
remoteContentPolicy={props.remoteContentPolicy}
mailboxSize={length}
mailboxSize={5}
match={props.match as any}
stationPendingMessages={pendingMessages}
stationPendingMessages={[]}
history={props.history}
isChatMissing={isChatMissing}
isChatLoading={isChatLoading}
isChatUnsynced={isChatUnsynced}
unreadCount={unreadCount}
unreadMsg={unreadMsg}
envelopes={envelopes || []}
graph={graph}
unreadCount={0}
unreadMsg={false}
envelopes={[]}
contacts={contacts}
association={props.association}
group={group}

View File

@ -3,10 +3,11 @@ import ChatEditor from './chat-editor';
import { S3Upload } from '~/views/components/s3-upload' ;
import { uxToHex } from '~/logic/lib/util';
import { Sigil } from '~/logic/lib/sigil';
import { createPost } from '~/logic/api/graph';
import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
import GlobalApi from '~/logic/api/global';
import { Envelope } from '~/types/chat-update';
import { Contacts } from '~/types';
import { Contacts, Content } from '~/types';
import { Row, BaseImage, Box, Icon } from '@tlon/indigo-react';
interface ChatInputProps {
@ -82,39 +83,23 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
submit(text) {
const { props, state } = this;
const [,,ship,name] = props.station.split('/');
if (state.inCodeMode) {
this.setState({
inCodeMode: false
}, () => {
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(), {
code: {
expression: text,
output: undefined
}
}
);
const contents: Content[] = [{ code: { expression: text, output: undefined }}];
const post = createPost(contents);
props.api.graph.addPost(ship, name, post);
});
return;
}
const messages = tokenizeMessage(text);
const post = createPost(tokenizeMessage((text)))
props.deleteMessage();
messages.forEach((message) => {
if (message.length > 0) {
message = this.getLetterType(message.join(' '));
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
message
);
}
});
props.api.graph.addPost(ship,name, post);
}
uploadSuccess(url) {
@ -123,12 +108,8 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
this.chatEditor.current.editor.setValue(url);
this.setState({ uploadingPaste: false });
} else {
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
{ url }
);
const [,,ship,name] = props.station.split('/');
props.api.graph.addPost(ship,name, createPost([{ url }]));
}
}

View File

@ -6,10 +6,11 @@ import { Box, Row, Text, Rule } from "@tlon/indigo-react";
import { OverlaySigil } from './overlay-sigil';
import { uxToHex, cite, writeText } from '~/logic/lib/util';
import { Envelope, IMessage } from "~/types/chat-update";
import { Group, Association, Contacts, LocalUpdateRemoteContentPolicy } from "~/types";
import { Group, Association, Contacts, LocalUpdateRemoteContentPolicy, Post } from "~/types";
import TextContent from './content/text';
import CodeContent from './content/code';
import RemoteContent from '~/views/components/RemoteContent';
import { Mention } from "~/views/components/MentionText";
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
@ -30,9 +31,9 @@ export const DayBreak = ({ when }) => (
interface ChatMessageProps {
measure(element): void;
msg: Envelope | IMessage;
previousMsg?: Envelope | IMessage;
nextMsg?: Envelope | IMessage;
msg: Post;
previousMsg?: Post;
nextMsg?: Post;
isLastRead: boolean;
group: Group;
association: Association;
@ -91,13 +92,13 @@ export default class ChatMessage extends Component<ChatMessageProps> {
} = this.props;
const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1);
const dayBreak = nextMsg && new Date(msg.when).getDate() !== new Date(nextMsg.when).getDate();
const dayBreak = nextMsg && new Date(msg['time-sent']).getDate() !== new Date(nextMsg['time-sent']).getDate();
const containerClass = `${renderSigil
? `cf pl2 lh-copy`
: `items-top cf hide-child`} ${isPending ? 'o-40' : ''} ${className}`
const timestamp = moment.unix(msg.when / 1000).format(renderSigil ? 'hh:mm a' : 'hh:mm');
const timestamp = moment.unix(msg['time-sent'] / 1000).format(renderSigil ? 'hh:mm a' : 'hh:mm');
const reboundMeasure = (event) => {
return measure(this.divRef.current);
@ -140,7 +141,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
ref={this.divRef}
className={containerClass}
style={style}
data-number={msg.number}
mb={1}
>
{dayBreak && !isLastRead ? <DayBreak when={msg.when} /> : null}
@ -156,7 +156,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
}
interface MessageProps {
msg: Envelope | IMessage;
msg: Post;
timestamp: string;
group: Group;
association: Association;
@ -191,10 +191,10 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
fontSize
} = this.props;
const datestamp = moment.unix(msg.when / 1000).format(DATESTAMP_FORMAT);
const datestamp = moment.unix(msg['time-sent']).format(DATESTAMP_FORMAT);
const contact = msg.author in contacts ? contacts[msg.author] : false;
const showNickname = !hideNicknames && contact && contact.nickname;
const name = showNickname ? contact.nickname : cite(msg.author);
const name = showNickname ? contact!.nickname : cite(msg.author);
const color = contact ? `#${uxToHex(contact.color)}` : this.isDark ? '#000000' :'#FFFFFF'
const sigilClass = contact ? '' : this.isDark ? 'mix-blend-diff' : 'mix-blend-darken';
@ -251,23 +251,32 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
<Text flexShrink='0' gray mono className="v-mid">{timestamp}</Text>
<Text flexShrink={0} gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text>
</Box>
<Box flexShrink={0} fontSize={fontSize ? fontSize : '14px'}><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} fontSize={fontSize} /></Box>
<Box flexShrink={0} fontSize={fontSize ? fontSize : '14px'}>
{msg.contents.map(c =>
<MessageContent
contacts={contacts}
content={c}
remoteContentPolicy={remoteContentPolicy}
measure={measure}
fontSize={fontSize}
/>)}
</Box>
</Box>
</>
);
}
}
export const MessageWithoutSigil = ({ timestamp, msg, remoteContentPolicy, measure }) => (
export const MessageWithoutSigil = ({ timestamp, contacts, msg, remoteContentPolicy, measure }) => (
<>
<Text flexShrink={0} mono gray display='inline-block' pt='2px' lineHeight='tall' className="child">{timestamp}</Text>
<Box flexShrink={0} fontSize='14px' className="clamp-message" style={{ flexGrow: 1 }}>
<MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure}/>
{msg.contents.map(c => (<MessageContent contacts={contacts} content={c} remoteContentPolicy={remoteContentPolicy} measure={measure}/>))}
</Box>
</>
);
export const MessageContent = ({ content, remoteContentPolicy, measure, fontSize }) => {
export const MessageContent = ({ content, contacts, remoteContentPolicy, measure, fontSize }) => {
if ('code' in content) {
return <CodeContent content={content} />;
} else if ('url' in content) {
@ -286,15 +295,10 @@ export const MessageContent = ({ content, remoteContentPolicy, measure, fontSize
/>
</Text>
);
} else if ('me' in content) {
return (
<Text flexShrink={0} fontStyle='italic' fontSize={fontSize ? fontSize : '14px'} lineHeight='tall' color='black'>
{content.me}
</Text>
);
}
else if ('text' in content) {
} else if ('text' in content) {
return <TextContent fontSize={fontSize} content={content} />;
} else if ('mention' in content) {
return <Mention ship={content.mention} contacts={contacts} />
} else {
return null;
}

View File

@ -9,7 +9,7 @@ import { Contacts } from "~/types/contact-update";
import { Association } from "~/types/metadata-update";
import { Group } from "~/types/group-update";
import { Envelope, IMessage } from "~/types/chat-update";
import { LocalUpdateRemoteContentPolicy } from "~/types";
import { LocalUpdateRemoteContentPolicy, Graph } from "~/types";
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
import VirtualScroller from "~/views/components/VirtualScroller";
@ -29,13 +29,12 @@ type ChatWindowProps = RouteComponentProps<{
station: string;
}> & {
unreadCount: number;
envelopes: Envelope[];
isChatMissing: boolean;
isChatLoading: boolean;
isChatUnsynced: boolean;
unreadMsg: Envelope | false;
stationPendingMessages: IMessage[];
mailboxSize: number;
graph: Graph;
contacts: Contacts;
association: Association;
group: Group;
@ -58,6 +57,7 @@ interface ChatWindowState {
export default class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
private virtualList: VirtualScroller | null;
private unreadMarkerRef: React.RefObject<HTMLDivElement>;
private prevSize = 0;
INITIALIZATION_MAX_TIME = 1500;
@ -112,6 +112,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
}
initialFetch() {
/*
const { envelopes, mailboxSize, unreadCount } = this.props;
if (envelopes.length > 0) {
const start = Math.min(mailboxSize - unreadCount, mailboxSize - DEFAULT_BACKLOG_SIZE);
@ -127,28 +128,37 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.initialFetch();
}, 2000);
}
*/
}
componentDidUpdate(prevProps: ChatWindowProps, prevState) {
const { isChatMissing, history, envelopes, mailboxSize, stationPendingMessages, unreadCount, station } = this.props;
const { isChatMissing, history, graph, unreadCount, station } = this.props;
if (isChatMissing) {
history.push("/~404");
} else if (envelopes.length !== prevProps.envelopes.length && this.state.fetchPending) {
} else if (graph.size !== prevProps.graph.size && this.state.fetchPending) {
this.setState({ fetchPending: false });
}
if ((mailboxSize !== prevProps.mailboxSize) || (envelopes.length !== prevProps.envelopes.length)) {
/*if ((mailboxSize !== prevProps.mailboxSize) || (envelopes.length !== prevProps.envelopes.length)) {
this.virtualList?.calculateVisibleItems();
this.stayLockedIfActive();
}
}*/
if (unreadCount > prevProps.unreadCount && this.state.idle) {
/*if (unreadCount > prevProps.unreadCount && this.state.idle) {
this.setState({
lastRead: unreadCount ? mailboxSize - unreadCount : -1,
});
}
}*/
console.log(graph.size);
console.log(prevProps.graph.size);
if(this.prevSize !== graph.size) {
this.prevSize = graph.size;
this.virtualList?.calculateVisibleItems();
}
/*
if (stationPendingMessages.length !== prevProps.stationPendingMessages.length) {
this.virtualList?.calculateVisibleItems();
}
@ -164,6 +174,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
lastRead: unreadCount ? mailboxSize - unreadCount : -1,
});
}
*/
}
stayLockedIfActive() {
@ -174,16 +185,18 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
}
scrollToUnread() {
/*
const { mailboxSize, unreadCount, scrollTo } = this.props;
const target = scrollTo || (mailboxSize - unreadCount);
this.virtualList?.scrollToData(target);
*/
}
dismissUnread() {
if (this.state.fetchPending) return;
if (this.props.unreadCount === 0) return;
this.props.api.chat.read(this.props.station);
this.props.api.hark.readIndex({ chat: { chat: this.props.station, mention: false }});
//this.props.api.chat.read(this.props.station);
//this.props.api.hark.readIndex({ chat: { chat: this.props.station, mention: false }});
}
fetchMessages(start, end, force = false): Promise<void> {
@ -235,7 +248,6 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
render() {
const {
envelopes,
stationPendingMessages,
unreadCount,
unreadMsg,
@ -248,6 +260,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
group,
contacts,
mailboxSize,
graph,
hideAvatars,
hideNicknames,
remoteContentPolicy,
@ -256,27 +269,12 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
const unreadMarkerRef = this.unreadMarkerRef;
const messages = new BigIntOrderedMap();
let lastMessage = 0;
[...envelopes]
.sort((a, b) => a.number - b.number)
.forEach(message => {
const num = bigInt(message.number);
messages.set(num, message);
lastMessage = message.number;
});
stationPendingMessages
.sort((a, b) => a.when - b.when)
.forEach((message, index) => {
const idx = bigInt(index + 1); // To 1-index it
messages.set(bigInt(mailboxSize).add(idx), message);
lastMessage = mailboxSize + index;
});
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy, unreadMarkerRef, history, api };
const keys = graph.keys().reverse();
return (
<>
<UnreadNotice
@ -296,10 +294,10 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.dismissUnread();
}}
onScroll={this.onScroll.bind(this)}
data={messages}
size={mailboxSize + stationPendingMessages.length}
data={graph}
size={graph.size}
renderer={({ index, measure, scrollWindow }) => {
const msg: Envelope | IMessage = messages.get(index);
const msg = graph.get(index)!.post;
if (!msg) return null;
if (!this.state.initialized) {
return <MessagePlaceholder key={index.toString()} height="64px" index={index} />;
@ -309,11 +307,14 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
const isLastRead: boolean = Boolean(!isLastMessage && index.eq(bigInt(this.state.lastRead)));
const highlighted = bigInt(this.props.scrollTo || -1).eq(index);
const props = { measure, highlighted, scrollWindow, isPending, isLastRead, isLastMessage, msg, ...messageProps };
const graphIdx = keys.findIndex(idx => idx.eq(index));
const prevIdx = keys[graphIdx+1];
const nextIdx = keys[graphIdx-1];
return (
<ChatMessage
key={index.toString()}
previousMsg={messages.get(index.add(bigInt.one))}
nextMsg={messages.get(index.subtract(bigInt.one))}
previousMsg={prevIdx && graph.get(prevIdx)?.post}
nextMsg={nextIdx && graph.get(nextIdx)?.post}
{...props}
/>
);

View File

@ -37,7 +37,7 @@ export function MentionText(props: MentionTextProps) {
);
}
function Mention(props: { ship: string; contacts: Contacts }) {
export function Mention(props: { ship: string; contacts: Contacts }) {
const { contacts, ship } = props;
const contact = contacts[ship];
const showNickname = !!contact?.nickname;

View File

@ -1,4 +1,4 @@
import React, { PureComponent } from 'react';
import React, { PureComponent, Component } from 'react';
import _ from 'lodash';
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
import normalizeWheel from 'normalize-wheel';
@ -34,10 +34,10 @@ interface VirtualScrollerState {
scrollTop: number;
}
export default class VirtualScroller extends PureComponent<VirtualScrollerProps, VirtualScrollerState> {
export default class VirtualScroller extends Component<VirtualScrollerProps, VirtualScrollerState> {
private scrollContainer: React.RefObject<HTMLDivElement>;
public window: HTMLDivElement | null;
private cache: BigIntOrderedMap<BigInteger, any>;
private cache: BigIntOrderedMap<any>;
private pendingLoad: {
start: BigInteger;
end: BigInteger
@ -144,8 +144,8 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
//console.log([...items].map(([index]) => this.heightOf(index)));
const list = [...data];
console.log(list[0][0].toString());
//const list = [...data];
//console.log(list[0][0].toString());
// console.log(list[list.length - 1][0].toString());
[...data].forEach(([index, datum]) => {
const height = this.heightOf(index);

View File

@ -95,14 +95,11 @@ export function GroupsPane(props: GroupsPaneProps) {
string,
string
>;
const appName = app as AppName;
const isGraph = appIsGraph(app);
const resource = `${isGraph ? "/ship" : ""}/${host}/${name}`;
const association =
isGraph
? associations.graph[resource]
: associations[appName][resource];
const appName = app as AppName;
const resource = `/ship/${host}/${name}`;
const association = associations.graph[resource]
const resourceUrl = `${baseUrl}/resource/${app}${resource}`;
if (!association) {
@ -133,10 +130,8 @@ export function GroupsPane(props: GroupsPaneProps) {
path={relativePath("/join/:app/(ship)?/:host/:name")}
render={(routeProps) => {
const { app, host, name } = routeProps.match.params;
const appName = app as AppName;
const isGraph = appIsGraph(app);
const appPath = `${isGraph ? '/ship/' : '/'}${host}/${name}`;
const association = isGraph ? associations.graph[appPath] : associations[appName][appPath];
const appPath = `/ship/${host}/${name}`;
const association = associations.graph[appPath];
const resourceUrl = `${baseUrl}/join/${app}${appPath}`;
if (!association) {

View File

@ -52,44 +52,22 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
const resId: string = stringToSymbol(values.name);
try {
const { name, description, moduleType, ships } = values;
switch (moduleType) {
case 'chat':
const appPath = `/~${window.ship}/${resId}`;
const groupPath = group || `/ship${appPath}`;
await api.chat.create(
name,
description,
appPath,
groupPath,
{ invite: { pending: ships.map(s => `~${s}`) } },
ships.map(s => `~${s}`),
true,
false
);
break;
case "publish":
case "link":
if (group) {
await api.graph.createManagedGraph(
resId,
name,
description,
group,
moduleType
);
} else {
await api.graph.createUnmanagedGraph(
resId,
name,
description,
{ invite: { pending: ships.map((s) => `~${s}`) } },
moduleType
);
}
break;
default:
console.log('fallthrough');
if (group) {
await api.graph.createManagedGraph(
resId,
name,
description,
group,
moduleType
);
} else {
await api.graph.createUnmanagedGraph(
resId,
name,
description,
{ invite: { pending: ships.map((s) => `~${s}`) } },
moduleType
);
}
if (!group) {
@ -98,8 +76,7 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
actions.setStatus({ success: null });
const resourceUrl = parentPath(location.pathname);
history.push(
`${resourceUrl}/resource/${moduleType}` +
`${moduleType !== 'chat' ? '/ship' : ''}/~${window.ship}/${resId}`
`${resourceUrl}/resource/${moduleType}/ship/~${window.ship}/${resId}`
);
} catch (e) {
console.error(e);