diff --git a/net/web/src/session/conversation/Conversation.jsx b/net/web/src/session/conversation/Conversation.jsx index 97724e06..7b8ddba6 100644 --- a/net/web/src/session/conversation/Conversation.jsx +++ b/net/web/src/session/conversation/Conversation.jsx @@ -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 () + } return ( @@ -31,6 +35,8 @@ console.log(state); )}
+
diff --git a/net/web/src/session/conversation/Conversation.styled.js b/net/web/src/session/conversation/Conversation.styled.js index 0a06d787..7e218137 100644 --- a/net/web/src/session/conversation/Conversation.styled.js +++ b/net/web/src/session/conversation/Conversation.styled.js @@ -52,6 +52,7 @@ export const ConversationWrapper = styled.div` .thread { flex-grow: 1; + min-height: 0; } .divider { diff --git a/net/web/src/session/conversation/addTopic/AddTopic.jsx b/net/web/src/session/conversation/addTopic/AddTopic.jsx index b7884676..ae8cfa8b 100644 --- a/net/web/src/session/conversation/addTopic/AddTopic.jsx +++ b/net/web/src/session/conversation/addTopic/AddTopic.jsx @@ -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 actions.setPosition(index, pos)} url={item.url} /> } return <> - } + }; const removeItem = (index) => { actions.removeAsset(index); - } + }; const picker = ( @@ -84,7 +98,8 @@ export function AddTopic({ cardId, channelId }) {
keyDown(e)} /> + enterkeyhint="send" onKeyDown={(e) => keyDown(e)} onChange={(e) => actions.setMessageText(e.target.value)} + value={state.messageText} autocapitalize="none" />
attachImage.current.click()}> @@ -98,17 +113,17 @@ export function AddTopic({ cardId, channelId }) {
- - - -
-
- +
+
+ + + +
-
+
diff --git a/net/web/src/session/conversation/addTopic/useAddTopic.hook.js b/net/web/src/session/conversation/addTopic/useAddTopic.hook.js index 26cc3fab..3345fa5d 100644 --- a/net/web/src/session/conversation/addTopic/useAddTopic.hook.js +++ b/net/web/src/session/conversation/addTopic/useAddTopic.hook.js @@ -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"); } }, }; diff --git a/net/web/src/session/conversation/topicItem/TopicItem.jsx b/net/web/src/session/conversation/topicItem/TopicItem.jsx new file mode 100644 index 00000000..483cf0a6 --- /dev/null +++ b/net/web/src/session/conversation/topicItem/TopicItem.jsx @@ -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 + } + if (asset.video) { + return + } + if (asset.audio) { + return + } + return <> + } + + const removeTopic = () => { + Modal.confirm({ + title: 'Do you want to delete this message?', + icon: , + okText: 'Yes, Delete', + cancelText: 'No, Cancel', + onOk() { actions.removeTopic() }, + }); + } + + const Options = () => { + if (state.editing) { + return <>; + } + if (state.owner) { + return ( +
+
actions.setEditing(true)}> + +
+
removeTopic()}> + +
+
+ ); + } + if (host) { + return ( +
+
removeTopic()}> + +
+
+ ); + } + return <>; + } + + const Message = () => { + if (state.editing) { + return ( +
+ actions.setEdit(e.target.value)} rows={3} bordered={false}/> +
+ + + + +
+
+ ); + } + return
{ state.message?.text }
+ } + + if (!state.confirmed) { + return ( + +
+ +
+
+
+
{ name }
+
{ getTime(offset) }
+
+ +
+ +
+
+
+ ) + } + + return ( + +
+ +
+
+
+
{ name }
+
{ getTime(offset) }
+
+ { state.assets.length > 0 && ( + + )} +
+ +
+
+ +
+
+
+ ) +} + +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"; +} diff --git a/net/web/src/session/conversation/topicItem/TopicItem.styled.js b/net/web/src/session/conversation/topicItem/TopicItem.styled.js new file mode 100644 index 00000000..1943dfbc --- /dev/null +++ b/net/web/src/session/conversation/topicItem/TopicItem.styled.js @@ -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; + } + } + } + } +`; + + diff --git a/net/web/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx b/net/web/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx new file mode 100644 index 00000000..a61a9cba --- /dev/null +++ b/net/web/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx @@ -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 ( +
setPlaying(false)}> + +
+ ) + } + return ( +
setPlaying(true)}> + +
+ ) + } + + return ( + + + {({ height }) => { + if (height != dimension.height) { + setDimension({ height }); + } + return
+ }} + +
+
+ { !active && ( +
onActivate()}> + +
+ )} + { active && ( + + )} + +
+
+
{ label }
+ + ) +} + diff --git a/net/web/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js b/net/web/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js new file mode 100644 index 00000000..743493d3 --- /dev/null +++ b/net/web/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js @@ -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; + } +`; + + diff --git a/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx b/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx new file mode 100644 index 00000000..41ce8d3e --- /dev/null +++ b/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx @@ -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 ( + + + {({ width, height }) => { + if (width != state.width || height != state.height) { + updateState({ width, height }); + } + return + }} + +
+
+
onPopOut()}> + +
+
+
+ { updateState({ popout: false })}}> + + +
+ ) +} + diff --git a/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js b/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js new file mode 100644 index 00000000..b77da6a6 --- /dev/null +++ b/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js @@ -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; + } +`; + diff --git a/net/web/src/session/conversation/topicItem/useTopicItem.hook.js b/net/web/src/session/conversation/topicItem/useTopicItem.hook.js new file mode 100644 index 00000000..1b725476 --- /dev/null +++ b/net/web/src/session/conversation/topicItem/useTopicItem.hook.js @@ -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 }; +} diff --git a/net/web/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx b/net/web/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx new file mode 100644 index 00000000..d6b38b9d --- /dev/null +++ b/net/web/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx @@ -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 ( +
updateState({ inline: true, inlineUrl: lqUrl, playing: false })}> + +
+ ) + } + if (state.playing) { + return ( +
updateState({ playing: false })}> + +
+ ) + } + else { + return ( +
updateState({ playing: true })}> + +
+ ) + } + } + + const Controls = () => { + return ( +
+
+ +
+
onPopOut()}> + +
+
+ ) + } + + return ( + + + {({ width, height }) => { + if (width != state.width || height != state.height) { + updateState({ width, height }); + } + return + }} + +
+ + +
+ { updateState({ popout: false })}}> + + +
+ ) +} + diff --git a/net/web/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js b/net/web/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js new file mode 100644 index 00000000..eafb2c7d --- /dev/null +++ b/net/web/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js @@ -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; + } +`; + diff --git a/net/web/src/session/conversation/useConversation.hook.js b/net/web/src/session/conversation/useConversation.hook.js index 07131be2..1d74ff4d 100644 --- a/net/web/src/session/conversation/useConversation.hook.js +++ b/net/web/src/session/conversation/useConversation.hook.js @@ -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 }; diff --git a/net/web/src/session/conversation/virtualList/VirtualList.jsx b/net/web/src/session/conversation/virtualList/VirtualList.jsx new file mode 100644 index 00000000..d77b3079 --- /dev/null +++ b/net/web/src/session/conversation/virtualList/VirtualList.jsx @@ -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 ( + + + {({ height }) => { + if (typeof height !== 'undefined') { + onItemHeight(container, height); + } + return itemRenderer(itemView.current[container.index]); + }} + + + ) + } + + 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 ( +
+ + {({ height }) => { + if (height) { + viewHeight.current = height; + updateCanvas(); + } + return ( + +
+
+ { slots.values() } +
+
+
+ ) + }} +
+
+ ) +} diff --git a/net/web/src/session/conversation/virtualList/VirtualList.styled.js b/net/web/src/session/conversation/virtualList/VirtualList.styled.js new file mode 100644 index 00000000..66c5767b --- /dev/null +++ b/net/web/src/session/conversation/virtualList/VirtualList.styled.js @@ -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; + } +`; diff --git a/net/web/src/session/details/Details.styled.js b/net/web/src/session/details/Details.styled.js index 346fa7a4..b770be9d 100644 --- a/net/web/src/session/details/Details.styled.js +++ b/net/web/src/session/details/Details.styled.js @@ -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; }