adding virtual list from main as starting point

This commit is contained in:
Roland Osborne 2022-08-23 14:11:18 -07:00
parent 994102f22d
commit 0032e03897
17 changed files with 1170 additions and 21 deletions

View File

@ -3,12 +3,16 @@ import { SettingOutlined, RightOutlined, CloseOutlined } from '@ant-design/icons
import { useConversation } from './useConversation.hook';
import { Logo } from 'logo/Logo';
import { AddTopic } from './addTopic/AddTopic';
import { VirtualList } from './virtualList/VirtualList';
import { TopicItem } from './topicItem/TopicItem';
export function Conversation({ closeConversation, openDetails, cardId, channelId }) {
const { state, actions } = useConversation(cardId, channelId);
console.log(state);
const topicRenderer = (topic) => {
return (<TopicItem host={cardId == null} topic={topic} />)
}
return (
<ConversationWrapper>
@ -31,6 +35,8 @@ console.log(state);
)}
</div>
<div class="thread">
<VirtualList id={channelId + cardId}
items={state.topics} itemRenderer={topicRenderer} onMore={actions.more} />
</div>
<div class="divider">
<div class="line" />

View File

@ -52,6 +52,7 @@ export const ConversationWrapper = styled.div`
.thread {
flex-grow: 1;
min-height: 0;
}
.divider {

View File

@ -1,6 +1,6 @@
import { AddTopicWrapper } from './AddTopic.styled';
import { useAddTopic } from './useAddTopic.hook';
import { Input, Menu, Dropdown } from 'antd';
import { Modal, Input, Menu, Dropdown } from 'antd';
import { useRef, useState } from 'react';
import { SoundOutlined, VideoCameraOutlined, PictureOutlined, FontColorsOutlined, FontSizeOutlined, PaperClipOutlined, SendOutlined } from '@ant-design/icons';
import { SketchPicker } from "react-color";
@ -19,23 +19,37 @@ export function AddTopic({ cardId, channelId }) {
const keyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
msg.current.blur();
addTopic();
}
}
const addTopic = async () => {
try {
await actions.addTopic();
}
catch (err) {
console.log(err);
Modal.error({
title: 'Failed to Post Message',
content: 'Please try again.',
});
}
};
const onSelectImage = (e) => {
actions.addImage(e.target.files[0]);
attachImage.current.value = '';
}
};
const onSelectAudio = (e) => {
actions.addAudio(e.target.files[0]);
attachAudio.current.value = '';
}
};
const onSelectVideo = (e) => {
actions.addVideo(e.target.files[0]);
attachVideo.current.value = '';
}
};
const renderItem = (item, index) => {
if (item.image) {
@ -48,11 +62,11 @@ export function AddTopic({ cardId, channelId }) {
return <VideoFile onPosition={(pos) => actions.setPosition(index, pos)} url={item.url} />
}
return <></>
}
};
const removeItem = (index) => {
actions.removeAsset(index);
}
};
const picker = (
<Menu style={{ backgroundColor: 'unset', boxShadow: 'unset' }}>
@ -84,7 +98,8 @@ export function AddTopic({ cardId, channelId }) {
</div>
<div class="message">
<Input.TextArea ref={msg} placeholder="New Message" spellCheck="true" autoSize={{ minRows: 2, maxRows: 6 }}
autocapitalize="none" enterkeyhint="send" onKeyDown={(e) => keyDown(e)} />
enterkeyhint="send" onKeyDown={(e) => keyDown(e)} onChange={(e) => actions.setMessageText(e.target.value)}
value={state.messageText} autocapitalize="none" />
</div>
<div class="buttons">
<div class="button space" onClick={() => attachImage.current.click()}>
@ -98,17 +113,17 @@ export function AddTopic({ cardId, channelId }) {
</div>
<div class="bar space" />
<div class="button space">
<Dropdown overlay={sizer} overlayStyle={{ minWidth: 0 }} trigger={['click']} placement="topLeft">
<FontSizeOutlined />
</Dropdown>
</div>
<div class="button space">
<Dropdown overlay={picker} overlayStyle={{ minWidth: 0 }} trigger={['click']} placement="topLeft">
<Dropdown overlay={picker} overlayStyle={{ minWidth: 0 }} trigger={['click']} placement="top">
<FontColorsOutlined />
</Dropdown>
</div>
<div class="button space">
<Dropdown overlay={sizer} overlayStyle={{ minWidth: 0 }} trigger={['click']} placement="top">
<FontSizeOutlined />
</Dropdown>
</div>
<div class="end">
<div class="button"><SendOutlined /></div>
<div class="button" onClick={addTopic}><SendOutlined /></div>
</div>
</div>
</AddTopicWrapper>

View File

@ -74,8 +74,8 @@ export function useAddTopic(cardId, channelId) {
},
addTopic: async () => {
if (!state.busy) {
updateState({ busy: true });
try {
updateState({ busy: true });
let message = {
text: state.messageText,
textColor: state.textColorSet ? state.textColor : null,
@ -87,13 +87,17 @@ export function useAddTopic(cardId, channelId) {
else {
await channel.actions.addChannelTopic(channelId, message, state.assets);
}
updateState({ messageText: null, textColor: '#444444', textColorSet: false, textSize: 12, textSizeSet: false, assets: [] });
updateState({ busy: false, messageText: null, textColor: '#444444', textColorSet: false,
textSize: 12, textSizeSet: false, assets: [] });
}
catch(err) {
console.log(err);
window.alert("failed to add message");
updateState({ busy: false });
throw new Error("failed to post topic");
}
updateState({ busy: false });
}
else {
throw new Error("operation in progress");
}
},
};

View File

@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { TopicItemWrapper } from './TopicItem.styled';
import { useTopicItem } from './useTopicItem.hook';
import { VideoAsset } from './videoAsset/VideoAsset';
import { AudioAsset } from './audioAsset/AudioAsset';
import { ImageAsset } from './imageAsset/ImageAsset';
import { Logo } from 'logo/Logo';
import { Space, Skeleton, Button, Modal, Input } from 'antd';
import { ExclamationCircleOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
import { Carousel } from 'carousel/Carousel';
export function TopicItem({ host, topic }) {
const { state, actions } = useTopicItem(topic);
const [ edit, setEdit ] = useState(null);
let name = state.name ? state.name : state.handle;
let nameClass = state.name ? 'set' : 'unset';
let d = new Date();
let offset = d.getTime() / 1000 - state.created;
if (name == null) {
name = "unknown contact"
nameClass = "unknown"
}
const renderAsset = (asset) => {
if (asset.image) {
return <ImageAsset thumbUrl={actions.getAssetUrl(asset.image.thumb)}
fullUrl={actions.getAssetUrl(asset.image.full)} />
}
if (asset.video) {
return <VideoAsset thumbUrl={actions.getAssetUrl(asset.video.thumb)}
lqUrl={actions.getAssetUrl(asset.video.lq)} hdUrl={actions.getAssetUrl(asset.video.hd)} />
}
if (asset.audio) {
return <AudioAsset label={asset.audio.label} audioUrl={actions.getAssetUrl(asset.audio.full)} />
}
return <></>
}
const removeTopic = () => {
Modal.confirm({
title: 'Do you want to delete this message?',
icon: <ExclamationCircleOutlined />,
okText: 'Yes, Delete',
cancelText: 'No, Cancel',
onOk() { actions.removeTopic() },
});
}
const Options = () => {
if (state.editing) {
return <></>;
}
if (state.owner) {
return (
<div class="buttons">
<div class="button" onClick={() => actions.setEditing(true)}>
<EditOutlined />
</div>
<div class="button" onClick={() => removeTopic()}>
<DeleteOutlined />
</div>
</div>
);
}
if (host) {
return (
<div class="buttons">
<div class="button" onClick={() => removeTopic()}>
<DeleteOutlined />
</div>
</div>
);
}
return <></>;
}
const Message = () => {
if (state.editing) {
return (
<div class="editing">
<Input.TextArea style={{ resize: 'none' }} defaultValue={state.message?.text} placeholder="message"
style={{ color: state.textColor, fontSize: state.textSize }}
onChange={(e) => actions.setEdit(e.target.value)} rows={3} bordered={false}/>
<div class="controls">
<Space>
<Button onClick={() => actions.setEditing(false)}>Cancel</Button>
<Button type="primary" onClick={() => actions.setMessage()} loading={state.body}>Save</Button>
</Space>
</div>
</div>
);
}
return <div style={{ color: state.textColor, fontSize: state.textSize }}>{ state.message?.text }</div>
}
if (!state.confirmed) {
return (
<TopicItemWrapper>
<div class="avatar">
<Logo width={32} height={32} radius={4} imageUrl={state.imageUrl} />
</div>
<div class="topic">
<div class="info">
<div class={nameClass}>{ name }</div>
<div>{ getTime(offset) }</div>
</div>
<Skeleton size={'small'} active={true} />
<div class="options">
<Options />
</div>
</div>
</TopicItemWrapper>
)
}
return (
<TopicItemWrapper>
<div class="avatar">
<Logo width={32} height={32} radius={4} imageUrl={state.imageUrl} />
</div>
<div class="topic">
<div class="info">
<div class={nameClass}>{ name }</div>
<div>{ getTime(offset) }</div>
</div>
{ state.assets.length > 0 && (
<Carousel ready={state.ready} error={state.error} items={state.assets} itemRenderer={renderAsset} />
)}
<div class="message">
<Message />
</div>
<div class="options">
<Options />
</div>
</div>
</TopicItemWrapper>
)
}
function getTime(offset) {
if (offset < 1) {
return ''
}
if (offset < 60) {
return Math.floor(offset) + "s";
}
offset /= 60;
if (offset < 60) {
return Math.floor(offset) + "m";
}
offset /= 60;
if (offset < 24) {
return Math.floor(offset) + "h";
}
offset /= 24;
if (offset < 366) {
return Math.floor(offset) + "d";
}
offset /= 365.25;
return Math.floor(offset) + "y";
}

View File

@ -0,0 +1,100 @@
import styled from 'styled-components';
export const TopicItemWrapper = styled.div`
display: flex;
flex-direction: row;
width: 100%;
padding-left: 8px;
.avatar {
height: 32px;
width: 32px;
}
.topic {
display: flex;
flex-direction: column;
padding-left: 8px;
flex-grow: 1;
&:hover .options {
visibility: visible;
}
.options {
position: absolute;
top: 0;
right: 0;
visibility: hidden;
.buttons {
display: flex;
flex-direction: row;
border-radius: 4px;
background-color: #eeeeee;
border: 1px solid #555555;
margin-top: 2px;
.button {
font-size: 14px;
margin-left: 8px;
margin-right: 8px;
cursor: pointer;
}
}
}
.info {
display: flex;
flex-direction: row;
line-height: 1;
.comments {
padding-left: 8px;
cursor: pointer;
color: #888888;
}
.set {
font-weight: bold;
color: #444444;
padding-right: 8px;
}
.unset {
font-weight: bold;
font-style: italic;
color: #888888;
padding-right: 8px;
}
.unknown {
font-style: italic;
color: #aaaaaa;
padding-right: 8px;
}
}
.message {
padding-top: 6px;
padding-right: 16px;
white-space: pre-line;
.editing {
display: flex;
flex-direction: column;
border-radius: 4px;
border: 1px solid #aaaaaa;
width: 100%;
.controls {
display: flex;
flex-direction: row;
justify-content: flex-end;
padding-bottom: 8px;
padding-right: 8px;
}
}
}
}
`;

View File

@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react';
import { Button } from 'antd';
import ReactPlayer from 'react-player'
import ReactResizeDetector from 'react-resize-detector';
import { PlayCircleOutlined, MinusCircleOutlined, SoundOutlined } from '@ant-design/icons';
import { AudioAssetWrapper } from './AudioAsset.styled';
export function AudioAsset({ label, audioUrl }) {
const [active, setActive] = useState(false);
const [dimension, setDimension] = useState({});
const [playing, setPlaying] = useState(true);
const [ready, setReady] = useState(false);
const [url, setUrl] = useState(null);
useEffect(() => {
setActive(false);
setPlaying(false);
setUrl(null);
}, [label, audioUrl]);
const onReady = () => {
if (!ready) {
setReady(true);
setPlaying(false);
}
}
const onActivate = () => {
setUrl(audioUrl);
setActive(true);
}
const Control = () => {
if (!ready) {
return <></>
}
if (playing) {
return (
<div onClick={() => setPlaying(false)}>
<MinusCircleOutlined style={{ fontSize: 48, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)
}
return (
<div onClick={() => setPlaying(true)}>
<PlayCircleOutlined style={{ fontSize: 48, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)
}
return (
<AudioAssetWrapper>
<ReactResizeDetector handleWidth={false} handleHeight={true}>
{({ height }) => {
if (height != dimension.height) {
setDimension({ height });
}
return <div style={{ height: '100%', borderRadius: 4, width: dimension.height, backgroundColor: '#444444' }} />
}}
</ReactResizeDetector>
<div class="player" style={{ width: dimension.height, height: dimension.height }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ !active && (
<div onClick={() => onActivate()}>
<SoundOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
{ active && (
<Control />
)}
<ReactPlayer style={{ position: 'absolute', top: 0, visibility: 'hidden' }} playing={playing} height="100%" width="100%" controls="true" url={url} onReady={onReady} />
</div>
</div>
<div class="label">{ label }</div>
</AudioAssetWrapper>
)
}

View File

@ -0,0 +1,27 @@
import styled from 'styled-components';
export const AudioAssetWrapper = styled.div`
position: relative;
height: 100%;
.player {
top: 0;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
.label {
bottom: 0;
position: absolute;
width: 100%;
overflow: hidden;
text-align: center;
color: white;
text-overflow: ellipsis;
white-space: nowrap;
}
`;

View File

@ -0,0 +1,53 @@
import React, { useRef, useEffect, useState } from 'react';
import { Button, Modal } from 'antd';
import ReactResizeDetector from 'react-resize-detector';
import { SelectOutlined, ExpandOutlined, MinusCircleOutlined, PlayCircleOutlined } from '@ant-design/icons';
import { ImageAssetWrapper } from './ImageAsset.styled';
export function ImageAsset({ thumbUrl, fullUrl }) {
const [state, setState] = useState({ width: 0, height: 0, popout: false });
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
const onPopOut = () => {
if (state.width == 0 || state.height == 0) {
updateState({ popout: true, popWidth: '50%', popHeight: '50%' });
}
else {
if (state.width / state.height > window.innerWidth / window.innerHeight) {
updateState({ popout: true, popWidth: '80%', popHeight: 'auto' });
}
else {
let width = Math.floor(80 * (state.width / state.height) * (window.innerHeight / window.innerWidth));
updateState({ popout: true, popWidth: width + '%', popHeight: 'auto' });
}
}
}
return (
<ImageAssetWrapper>
<ReactResizeDetector handleWidth={true} handleHeight={true}>
{({ width, height }) => {
if (width != state.width || height != state.height) {
updateState({ width, height });
}
return <img style={{ height: '100%', objectFit: 'contain' }} src={thumbUrl} alt="" />
}}
</ReactResizeDetector>
<div class="viewer">
<div class="overlay" style={{ width: state.width, height: state.height }}>
<div class="expand" onClick={() => onPopOut()}>
<ExpandOutlined style={{ fontSize: 24, color: '#eeeeee', cursor: 'pointer' }} />
</div>
</div>
</div>
<Modal visible={state.popout} width={state.popWidth} height={state.popHeight} bodyStyle={{ width: '100%', height: 'auto', paddingBottom: 6, paddingTop: 6, paddingLeft: 6, paddingRight: 6, backgroundColor: '#dddddd' }} footer={null} destroyOnClose={true} closable={false} onCancel={() => { updateState({ popout: false })}}>
<img style={{ width: '100%', objectFit: 'contain' }} src={fullUrl} alt="" />
</Modal>
</ImageAssetWrapper>
)
}

View File

@ -0,0 +1,30 @@
import styled from 'styled-components';
export const ImageAssetWrapper = styled.div`
position: relative;
height: 100%;
.viewer {
top: 0;
position: absolute;
}
.viewer:hover .overlay {
visibility: visible;
}
.overlay {
visibility: hidden;
position: relative;
background-color: black;
opacity: 0.5;
}
.expand {
padding-left: 4px;
position: absolute;
bottom: 0;
left: 0;
}
`;

View File

@ -0,0 +1,126 @@
import { useContext, useState, useEffect, useRef } from 'react';
import { ConversationContext } from 'context/ConversationContext';
import { ProfileContext } from 'context/ProfileContext';
import { CardContext } from 'context/CardContext';
export function useTopicItem(topic) {
const [guid, setGuid] = useState(null);
const [state, setState] = useState({
name: null,
handle: null,
imageUrl: null,
message: null,
created: null,
confirmed: false,
ready: false,
error: false,
owner: false,
assets: [],
editing: false,
busy: false,
textColor: '#444444',
textSize: 14,
});
const profile = useContext(ProfileContext);
const card = useContext(CardContext);
const conversation = useContext(ConversationContext);
const editMessage = useRef(null);
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
useEffect(() => {
let owner = false;
if (profile.state.profile.guid == topic?.data?.topicDetail.guid) {
owner = true;
}
let textColor = '#444444';
let textSize = 14;
if (!topic?.data) {
console.log("invalid topic:", topic);
return;
}
const { status, transform, data } = topic.data.topicDetail;
let message;
let ready = false;
let error = false;
let confirmed = false;
let assets = [];
if (status === 'confirmed') {
confirmed = true;
try {
message = JSON.parse(data);
if (message.textColor != null) {
textColor = message.textColor;
}
if (message.textSize != null) {
textSize = message.textSize;
}
if (message.assets) {
assets = message.assets;
delete message.assets;
}
if (transform === 'complete') {
ready = true;
}
if (transform === 'error') {
error = true;
}
}
catch(err) {
console.log(err);
}
}
if (profile.state.init && card.state.init && conversation.state.init) {
const { guid, created } = topic.data.topicDetail;
if (profile.state.profile.guid == guid) {
const { name, handle, imageUrl } = profile.actions.getProfile();
updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created, owner, textColor, textSize });
}
else {
const { name, handle, imageUrl } = card.actions.getCardProfileByGuid(guid);
updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created, owner, textColor, textSize });
}
}
}, [profile, card, conversation, topic]);
const actions = {
getAssetUrl: (assetId) => {
return conversation.actions.getAssetUrl(topic?.id, assetId);
},
removeTopic: async () => {
return await conversation.actions.removeTopic(topic.id);
},
setEditing: (editing) => {
editMessage.current = state.message?.text;
updateState({ editing });
},
setEdit: (edit) => {
editMessage.current = edit;
},
setMessage: async () => {
if (!state.busy) {
updateState({ busy: true });
try {
await conversation.actions.setTopicSubject(topic.id,
{ ...state.message, text: editMessage.current, assets: state.assets });
updateState({ editing: false });
}
catch (err) {
window.alert(err);
}
updateState({ busy: false });
}
},
};
return { state, actions };
}

View File

@ -0,0 +1,93 @@
import React, { useRef, useEffect, useState } from 'react';
import { Button, Modal } from 'antd';
import ReactPlayer from 'react-player'
import ReactResizeDetector from 'react-resize-detector';
import { SelectOutlined, ExpandOutlined, MinusCircleOutlined, PlayCircleOutlined } from '@ant-design/icons';
import { VideoAssetWrapper } from './VideoAsset.styled';
export function VideoAsset({ thumbUrl, lqUrl, hdUrl }) {
const [state, setState] = useState({});
const player = useRef(null);
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
useEffect(() => {
}, [thumbUrl, hdUrl, lqUrl]);
const onPopOut = () => {
if (state.width == 0 || state.height == 0) {
updateState({ popout: true, popWidth: '50%', inline: false, popoutUrl: hdUrl, playing: false, inlineUrl: null });
}
else {
if (state.width / state.height > window.innerWidth / window.innerHeight) {
updateState({ popout: true, popWidth: '70%', inline: false, popoutUrl: hdUrl, playing: false, inlineUrl: null });
}
else {
let width = Math.floor(70 * (state.width / state.height) * (window.innerHeight / window.innerWidth));
updateState({ popout: true, popWidth: width + '%', inline: false, popoutUrl: hdUrl, playing: false, inlineUrl: null });
}
}
}
const CenterButton = () => {
if (!state.inline) {
return (
<div onClick={() => updateState({ inline: true, inlineUrl: lqUrl, playing: false })}>
<SelectOutlined style={{ fontSize: 48, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)
}
if (state.playing) {
return (
<div onClick={() => updateState({ playing: false })}>
<MinusCircleOutlined style={{ fontSize: 48, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)
}
else {
return (
<div onClick={() => updateState({ playing: true })}>
<PlayCircleOutlined style={{ fontSize: 48, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)
}
}
const Controls = () => {
return (
<div>
<div class="control">
<CenterButton />
</div>
<div class="expand" onClick={() => onPopOut()}>
<ExpandOutlined style={{ fontSize: 24, color: '#eeeeee', cursor: 'pointer' }} />
</div>
</div>
)
}
return (
<VideoAssetWrapper>
<ReactResizeDetector handleWidth={true} handleHeight={true}>
{({ width, height }) => {
if (width != state.width || height != state.height) {
updateState({ width, height });
}
return <img style={{ height: '100%', objectFit: 'contain' }} src={thumbUrl} alt="" />
}}
</ReactResizeDetector>
<div class="player" style={{ width: state.width, height: state.height }}>
<ReactPlayer ref={player} controls={false} playing={state.playing}
height="100%" width="100%" url={state.inlineUrl} />
<Controls />
</div>
<Modal visible={state.popout} width={state.popWidth} bodyStyle={{ paddingBottom: 0, paddingTop: 6, paddingLeft: 6, paddingRight: 6, backgroundColor: '#dddddd' }} footer={null} destroyOnClose={true} closable={false} onCancel={() => { updateState({ popout: false })}}>
<ReactPlayer controls={true} height="100%" width="100%" url={state.popoutUrl} />
</Modal>
</VideoAssetWrapper>
)
}

View File

@ -0,0 +1,49 @@
import styled from 'styled-components';
export const VideoAssetWrapper = styled.div`
position: relative;
height: 100%;
.playback {
top: 0;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
.player:hover .control {
visibility: visible;
}
.player:hover .expand {
visibility: visible;
}
.player {
position: absolute;
top: 0;
}
.control {
top: 0;
visibility: hidden;
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: black;
opacity: 0.5;
}
.expand {
padding-left: 4px;
visibility: hidden;
position: absolute;
bottom: 0;
left: 0;
}
`;

View File

@ -2,6 +2,7 @@ import { useContext, useState, useEffect } from 'react';
import { ViewportContext } from 'context/ViewportContext';
import { CardContext } from 'context/CardContext';
import { ChannelContext } from 'context/ChannelContext';
import { ConversationContext } from 'context/ConversationContext';
export function useConversation(cardId, channelId) {
@ -10,11 +11,13 @@ export function useConversation(cardId, channelId) {
image: null,
logo: null,
subject: null,
topics: [],
});
const viewport = useContext(ViewportContext);
const card = useContext(CardContext);
const channel = useContext(ChannelContext);
const conversation = useContext(ConversationContext);
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
@ -59,8 +62,29 @@ export function useConversation(cardId, channelId) {
updateState({ image, subject, logo });
}, [cardId, channelId, card, channel]);
useEffect(() => {
conversation.actions.setConversationId(cardId, channelId);
}, [cardId, channelId]);
useEffect(() => {
let topics = Array.from(conversation.state.topics.values()).sort((a, b) => {
const aTimestamp = a?.data?.topicDetail?.created;
const bTimestamp = b?.data?.topicDetail?.created;
if(aTimestamp == bTimestamp) {
return 0;
}
if(aTimestamp == null || aTimestamp < bTimestamp) {
return -1;
}
return 1;
});
updateState({ topics });
}, [conversation]);
const actions = {
more: () => {
conversation.actions.addHistory();
},
};
return { state, actions };

View File

@ -0,0 +1,344 @@
import React, { useRef, useState, useEffect } from 'react';
import { VirtualListWrapper, VirtualItem } from './VirtualList.styled';
import ReactResizeDetector from 'react-resize-detector';
export function VirtualList({ id, items, itemRenderer, onMore }) {
const REDZONE = 1024; // recenter on canvas if in canvas edge redzone
const HOLDZONE = 2048; // drop slots outside of holdzone of view
const OVERSCAN = 1024; // add slots in overscan of view
const DEFAULT_ITEM_HEIGHT = 256;
const DEFAULT_LIST_HEIGHT = 4096;
const GUTTER = 6;
const [ msg, setMsg ] = useState("YO");
const [ canvasHeight, setCanvasHeight ] = useState(16384);
const [ slots, setSlots ] = useState(new Map());
const [ scroll, setScroll ] = useState('hidden');
let update = useRef(0);
let viewHeight = useRef(DEFAULT_LIST_HEIGHT);
let latch = useRef(true);
let scrollTop = useRef(0);
let containers = useRef([]);
let listRef = useRef();
let key = useRef(null);
let itemView = useRef([]);
let nomore = useRef(false);
const addSlot = (id, slot) => {
setSlots((m) => { m.set(id, slot); return new Map(m); })
}
const updateSlot = (id, slot) => {
setSlots((m) => { m.set(id, slot); return new Map(m); })
}
const removeSlot = (id) => {
setSlots((m) => { m.delete(id); return new Map(m); });
}
const clearSlots = () => {
setSlots((m) => { return new Map() })
}
useEffect(() => {
updateCanvas();
}, [canvasHeight]);
useEffect(() => {
if (key.current != id) {
key.current = id;
latch.current = true;
containers.current = [];
clearSlots();
}
itemView.current = items;
setItems();
}, [items, id]);
const onScrollWheel = (e) => {
latch.current = false;
}
const onScrollView = (e) => {
scrollTop.current = e.target.scrollTop;
loadNextSlot();
dropSlots();
centerCanvas();
limitScroll();
}
const updateCanvas = () => {
alignSlots();
loadNextSlot();
dropSlots();
centerCanvas();
limitScroll();
latchScroll();
}
const limitScroll = () => {
let view = getPlacement();
if (view && containers.current[containers.current.length - 1].index == itemView.current.length - 1) {
if (view?.overscan?.bottom <= 0) {
if (view.position.height < viewHeight.current) {
if (scrollTop.current != view.position.top) {
scrollTop.current = view.position.top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
}
else {
if (scrollTop.current != view.position.bottom - viewHeight.current) {
scrollTop.current = view.position.bottom - viewHeight.current;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
}
latch.current = true;
}
}
if (view && containers.current[0].index == 0) {
if (view?.overscan?.top <= 0) {
scrollTop.current = containers.current[0].top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
if (!nomore.current) {
nomore.current = true;
onMore();
setTimeout(() => {
nomore.current = false;
}, 2500);
}
}
}
}
const loadNextSlot = () => {
let view = getPlacement();
if (view) {
if (view.overscan.top < OVERSCAN) {
if (containers.current[0].index > 0 && containers.current[0].index < itemView.current.length) {
let below = containers.current[0];
let container = {
top: below.top - (DEFAULT_ITEM_HEIGHT + 2 * GUTTER),
height: DEFAULT_ITEM_HEIGHT,
index: containers.current[0].index - 1,
id: itemView.current[containers.current[0].index - 1].id,
revision: itemView.current[containers.current[0].index - 1].revision,
}
containers.current.unshift(container);
addSlot(container.id, getSlot(container))
loadNextSlot();
}
}
if (view.overscan.bottom < OVERSCAN) {
if (containers.current[containers.current.length - 1].index + 1 < itemView.current.length) {
let above = containers.current[containers.current.length - 1];
let container = {
top: above.top + above.height + 2 * GUTTER,
height: DEFAULT_ITEM_HEIGHT,
index: containers.current[containers.current.length - 1].index + 1,
id: itemView.current[containers.current[containers.current.length - 1].index + 1].id,
revision: itemView.current[containers.current[containers.current.length - 1].index + 1].revision,
}
containers.current.push(container);
addSlot(container.id, getSlot(container))
loadNextSlot();
}
}
}
}
const dropSlots = () => {
while (containers.current.length > 0 &&
containers.current[0].top + containers.current[0].height + 2 * GUTTER + HOLDZONE < scrollTop.current) {
removeSlot(containers.current[0].id);
containers.current.shift();
}
while (containers.current.length > 0 &&
containers.current[containers.current.length - 1].top > scrollTop.current + viewHeight.current + HOLDZONE) {
removeSlot(containers.current[containers.current.length - 1].id);
containers.current.pop();
}
}
const alignSlots = () => {
if (containers.current.length > 1) {
let mid = Math.floor(containers.current.length / 2);
let alignTop = containers.current[mid].top + containers.current[mid].height + 2 * GUTTER;
for (let i = mid + 1; i < containers.current.length; i++) {
if (containers.current[i].top != alignTop) {
containers.current[i].top = alignTop;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
alignTop += containers.current[i].height + 2 * GUTTER;
}
let alignBottom = containers.current[mid].top;
for (let i = mid - 1; i >= 0; i--) {
alignBottom -= (containers.current[i].height + 2 * GUTTER);
if (containers.current[i].top != alignBottom) {
containers.current[i].top = alignBottom;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
}
}
};
const centerCanvas = () => {
let view = getPlacement();
if (view) {
let height = canvasHeight;
if (view.position.top < REDZONE) {
let shift = height / 2;
for (let i = 0; i < containers.current.length; i++) {
containers.current[i].top += shift;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
scrollTop.current += shift;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
else if (view.position.bottom + REDZONE > height) {
let shift = height / 2;
for (let i = 0; i < containers.current.length; i++) {
containers.current[i].top -= shift;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
scrollTop.current -= shift;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
}
}
const latchScroll = () => {
if (latch.current) {
let view = getPlacement();
if (view) {
if (view.position.height < viewHeight.current) {
if (scrollTop.current != view.position.top) {
scrollTop.current = view.position.top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0, behavior: 'smooth' });
}
}
else {
if (scrollTop.current != view.position.bottom - viewHeight.current) {
scrollTop.current = view.position.bottom - viewHeight.current;
listRef.current.scrollTo({ top: scrollTop.current, left: 0, behavior: 'smooth' });
}
}
}
}
}
const setItems = () => {
// align containers in case history was loaded
if (containers.current.length > 0) {
let container = containers.current[0];
for (let j = 0; j < itemView.current.length; j++) {
if (itemView.current[j].id == container.id) {
for(let i = 0; i < containers.current.length; i++) {
containers.current[i].index = i + j;
}
break;
}
}
}
// remove containers following any removed item
for (let i = 0; i < containers.current.length; i++) {
let container = containers.current[i];
if (itemView.current.length <= container.index || itemView.current[container.index].id != container.id) {
while (containers.current.length > i) {
let popped = containers.current.pop();
removeSlot(popped.id);
}
break;
}
else if (itemView.current[container.index].revision != container.revision) {
updateSlot(container.id, getSlot(containers.current[i]));
containers.revision = itemView.current[container.index].revision;
}
}
// place first slot
if (itemView.current.length > 0 && canvasHeight > 0) {
let view = getPlacement();
if (!view) {
let pos = canvasHeight / 2;
listRef.current.scrollTo({ top: pos, left: 0 });
scrollTop.current = pos;
let container = {
top: pos - DEFAULT_ITEM_HEIGHT,
height: DEFAULT_ITEM_HEIGHT,
index: itemView.current.length - 1,
id: itemView.current[itemView.current.length - 1].id,
revision: itemView.current[itemView.current.length - 1].revision,
}
containers.current.push(container);
addSlot(container.id, getSlot(container));
}
}
updateCanvas();
}
const onItemHeight = (container, height) => {
container.height = height;
updateCanvas();
}
const getSlot = (container) => {
return (
<VirtualItem style={{ top: container.top, paddingTop: GUTTER, paddingBottom: GUTTER }}>
<ReactResizeDetector handleHeight={true}>
{({ height }) => {
if (typeof height !== 'undefined') {
onItemHeight(container, height);
}
return itemRenderer(itemView.current[container.index]);
}}
</ReactResizeDetector>
</VirtualItem>
)
}
const getPlacement = () => {
if (containers.current.length == 0) {
return null;
}
let top = containers.current[0].top;
let bottom = containers.current[containers.current.length-1].top + containers.current[containers.current.length-1].height + 2 * GUTTER;
let overTop = scrollTop.current - top;
let overBottom = bottom - (scrollTop.current + viewHeight.current);
return {
position: { top, bottom, height: bottom - top },
overscan: { top: overTop, bottom: overBottom }
};
}
return (
<div style={{ position: 'relative', height: '100%' }}>
<ReactResizeDetector handleHeight={true} handleWidth={true}>
{({ height }) => {
if (height) {
viewHeight.current = height;
updateCanvas();
}
return (
<VirtualListWrapper onScroll={onScrollView} onWheel={onScrollWheel} onTouchStart={onScrollWheel} >
<div class="rollview" style={{ overflowY: 'auto' }} ref={listRef} onScroll={onScrollView}>
<div class="roll" style={{ height: canvasHeight }}>
{ slots.values() }
</div>
</div>
</VirtualListWrapper>
)
}}
</ReactResizeDetector>
</div>
)
}

View File

@ -0,0 +1,34 @@
import styled from 'styled-components';
export const VirtualListWrapper = styled.div`
width: 100%;
height: 100%;
overflow: hidden;
.rollview {
width: 100%;
height: 100%;
/* hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
}
.rollview::-webkit-scrollbar {
display: none;
}
.roll {
width: 100%;
position: relative;
}
`;
export const VirtualItem = styled.div`
position: absolute;
width: 100%;
overflow: hidden;
&:hover {
background-color: #f0f5e0;
}
`;

View File

@ -42,6 +42,8 @@ export const DetailsWrapper = styled.div`
align-items: center;
flex-grow: 1;
position: relative;
min-height: 0;
overflow: scroll;
.label {
padding-top: 16px;
@ -52,8 +54,6 @@ export const DetailsWrapper = styled.div`
.members {
width: 100%;
min-height: 0;
overflow: scroll;
padding-left: 16px;
}