diff --git a/net/web/src/session/conversation/Conversation.jsx b/net/web/src/session/conversation/Conversation.jsx index 67ee8b3d..f0bb093a 100644 --- a/net/web/src/session/conversation/Conversation.jsx +++ b/net/web/src/session/conversation/Conversation.jsx @@ -36,8 +36,8 @@ export function Conversation({ closeConversation, openDetails, cardId, channelId )}
- +
diff --git a/net/web/src/session/conversation/topicItem/TopicItem.jsx b/net/web/src/session/conversation/topicItem/TopicItem.jsx index 914a68c6..6dcebe4a 100644 --- a/net/web/src/session/conversation/topicItem/TopicItem.jsx +++ b/net/web/src/session/conversation/topicItem/TopicItem.jsx @@ -21,17 +21,17 @@ export function TopicItem({ host, topic }) { nameClass = "unknown" } - const renderAsset = (asset) => { + const renderAsset = (asset, idx, topicId) => { if (asset.image) { - return + return } if (asset.video) { - return + return } if (asset.audio) { - return + return } return <> } @@ -95,46 +95,50 @@ export function TopicItem({ host, topic }) { return ( -
-
- -
-
-
{ name }
-
{ state.created }
-
-
- + { state.init && ( + <> +
+
+ +
+
+
{ name }
+
{ state.created }
+
+
+ +
-
- { !state.confirmed && ( -
-
- -
-
- )} - { state.confirmed && ( -
- { state.error && ( -
- + { !state.confirmed && ( +
+
+ +
)} - { !state.error && !state.ready && ( -
- + { state.confirmed && ( +
+ { state.error && ( +
+ +
+ )} + { !state.error && !state.ready && ( +
+ +
+ )} + { !state.error && state.ready && state.assets.length > 0 && ( +
+ +
+ )} +
+ +
)} - { !state.error && state.ready && state.assets.length > 0 && ( -
- -
- )} -
- -
-
+ )} ) diff --git a/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx b/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx index ae5004cf..5879a3e5 100644 --- a/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx +++ b/net/web/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx @@ -23,6 +23,11 @@ export function ImageAsset({ thumbUrl, fullUrl }) { } } + const clearPopout = (e) => { + e.stopPropagation(); + actions.clearPopout(); + } + return ( @@ -46,7 +51,7 @@ export function ImageAsset({ thumbUrl, fullUrl }) {
)} { state.display === 'small' && state.popout && ( -
+
)} diff --git a/net/web/src/session/conversation/topicItem/useTopicItem.hook.js b/net/web/src/session/conversation/topicItem/useTopicItem.hook.js index 58993a1a..5a4c9f05 100644 --- a/net/web/src/session/conversation/topicItem/useTopicItem.hook.js +++ b/net/web/src/session/conversation/topicItem/useTopicItem.hook.js @@ -8,6 +8,7 @@ export function useTopicItem(topic) { const [guid, setGuid] = useState(null); const [state, setState] = useState({ + init: false, name: null, handle: null, imageUrl: null, @@ -18,6 +19,7 @@ export function useTopicItem(topic) { error: false, owner: false, assets: [], + topicId: null, editing: false, busy: false, textColor: '#444444', @@ -98,18 +100,18 @@ export function useTopicItem(topic) { 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: createdStr, owner, textColor, textSize }); + updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created: createdStr, owner, textColor, textSize, topicId: topic.id, init: true }); } else { const { name, handle, imageUrl } = card.actions.getCardProfileByGuid(guid); - updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created: createdStr, owner, textColor, textSize }); + updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created: createdStr, owner, textColor, textSize, topicId: topic.id, init: true }); } } }, [profile, card, conversation, topic]); const actions = { - getAssetUrl: (assetId) => { - return conversation.actions.getAssetUrl(topic?.id, assetId); + getAssetUrl: (assetId, topicId) => { + return conversation.actions.getAssetUrl(state.topicId, assetId); }, removeTopic: async () => { return await conversation.actions.removeTopic(topic.id); diff --git a/net/web/src/session/conversation/virtualList/VirtualList.jsx b/net/web/src/session/conversation/virtualList/VirtualList.jsx index 1c4b67bc..5395205f 100644 --- a/net/web/src/session/conversation/virtualList/VirtualList.jsx +++ b/net/web/src/session/conversation/virtualList/VirtualList.jsx @@ -1,338 +1,371 @@ import React, { useRef, useState, useEffect } from 'react'; import { VirtualListWrapper, VirtualItem } from './VirtualList.styled'; import ReactResizeDetector from 'react-resize-detector'; +import { useVirtualList } from './useVirtualList.hook'; -export function VirtualList({ id, items, itemRenderer, onMore }) { +export function VirtualList({ id, items, itemRenderer, loadMore }) { - 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 = 1; + const redZone = 1024; + const holdZone = 2048; + const fillZone = 1024; - const [ msg, setMsg ] = useState("YO"); + const pushDelay = 250; + const moreDelay = 2000; + const latchDelay = 500; - const [ canvasHeight, setCanvasHeight ] = useState(16384); - const [ slots, setSlots ] = useState(new Map()); - const [ scroll, setScroll ] = useState('hidden'); + const defaultHeight = 32; - 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 rollHeight = 16384; - const addSlot = (id, slot) => { - setSlots((m) => { m.set(id, slot); return new Map(m); }) - } + const { state, actions } = useVirtualList(); + const containers = useRef([]); + const itemView = useRef([]); + const debounce = useRef([]); + const nomore = useRef(false); + const nolatch = useRef(false); + const list = useRef(null); + const scrollTop = useRef(0); - 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() }) - } + const [scrollPos, setScrollPos] = useState(0); useEffect(() => { - updateCanvas(); - }, [canvasHeight]); + scrollTop.current = 8192; + list.current.scrollTo({ top: 8192, left: 0 }); + }, []); useEffect(() => { - if (key.current != id) { - key.current = id; - latch.current = true; - containers.current = []; - clearSlots(); - } + + // reference copy itemView.current = items; - setItems(); - }, [items, id]); - const onScrollWheel = (e) => { - latch.current = false; - } + // genearte set of active ids + let ids = new Map(); + for (let i = 0; i < itemView.current.length; i++) { + ids.set(getItemKey(itemView.current[i]), i); + } - const onScrollView = (e) => { - scrollTop.current = e.target.scrollTop; - loadNextSlot(); - dropSlots(); - centerCanvas(); - limitScroll(); - } + // remove any deleted items + let slots = []; + containers.current.forEach((container) => { + if (!ids.has(container.key)) { + actions.removeSlot(container.key); + } + else { + container.index = ids.get(container.key); + container.item = itemView.current[container.index]; + slots.push(container); + } + }); + containers.current = slots; - const updateCanvas = () => { + // sort by index + containers.current.sort((a, b) => { + if (a.index < b.index) { + return -1; + } + return 1; + }); + + // rerender list + layoutItems(); + }, [items]); + + useEffect(() => { + layoutItems(); + }, [scrollPos, state.listHeight, state.view]); + + const layoutItems = () => { alignSlots(); - loadNextSlot(); - dropSlots(); - centerCanvas(); - limitScroll(); - latchScroll(); - } + loadSlots(); + releaseSlots(); + centerSlots(); + latchSlots(); + pushSlots(); + }; - 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(); + const latchSlots = () => { + if (containers.current.length > 0 && state.latched) { + if (!nolatch.current) { + const last = containers.current[containers.current.length - 1]; + const bottom = last.top + last.height; + if (last.heightSet && scrollTop.current < bottom - state.listHeight) { + list.current.scrollTo({ top: bottom - state.listHeight, left: 0, behavior: 'smooth' }); + nolatch.current = true; setTimeout(() => { - nomore.current = false; - }, 2500); + nolatch.current = false; + latchSlots(); + }, latchDelay); } } } } - 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, + const pushSlots = () => { + if (debounce.current != null) { + clearTimeout(debounce.current); + }; + debounce.current = setTimeout(() => { + if (containers.current.length > 0) { + const range = getContainerRange(); + if (range.bottom - range.top < state.listHeight) { + if (scrollTop.current + state.listHeight != range.bottom) { + list.current.scrollTo({ top: range.bottom - state.listHeight, left: 0, behavior: 'smooth' }); + actions.latch(); } - containers.current.unshift(container); - addSlot(container.id, getSlot(container)) - loadNextSlot(); + } + else if (scrollTop.current + state.listHeight > range.bottom) { + list.current.scrollTo({ top: range.bottom - state.listHeight, left: 0, behavior: 'smooth' }); + actions.latch(); + } + else if (scrollTop.current < range.top) { + list.current.scrollTo({ top: range.top, left: 0, behavior: 'smooth' }); } } - 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(); - } - } + }, pushDelay); + }; 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])); + if (state.latched) { + const index = containers.current.length - 1; + const last = containers.current[index]; + let bottom = last.top + last.height; + for (let i = index; i >= 0; i--) { + let container = containers.current[i]; + if (container.top + container.height != bottom) { + container.top = bottom - container.height; + actions.updateSlot(container.key, getSlot(container)); + } + bottom -= container.height; } - alignTop += containers.current[i].height + 2 * GUTTER; + } + else { + const index = Math.floor(containers.current.length / 2); + const mid = containers.current[index]; + let top = mid.top; + for (let i = index; i < containers.current.length; i++) { + let container = containers.current[i]; + if (container.top != top) { + container.top = top; + actions.updateSlot(container.key, getSlot(container)); + } + top += container.height; + } + let bottom = mid.top + mid.height; + for (let i = index; i >= 0; i--) { + let container = containers.current[i]; + if (container.top + container.height != bottom) { + container.top = bottom - container.height; + actions.updateSlot(container.key, getSlot(container)); + } + bottom -= container.height; + } + } + } + } + + const loadSlots = () => { + + if (containers.current.length == 0) { + // add the first slot + if (itemView.current.length > 0) { + let item = itemView.current[itemView.current.length - 1]; + let slot = { + top: rollHeight / 2 + state.listHeight, + height: defaultHeight, + heightSet: false, + key: getItemKey(item), + index: itemView.current.length - 1, + item: item, + } + containers.current.push(slot); + actions.addSlot(slot.key, getSlot(slot)); + list.current.scrollTo({ top: rollHeight / 2, left: 0 }); + } + } + else { + // fill in any missing slots + let index = containers.current[0].index; + for (let i = 1; i < containers.current.length; i++) { + let container = containers.current[i]; + if (container.index != index + i) { + const item = itemView.current[index + 1]; + let slot = { + top: container.top - defaultHeight, + height: defaultHeight, + heightSet: false, + index: index + i, + item: item, + key: getItemKey(item), + } + containers.current.splice(i, 0, slot); + actions.addSlot(slot.key, getSlot(slot)); + } + } + } + + loadSlotAbove(); + loadSlotBelow(); + }; + + const loadSlotAbove = () => { + if (containers.current.length > 0) { + const range = getContainerRange(); + if (scrollTop.current - fillZone < range.top) { + const container = containers.current[0]; + if (container.index > 0) { + const index = container.index - 1; + const item = itemView.current[index]; + let slot = { + top: container.top - defaultHeight, + height: defaultHeight, + heightSet: false, + index: index, + item: item, + key: getItemKey(item), + } + containers.current.unshift(slot); + actions.addSlot(slot.key, getSlot(slot)); + loadSlotAbove(); + } + } + } + } + + const loadSlotBelow = () => { + if (containers.current.length > 0) { + const container = containers.current[containers.current.length - 1]; + if (container.index + 1 < itemView.current.length) { + const range = getContainerRange(); + if (scrollTop.current + state.listHeight + fillZone > range.bottom) { + const index = container.index + 1; + const item = itemView.current[index]; + let slot = { + top: container.top + container.height, + height: defaultHeight, + heightSet: false, + index: index, + item: item, + key: getItemKey(item), + } + containers.current.push(slot); + actions.addSlot(slot.key, getSlot(slot)); + loadSlotBelow(); + } + } + } + } + + const releaseSlots = () => { + releaseSlotAbove(); + releaseSlotBelow(); + }; + + const releaseSlotAbove = () => { + if (containers.current.length > 1) { + const container = containers.current[0]; + if (container.top + container.height < scrollTop.current - holdZone) { + actions.removeSlot(container.key); + containers.current.shift(); + releaseSlotAbove(); + } + } + } + + const releaseSlotBelow = () => { + if (containers.current.length > 1) { + const container = containers.current[containers.current.length - 1]; + if (container.top > scrollTop.current + state.listHeight + holdZone) { + actions.removeSlot(container.key); + containers.current.pop(); + releaseSlotBelow(); + } + } + } + + const centerSlots = () => { + if (containers.current.length > 0) { + const top = containers.current[0]; + if (top.top < redZone) { + containers.current.forEach(container => { + container.top += rollHeight / 2; + actions.updateSlot(container.key, getSlot(container)); + }); + list.current.scrollTo({ top: scrollTop.current + rollHeight / 2, left: 0 }); } - 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 bottom = containers.current[containers.current.length - 1]; + if (bottom.top + bottom.height > rollHeight - redZone) { + containers.current.forEach(container => { + container.top -= rollHeight / 2; + actions.updateSlot(container.key, getSlot(container)); + }); + list.current.scrollTo({ top: scrollTop.current - rollHeight / 2, left: 0 }); } } }; - 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 getItemKey = (item) => { + return `${id}.${item.id}.${item.revision}` } - 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) => { + const getSlot = (item) => { + const container = item; return ( - + {({ height }) => { - if (typeof height !== 'undefined') { - onItemHeight(container, height); + if (typeof height !== 'undefined' && container.height != height) { + container.height = height; + container.heightSet = true; + layoutItems(); } - return itemRenderer(itemView.current[container.index]); + return itemRenderer(container.item); }} - + ) } - const getPlacement = () => { - if (containers.current.length == 0) { - return null; + const getContainerRange = () => { + let top = rollHeight; + let bottom = 0; + containers.current.forEach((c) => { + if (c.top < top) { + top = c.top; + } + if (c.top + c.height > bottom) { + bottom = c.top + c.height; + } + }); + return { top, bottom }; + } + + const scrollView = (e) => { + if (containers.current.length > 0 && containers.current[0].index == 0 && !nomore.current) { + loadMore(); + nomore.current = true; + setTimeout(() => { + nomore.current = false; + }, moreDelay); } - 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 } - }; + scrollTop.current = e.target.scrollTop; + setScrollPos(e.target.scrollTop); } return (
- + {({ height }) => { - if (height) { - viewHeight.current = height; - updateCanvas(); + if (height && state.listHeight != height) { + actions.setListHeight(height); } return ( - -
-
- { slots.values() } + +
+
+ { state.slots }
diff --git a/net/web/src/session/conversation/virtualList/VirtualList.styled.js b/net/web/src/session/conversation/virtualList/VirtualList.styled.js index 2d3cbda8..b8df2f93 100644 --- a/net/web/src/session/conversation/virtualList/VirtualList.styled.js +++ b/net/web/src/session/conversation/virtualList/VirtualList.styled.js @@ -8,6 +8,7 @@ export const VirtualListWrapper = styled.div` .rollview { width: 100%; height: 100%; + overflow-y: auto; /* hide scrollbar for IE, Edge and Firefox */ -ms-overflow-style: none; @@ -28,4 +29,5 @@ export const VirtualItem = styled.div` position: absolute; width: 100%; overflow: hidden; + max-height: 1024px; `; diff --git a/net/web/src/session/conversation/virtualList/useVirtualList.hook.js b/net/web/src/session/conversation/virtualList/useVirtualList.hook.js new file mode 100644 index 00000000..84afd75f --- /dev/null +++ b/net/web/src/session/conversation/virtualList/useVirtualList.hook.js @@ -0,0 +1,55 @@ +import { useContext, useState, useEffect, useRef } from 'react'; + +export function useVirtualList(id) { + + const [state, setState] = useState({ + view: null, + listHeight: 128, + slots: [], + latched: true, + }); + + const slots = useRef(new Map()); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + } + + const actions = { + setView: (view) => { + updateState({ view }); + }, + latch: () => { + updateState({ latched: true }); + }, + unlatch: () => { + updateState({ latched: false }); + }, + setListHeight: (listHeight) => { + updateState({ listHeight }); + }, + addSlot: (id, slot) => { + slots.current.set(id, slot); + let items = Array.from(slots.current.values()); + updateState({ slots: items }); + }, + updateSlot: (id, slot) => { + slots.current.set(id, slot); + let items = Array.from(slots.current.values()); + updateState({ slots: items }); + }, + removeSlot: (id) => { + slots.current.set(id, (<>)); + let items = Array.from(slots.current.values()); + updateState({ slots: items }); + }, + clearSlots: () => { + slots.current = new Map(); + let items = Array.from(slots.current.values()); + updateState({ slots: items }); + }, + }; + + return { state, actions }; +} +