From acd7501e1cd908f898044fc3496b2a2ecb478cda Mon Sep 17 00:00:00 2001 From: ~latter-bolden Date: Thu, 21 Mar 2024 01:39:57 -0400 Subject: [PATCH 1/5] web: add workaround for nested radix pointer events issue --- .../tlon-web/src/chat/ChatSearch/ChatSearch.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/tlon-web/src/chat/ChatSearch/ChatSearch.tsx b/apps/tlon-web/src/chat/ChatSearch/ChatSearch.tsx index 7cf05d498e..d4a224ddf0 100644 --- a/apps/tlon-web/src/chat/ChatSearch/ChatSearch.tsx +++ b/apps/tlon-web/src/chat/ChatSearch/ChatSearch.tsx @@ -1,7 +1,7 @@ import * as Dialog from '@radix-ui/react-dialog'; import { ChatMap } from '@tloncorp/shared/dist/urbit/channel'; import cn from 'classnames'; -import React, { PropsWithChildren, useCallback } from 'react'; +import React, { PropsWithChildren, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { Link } from 'react-router-dom'; import { VirtuosoHandle } from 'react-virtuoso'; @@ -86,6 +86,15 @@ export default function ChatSearch({ [navigate, root] ); + // This is a hack to prevent the bug where nested @radix-ui components cause + // pointerEvents to get set to none on body which trips up our prevent close + // detection + useEffect(() => { + setTimeout(() => { + document.body.style.pointerEvents = ''; + }, 0); + }); + return (
- + Date: Thu, 21 Mar 2024 20:25:03 +0000 Subject: [PATCH 2/5] update glob: [skip actions] --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 4d823e9258..5fa1f7b275 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0vcdj6c.jc8nr.avuag.uhh4v.vqc33.glob' 0vcdj6c.jc8nr.avuag.uhh4v.vqc33] + glob-http+['https://bootstrap.urbit.org/glob-0v6vejo.ambet.2v7qd.muvf7.d92qm.glob' 0v6vejo.ambet.2v7qd.muvf7.d92qm] base+'groups' version+[5 7 0] website+'https://tlon.io' From a446e6453c6efadb61ac8ea0c25345bccaf1b75a Mon Sep 17 00:00:00 2001 From: ~latter-bolden Date: Fri, 22 Mar 2024 14:34:55 -0400 Subject: [PATCH 3/5] search: remove radix, use individual hook for click outside tracking --- .../src/chat/ChatSearch/ChatSearch.tsx | 88 ++++++------------- 1 file changed, 28 insertions(+), 60 deletions(-) diff --git a/apps/tlon-web/src/chat/ChatSearch/ChatSearch.tsx b/apps/tlon-web/src/chat/ChatSearch/ChatSearch.tsx index d4a224ddf0..4b90f7869a 100644 --- a/apps/tlon-web/src/chat/ChatSearch/ChatSearch.tsx +++ b/apps/tlon-web/src/chat/ChatSearch/ChatSearch.tsx @@ -1,15 +1,13 @@ -import * as Dialog from '@radix-ui/react-dialog'; import { ChatMap } from '@tloncorp/shared/dist/urbit/channel'; import cn from 'classnames'; -import React, { PropsWithChildren, useCallback, useEffect } from 'react'; +import React, { PropsWithChildren, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router'; import { Link } from 'react-router-dom'; import { VirtuosoHandle } from 'react-virtuoso'; +import { useOnClickOutside } from 'usehooks-ts'; -import useActiveTab from '@/components/Sidebar/util'; import { useSafeAreaInsets } from '@/logic/native'; import useMedia, { useIsMobile } from '@/logic/useMedia'; -import { disableDefault } from '@/logic/utils'; import ChatSearchResults from './ChatSearchResults'; import SearchBar from './SearchBar'; @@ -45,8 +43,8 @@ export default function ChatSearch({ const navigate = useNavigate(); const isMobile = useIsMobile(); const isSmall = useMedia('(min-width: 768px) and (max-width: 1099px)'); - const activeTab = useActiveTab(); const safeAreaInsets = useSafeAreaInsets(); + const containerRef = useRef(null); const scrollerRef = React.useRef(null); const { selected, rawInput, onChange, onKeyDown } = useChatSearchInput({ root, @@ -68,32 +66,7 @@ export default function ChatSearch({ }, []), }); - const preventClose = useCallback((e: Event) => { - const target = e.target as HTMLElement; - const hasNavAncestor = target.id === 'search' || target.closest('#search'); - - if (hasNavAncestor) { - e.preventDefault(); - } - }, []); - - const onOpenChange = useCallback( - (open: boolean) => { - if (!open) { - navigate(root); - } - }, - [navigate, root] - ); - - // This is a hack to prevent the bug where nested @radix-ui components cause - // pointerEvents to get set to none on body which trips up our prevent close - // detection - useEffect(() => { - setTimeout(() => { - document.body.style.pointerEvents = ''; - }, 0); - }); + useOnClickOutside(containerRef, () => navigate(root)); return (
{!isMobile && !isSmall ? children : null} @@ -121,36 +95,30 @@ export default function ChatSearch({ isSmall={isSmall} /> - - +
-
- -
- - + +
+
{!isSmall && ( From 63dad4a617f983a3d66468e75bed582972a6cb6a Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 22 Mar 2024 19:38:15 +0000 Subject: [PATCH 4/5] update glob: [skip actions] --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 5fa1f7b275..105b429942 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v6vejo.ambet.2v7qd.muvf7.d92qm.glob' 0v6vejo.ambet.2v7qd.muvf7.d92qm] + glob-http+['https://bootstrap.urbit.org/glob-0v5.dvi7n.9bcsc.hid09.mhlbr.jr4l5.glob' 0v5.dvi7n.9bcsc.hid09.mhlbr.jr4l5] base+'groups' version+[5 7 0] website+'https://tlon.io' From 8bbfa06e7bf24dc31c52418f4cf6fe59b92b5dd9 Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Mon, 25 Mar 2024 10:31:06 -0500 Subject: [PATCH 5/5] edit: fix issue with re-renders on editing reply message --- apps/tlon-web/src/app.tsx | 19 +- apps/tlon-web/src/chat/ChatChannel.tsx | 4 +- .../tlon-web/src/chat/ChatInput/ChatInput.tsx | 16 +- .../src/chat/ChatThread/ChatThread.tsx | 4 +- apps/tlon-web/src/chat/ChatWindow.tsx | 392 +++++++++--------- .../src/logic/useIsEditingMessage.tsx | 5 +- apps/tlon-web/src/replies/ReplyMessage.tsx | 2 +- .../src/replies/ReplyMessageOptions.tsx | 2 +- 8 files changed, 228 insertions(+), 216 deletions(-) diff --git a/apps/tlon-web/src/app.tsx b/apps/tlon-web/src/app.tsx index 5b5de81bab..86360d73c1 100644 --- a/apps/tlon-web/src/app.tsx +++ b/apps/tlon-web/src/app.tsx @@ -156,15 +156,16 @@ const SuspendedDiaryAddNote = ( ); interface RoutesProps { - state: { backgroundLocation?: Location } | null; - location: Location; isMobile: boolean; isSmall: boolean; } -function GroupsRoutes({ state, location, isMobile, isSmall }: RoutesProps) { +const GroupsRoutes = React.memo(({ isMobile, isSmall }: RoutesProps) => { const groupsTitle = 'Tlon'; const loaded = useSettingsLoaded(); + const location = useLocation(); + + const state = location.state as { backgroundLocation?: Location } | null; useEffect(() => { if (loaded) { @@ -558,7 +559,7 @@ function GroupsRoutes({ state, location, isMobile, isSmall }: RoutesProps) { ) : null} ); -} +}); function authRedirect() { document.location = `${document.location.protocol}//${document.location.host}`; @@ -613,7 +614,6 @@ function App() { useNativeBridge(); const navigate = useNavigate(); const handleError = useErrorHandler(); - const location = useLocation(); const isMobile = useIsMobile(); const isSmall = useMedia('(max-width: 1023px)'); @@ -637,20 +637,13 @@ function App() { })(); }, [handleError]); - const state = location.state as { backgroundLocation?: Location } | null; - return (
- + diff --git a/apps/tlon-web/src/chat/ChatChannel.tsx b/apps/tlon-web/src/chat/ChatChannel.tsx index 1087bd3d0e..b34cf7a62e 100644 --- a/apps/tlon-web/src/chat/ChatChannel.tsx +++ b/apps/tlon-web/src/chat/ChatChannel.tsx @@ -30,7 +30,7 @@ import { useRouteGroup } from '@/state/groups/groups'; import ChatThread from './ChatThread/ChatThread'; -function ChatChannel({ title }: ViewProps) { +const ChatChannel = React.memo(({ title }: ViewProps) => { const { isChatInputFocused } = useChatInputFocus(); // TODO: We need to reroute users who can't read the channel // const navigate = useNavigate(); @@ -195,6 +195,6 @@ function ChatChannel({ title }: ViewProps) { ); -} +}); export default ChatChannel; diff --git a/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx b/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx index 0da270af8e..db711e2dcd 100644 --- a/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx +++ b/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx @@ -185,6 +185,7 @@ export default function ChatInput({ const myLastMessage = useMyLastMessage(whom, replying); const lastMessageId = myLastMessage ? myLastMessage.seal.id : ''; const lastMessageIdRef = useRef(lastMessageId); + const isReplyingRef = useRef(!!replying); useEffect(() => { if (lastMessageId && lastMessageId !== lastMessageIdRef.current) { @@ -192,6 +193,13 @@ export default function ChatInput({ } }, [lastMessageId]); + useEffect(() => { + if (isReplyingRef.current && !replying) { + isReplyingRef.current = false; + } + isReplyingRef.current = !!replying; + }, [replying]); + const handleUnblockClick = useCallback(() => { unblockShip({ ship: whom, @@ -400,9 +408,11 @@ export default function ChatInput({ !isDmOrMultiDM ) { setSearchParams( - { - edit: lastMessageIdRef.current, - }, + isReplyingRef.current + ? { editReply: lastMessageIdRef.current } + : { + edit: lastMessageIdRef.current, + }, { replace: true } ); editor.commands.blur(); diff --git a/apps/tlon-web/src/chat/ChatThread/ChatThread.tsx b/apps/tlon-web/src/chat/ChatThread/ChatThread.tsx index e3947e82c3..287d34ebb4 100644 --- a/apps/tlon-web/src/chat/ChatThread/ChatThread.tsx +++ b/apps/tlon-web/src/chat/ChatThread/ChatThread.tsx @@ -21,6 +21,7 @@ import { useDragAndDrop } from '@/logic/DragAndDropContext'; import { useChannelCompatibility, useChannelFlag } from '@/logic/channel'; import { useBottomPadding } from '@/logic/position'; import { useIsScrolling } from '@/logic/scroll'; +import useIsEditingMessage from '@/logic/useIsEditingMessage'; import useMedia, { useIsMobile } from '@/logic/useMedia'; import { useAddReplyMutation, @@ -48,6 +49,7 @@ export default function ChatThread() { }>(); const isMobile = useIsMobile(); const { isChatInputFocused } = useChatInputFocus(); + const isEditing = useIsEditingMessage(); const scrollerRef = useRef(null); const flag = useChannelFlag()!; const nest = `chat/${flag}`; @@ -259,7 +261,7 @@ export default function ChatThread() {
searchParams.get('msg') || searchParams.get('edit') || idTime, - [searchParams, idTime] - ); - const nest = `chat/${whom}`; - const { - posts: messages, - hasNextPage, - hasPreviousPage, - fetchPreviousPage, - refetch, - remove, - fetchNextPage, - isLoading, - isFetching, - isFetchingNextPage, - isFetchingPreviousPage, - } = useInfinitePosts(nest, scrollToId); - const { mutate: markRead } = useMarkReadMutation(); - const scrollerRef = useRef(null); - const readTimeout = useChatInfo(whom).unread?.readTimeout; - const clearOnNavRef = useRef({ readTimeout, nest, whom, markRead }); - const { compatible } = useChannelCompatibility(nest); - const navigate = useNavigate(); - const latestMessageIndex = messages.length - 1; - const scrollToIndex = useMemo( - () => - scrollToId - ? messages.findIndex((m) => m[0].toString() === scrollToId) - : latestMessageIndex, - [scrollToId, messages, latestMessageIndex] - ); - const msgIdTimeInMessages = useMemo( - () => - scrollToId - ? messages.findIndex((m) => m[0].toString() === scrollToId) !== -1 - : false, - [scrollToId, messages] - ); - const latestIsMoreThan30NewerThanScrollTo = useMemo( - () => - scrollToIndex !== latestMessageIndex && - latestMessageIndex - scrollToIndex > 30, - [scrollToIndex, latestMessageIndex] - ); - - const goToLatest = useCallback(async () => { - if (idTime) { - navigate(root); - } else { - setSearchParams({}); - } - if (hasPreviousPage) { - // wait until next tick to avoid the race condition where refetch - // happens before navigation completes and clears scrollToId - // TODO: is there a better way to handle this? - setTimeout(() => { - remove(); - refetch(); - }, 0); - } else { - scrollerRef.current?.scrollToIndex({ index: 'LAST', align: 'end' }); - } - }, [ - setSearchParams, - remove, - refetch, - hasPreviousPage, - scrollerRef, - idTime, - navigate, +const ChatWindow = React.memo( + ({ + whom, root, - ]); - - useEffect(() => { - useChatStore.getState().setCurrent(whom); - - return () => { - useChatStore.getState().setCurrent(''); - }; - }, [whom]); - - const onAtBottom = useCallback(() => { - const { bottom, delayedRead } = useChatStore.getState(); - bottom(true); - delayedRead(whom, () => markRead({ nest })); - if (hasPreviousPage && !isFetching) { - log('fetching previous page'); - fetchPreviousPage(); - } - }, [nest, whom, markRead, fetchPreviousPage, hasPreviousPage, isFetching]); + prefixedElement, + scrollElementRef, + isScrolling, + }: ChatWindowProps) => { + const [searchParams, setSearchParams] = useSearchParams(); + const { idTime } = useParams(); + const scrollToId = useMemo( + () => searchParams.get('msg') || searchParams.get('edit') || idTime, + [searchParams, idTime] + ); + const nest = `chat/${whom}`; + const { + posts: messages, + hasNextPage, + hasPreviousPage, + fetchPreviousPage, + refetch, + remove, + fetchNextPage, + isLoading, + isFetching, + isFetchingNextPage, + isFetchingPreviousPage, + } = useInfinitePosts(nest, scrollToId); + const { mutate: markRead } = useMarkReadMutation(); + const scrollerRef = useRef(null); + const readTimeout = useChatInfo(whom).unread?.readTimeout; + const clearOnNavRef = useRef({ readTimeout, nest, whom, markRead }); + const { compatible } = useChannelCompatibility(nest); + const navigate = useNavigate(); + const latestMessageIndex = messages.length - 1; + const scrollToIndex = useMemo( + () => + scrollToId + ? messages.findIndex((m) => m[0].toString() === scrollToId) + : latestMessageIndex, + [scrollToId, messages, latestMessageIndex] + ); + const msgIdTimeInMessages = useMemo( + () => + scrollToId + ? messages.findIndex((m) => m[0].toString() === scrollToId) !== -1 + : false, + [scrollToId, messages] + ); + const latestIsMoreThan30NewerThanScrollTo = useMemo( + () => + scrollToIndex !== latestMessageIndex && + latestMessageIndex - scrollToIndex > 30, + [scrollToIndex, latestMessageIndex] + ); - const onAtTop = useCallback(() => { - if (hasNextPage && !isFetching) { - log('fetching next page'); - fetchNextPage(); - } - }, [fetchNextPage, hasNextPage, isFetching]); - - // read the messages once navigated away - useEffect(() => { - clearOnNavRef.current = { readTimeout, nest, whom, markRead }; - }, [readTimeout, nest, whom, markRead]); - - useEffect( - () => () => { - const curr = clearOnNavRef.current; - if (curr.readTimeout !== undefined && curr.readTimeout !== 0) { - useChatStore.getState().read(curr.whom); - curr.markRead({ nest: curr.nest }); + const goToLatest = useCallback(async () => { + if (idTime) { + navigate(root); + } else { + setSearchParams({}); } - }, - [] - ); - - useEffect(() => { - const doRefetch = async () => { - remove(); - await refetch(); - }; - - // If we have a scrollTo, we have a next page, and the scrollTo message is - // not in our current set of messages, that means we're scrolling to a - // message that's not yet cached. So, we need to refetch (which would fetch - // messages around the scrollTo time), then scroll to the message. - if (scrollToId && hasNextPage && !msgIdTimeInMessages) { - doRefetch(); - } - }, [scrollToId, hasNextPage, remove, refetch, msgIdTimeInMessages]); + if (hasPreviousPage) { + // wait until next tick to avoid the race condition where refetch + // happens before navigation completes and clears scrollToId + // TODO: is there a better way to handle this? + setTimeout(() => { + remove(); + refetch(); + }, 0); + } else { + scrollerRef.current?.scrollToIndex({ index: 'LAST', align: 'end' }); + } + }, [ + setSearchParams, + remove, + refetch, + hasPreviousPage, + scrollerRef, + idTime, + navigate, + root, + ]); + + useEffect(() => { + useChatStore.getState().setCurrent(whom); + + return () => { + useChatStore.getState().setCurrent(''); + }; + }, [whom]); + + const onAtBottom = useCallback(() => { + const { bottom, delayedRead } = useChatStore.getState(); + bottom(true); + delayedRead(whom, () => markRead({ nest })); + if (hasPreviousPage && !isFetching) { + log('fetching previous page'); + fetchPreviousPage(); + } + }, [nest, whom, markRead, fetchPreviousPage, hasPreviousPage, isFetching]); - if (isLoading) { - return ( -
- -
+ const onAtTop = useCallback(() => { + if (hasNextPage && !isFetching) { + log('fetching next page'); + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetching]); + + // read the messages once navigated away + useEffect(() => { + clearOnNavRef.current = { readTimeout, nest, whom, markRead }; + }, [readTimeout, nest, whom, markRead]); + + useEffect( + () => () => { + const curr = clearOnNavRef.current; + if (curr.readTimeout !== undefined && curr.readTimeout !== 0) { + useChatStore.getState().read(curr.whom); + curr.markRead({ nest: curr.nest }); + } + }, + [] ); - } - if (!compatible && messages.length === 0) { + useEffect(() => { + const doRefetch = async () => { + remove(); + await refetch(); + }; + + // If we have a scrollTo, we have a next page, and the scrollTo message is + // not in our current set of messages, that means we're scrolling to a + // message that's not yet cached. So, we need to refetch (which would fetch + // messages around the scrollTo time), then scroll to the message. + if (scrollToId && hasNextPage && !msgIdTimeInMessages) { + doRefetch(); + } + }, [scrollToId, hasNextPage, remove, refetch, msgIdTimeInMessages]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!compatible && messages.length === 0) { + return ( +
+ +

+ There may be content in this channel, but it is inaccessible + because the host is using an older, incompatible version of the + app. +

+

Please try again later.

+
+
+ ); + } + return ( -
- -

- There may be content in this channel, but it is inaccessible because - the host is using an older, incompatible version of the app. -

-

Please try again later.

-
+
+ +
+ +
+ {scrollToId && + (hasPreviousPage || latestIsMoreThan30NewerThanScrollTo) ? ( +
+ +
+ ) : null}
); } +); - return ( -
- -
- -
- {scrollToId && - (hasPreviousPage || latestIsMoreThan30NewerThanScrollTo) ? ( -
- -
- ) : null} -
- ); -} +export default ChatWindow; diff --git a/apps/tlon-web/src/logic/useIsEditingMessage.tsx b/apps/tlon-web/src/logic/useIsEditingMessage.tsx index e3c414e497..896bae3298 100644 --- a/apps/tlon-web/src/logic/useIsEditingMessage.tsx +++ b/apps/tlon-web/src/logic/useIsEditingMessage.tsx @@ -3,7 +3,10 @@ import { useSearchParams } from 'react-router-dom'; export default function useIsEditingMessage() { const [searchParams] = useSearchParams(); - const isEditing = useMemo(() => !!searchParams.get('edit'), [searchParams]); + const isEditing = useMemo( + () => !!searchParams.get('edit') || !!searchParams.get('editReply'), + [searchParams] + ); return isEditing; } diff --git a/apps/tlon-web/src/replies/ReplyMessage.tsx b/apps/tlon-web/src/replies/ReplyMessage.tsx index 1a223ba434..7153e7190c 100644 --- a/apps/tlon-web/src/replies/ReplyMessage.tsx +++ b/apps/tlon-web/src/replies/ReplyMessage.tsx @@ -126,7 +126,7 @@ const ReplyMessage = React.memo< ref ) => { const [searchParms, setSearchParams] = useSearchParams(); - const isEditing = searchParms.get('edit') === reply.seal.id; + const isEditing = searchParms.get('editReply') === reply.seal.id; const isEdited = useIsEdited(reply); const { seal, memo } = reply.seal.id ? reply : emptyReply; const container = useRef(null); diff --git a/apps/tlon-web/src/replies/ReplyMessageOptions.tsx b/apps/tlon-web/src/replies/ReplyMessageOptions.tsx index faa2bb51bc..62b141b01e 100644 --- a/apps/tlon-web/src/replies/ReplyMessageOptions.tsx +++ b/apps/tlon-web/src/replies/ReplyMessageOptions.tsx @@ -173,7 +173,7 @@ export default function ReplyMessageOptions(props: { }, [doCopy, isMobile, onOpenChange]); const edit = useCallback(() => { - setSearchParams({ edit: seal.id }, { replace: true }); + setSearchParams({ editReply: seal.id }, { replace: true }); }, [seal, setSearchParams]); const setReplyParam = useCallback(() => {