mirror of
https://github.com/balzack/databag.git
synced 2025-02-14 20:49:16 +00:00
adding virtual list from main as starting point
This commit is contained in:
parent
994102f22d
commit
0032e03897
@ -3,12 +3,16 @@ import { SettingOutlined, RightOutlined, CloseOutlined } from '@ant-design/icons
|
|||||||
import { useConversation } from './useConversation.hook';
|
import { useConversation } from './useConversation.hook';
|
||||||
import { Logo } from 'logo/Logo';
|
import { Logo } from 'logo/Logo';
|
||||||
import { AddTopic } from './addTopic/AddTopic';
|
import { AddTopic } from './addTopic/AddTopic';
|
||||||
|
import { VirtualList } from './virtualList/VirtualList';
|
||||||
|
import { TopicItem } from './topicItem/TopicItem';
|
||||||
|
|
||||||
export function Conversation({ closeConversation, openDetails, cardId, channelId }) {
|
export function Conversation({ closeConversation, openDetails, cardId, channelId }) {
|
||||||
|
|
||||||
const { state, actions } = useConversation(cardId, channelId);
|
const { state, actions } = useConversation(cardId, channelId);
|
||||||
|
|
||||||
console.log(state);
|
const topicRenderer = (topic) => {
|
||||||
|
return (<TopicItem host={cardId == null} topic={topic} />)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConversationWrapper>
|
<ConversationWrapper>
|
||||||
@ -31,6 +35,8 @@ console.log(state);
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="thread">
|
<div class="thread">
|
||||||
|
<VirtualList id={channelId + cardId}
|
||||||
|
items={state.topics} itemRenderer={topicRenderer} onMore={actions.more} />
|
||||||
</div>
|
</div>
|
||||||
<div class="divider">
|
<div class="divider">
|
||||||
<div class="line" />
|
<div class="line" />
|
||||||
|
@ -52,6 +52,7 @@ export const ConversationWrapper = styled.div`
|
|||||||
|
|
||||||
.thread {
|
.thread {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { AddTopicWrapper } from './AddTopic.styled';
|
import { AddTopicWrapper } from './AddTopic.styled';
|
||||||
import { useAddTopic } from './useAddTopic.hook';
|
import { useAddTopic } from './useAddTopic.hook';
|
||||||
import { Input, Menu, Dropdown } from 'antd';
|
import { Modal, Input, Menu, Dropdown } from 'antd';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { SoundOutlined, VideoCameraOutlined, PictureOutlined, FontColorsOutlined, FontSizeOutlined, PaperClipOutlined, SendOutlined } from '@ant-design/icons';
|
import { SoundOutlined, VideoCameraOutlined, PictureOutlined, FontColorsOutlined, FontSizeOutlined, PaperClipOutlined, SendOutlined } from '@ant-design/icons';
|
||||||
import { SketchPicker } from "react-color";
|
import { SketchPicker } from "react-color";
|
||||||
@ -19,23 +19,37 @@ export function AddTopic({ cardId, channelId }) {
|
|||||||
const keyDown = (e) => {
|
const keyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
msg.current.blur();
|
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) => {
|
const onSelectImage = (e) => {
|
||||||
actions.addImage(e.target.files[0]);
|
actions.addImage(e.target.files[0]);
|
||||||
attachImage.current.value = '';
|
attachImage.current.value = '';
|
||||||
}
|
};
|
||||||
|
|
||||||
const onSelectAudio = (e) => {
|
const onSelectAudio = (e) => {
|
||||||
actions.addAudio(e.target.files[0]);
|
actions.addAudio(e.target.files[0]);
|
||||||
attachAudio.current.value = '';
|
attachAudio.current.value = '';
|
||||||
}
|
};
|
||||||
|
|
||||||
const onSelectVideo = (e) => {
|
const onSelectVideo = (e) => {
|
||||||
actions.addVideo(e.target.files[0]);
|
actions.addVideo(e.target.files[0]);
|
||||||
attachVideo.current.value = '';
|
attachVideo.current.value = '';
|
||||||
}
|
};
|
||||||
|
|
||||||
const renderItem = (item, index) => {
|
const renderItem = (item, index) => {
|
||||||
if (item.image) {
|
if (item.image) {
|
||||||
@ -48,11 +62,11 @@ export function AddTopic({ cardId, channelId }) {
|
|||||||
return <VideoFile onPosition={(pos) => actions.setPosition(index, pos)} url={item.url} />
|
return <VideoFile onPosition={(pos) => actions.setPosition(index, pos)} url={item.url} />
|
||||||
}
|
}
|
||||||
return <></>
|
return <></>
|
||||||
}
|
};
|
||||||
|
|
||||||
const removeItem = (index) => {
|
const removeItem = (index) => {
|
||||||
actions.removeAsset(index);
|
actions.removeAsset(index);
|
||||||
}
|
};
|
||||||
|
|
||||||
const picker = (
|
const picker = (
|
||||||
<Menu style={{ backgroundColor: 'unset', boxShadow: 'unset' }}>
|
<Menu style={{ backgroundColor: 'unset', boxShadow: 'unset' }}>
|
||||||
@ -84,7 +98,8 @@ export function AddTopic({ cardId, channelId }) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="message">
|
<div class="message">
|
||||||
<Input.TextArea ref={msg} placeholder="New Message" spellCheck="true" autoSize={{ minRows: 2, maxRows: 6 }}
|
<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>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<div class="button space" onClick={() => attachImage.current.click()}>
|
<div class="button space" onClick={() => attachImage.current.click()}>
|
||||||
@ -98,17 +113,17 @@ export function AddTopic({ cardId, channelId }) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="bar space" />
|
<div class="bar space" />
|
||||||
<div class="button space">
|
<div class="button space">
|
||||||
<Dropdown overlay={sizer} overlayStyle={{ minWidth: 0 }} trigger={['click']} placement="topLeft">
|
<Dropdown overlay={picker} overlayStyle={{ minWidth: 0 }} trigger={['click']} placement="top">
|
||||||
<FontSizeOutlined />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="button space">
|
|
||||||
<Dropdown overlay={picker} overlayStyle={{ minWidth: 0 }} trigger={['click']} placement="topLeft">
|
|
||||||
<FontColorsOutlined />
|
<FontColorsOutlined />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="button space">
|
||||||
|
<Dropdown overlay={sizer} overlayStyle={{ minWidth: 0 }} trigger={['click']} placement="top">
|
||||||
|
<FontSizeOutlined />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<div class="button"><SendOutlined /></div>
|
<div class="button" onClick={addTopic}><SendOutlined /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AddTopicWrapper>
|
</AddTopicWrapper>
|
||||||
|
@ -74,8 +74,8 @@ export function useAddTopic(cardId, channelId) {
|
|||||||
},
|
},
|
||||||
addTopic: async () => {
|
addTopic: async () => {
|
||||||
if (!state.busy) {
|
if (!state.busy) {
|
||||||
updateState({ busy: true });
|
|
||||||
try {
|
try {
|
||||||
|
updateState({ busy: true });
|
||||||
let message = {
|
let message = {
|
||||||
text: state.messageText,
|
text: state.messageText,
|
||||||
textColor: state.textColorSet ? state.textColor : null,
|
textColor: state.textColorSet ? state.textColor : null,
|
||||||
@ -87,13 +87,17 @@ export function useAddTopic(cardId, channelId) {
|
|||||||
else {
|
else {
|
||||||
await channel.actions.addChannelTopic(channelId, message, state.assets);
|
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) {
|
catch(err) {
|
||||||
console.log(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");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
164
net/web/src/session/conversation/topicItem/TopicItem.jsx
Normal file
164
net/web/src/session/conversation/topicItem/TopicItem.jsx
Normal 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";
|
||||||
|
}
|
100
net/web/src/session/conversation/topicItem/TopicItem.styled.js
Normal file
100
net/web/src/session/conversation/topicItem/TopicItem.styled.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
126
net/web/src/session/conversation/topicItem/useTopicItem.hook.js
Normal file
126
net/web/src/session/conversation/topicItem/useTopicItem.hook.js
Normal 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 };
|
||||||
|
}
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -2,6 +2,7 @@ import { useContext, useState, useEffect } from 'react';
|
|||||||
import { ViewportContext } from 'context/ViewportContext';
|
import { ViewportContext } from 'context/ViewportContext';
|
||||||
import { CardContext } from 'context/CardContext';
|
import { CardContext } from 'context/CardContext';
|
||||||
import { ChannelContext } from 'context/ChannelContext';
|
import { ChannelContext } from 'context/ChannelContext';
|
||||||
|
import { ConversationContext } from 'context/ConversationContext';
|
||||||
|
|
||||||
export function useConversation(cardId, channelId) {
|
export function useConversation(cardId, channelId) {
|
||||||
|
|
||||||
@ -10,11 +11,13 @@ export function useConversation(cardId, channelId) {
|
|||||||
image: null,
|
image: null,
|
||||||
logo: null,
|
logo: null,
|
||||||
subject: null,
|
subject: null,
|
||||||
|
topics: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewport = useContext(ViewportContext);
|
const viewport = useContext(ViewportContext);
|
||||||
const card = useContext(CardContext);
|
const card = useContext(CardContext);
|
||||||
const channel = useContext(ChannelContext);
|
const channel = useContext(ChannelContext);
|
||||||
|
const conversation = useContext(ConversationContext);
|
||||||
|
|
||||||
const updateState = (value) => {
|
const updateState = (value) => {
|
||||||
setState((s) => ({ ...s, ...value }));
|
setState((s) => ({ ...s, ...value }));
|
||||||
@ -59,8 +62,29 @@ export function useConversation(cardId, channelId) {
|
|||||||
updateState({ image, subject, logo });
|
updateState({ image, subject, logo });
|
||||||
}, [cardId, channelId, card, channel]);
|
}, [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 = {
|
const actions = {
|
||||||
|
more: () => {
|
||||||
|
conversation.actions.addHistory();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { state, actions };
|
return { state, actions };
|
||||||
|
344
net/web/src/session/conversation/virtualList/VirtualList.jsx
Normal file
344
net/web/src/session/conversation/virtualList/VirtualList.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
`;
|
@ -42,6 +42,8 @@ export const DetailsWrapper = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: scroll;
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
@ -52,8 +54,6 @@ export const DetailsWrapper = styled.div`
|
|||||||
|
|
||||||
.members {
|
.members {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
|
||||||
overflow: scroll;
|
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user