diff --git a/apps/tlon-web/src/app.tsx b/apps/tlon-web/src/app.tsx index dfeaa1fb51..f113cdce1e 100644 --- a/apps/tlon-web/src/app.tsx +++ b/apps/tlon-web/src/app.tsx @@ -94,6 +94,7 @@ import { } from '@/state/settings'; import ChannelVolumeDialog from './channels/ChannelVolumeDialog'; +import ThreadVolumeDialog from './channels/ThreadVolumeDialog'; import MobileChatSearch from './chat/ChatSearch/MobileChatSearch'; import DevLog from './components/DevLog/DevLog'; import DevLogsView from './components/DevLog/DevLogView'; @@ -112,6 +113,7 @@ import NewGroupView from './groups/NewGroup/NewGroupView'; import { ChatInputFocusProvider } from './logic/ChatInputFocusContext'; import useAppUpdates, { AppUpdateContext } from './logic/useAppUpdates'; import ShareDMLure from './profiles/ShareDMLure'; +import { useActivityFirehose } from './state/activity'; import { useChannelsFirehose } from './state/channel/channel'; const ReactQueryDevtoolsProduction = React.lazy(() => @@ -329,17 +331,11 @@ const GroupsRoutes = React.memo(({ isMobile, isSmall }: RoutesProps) => { element={} > {isSmall ? null : ( - } - /> + } /> )} {isSmall ? ( - } - /> + } /> ) : null} {isMobile && ( } /> @@ -354,7 +350,7 @@ const GroupsRoutes = React.memo(({ isMobile, isSmall }: RoutesProps) => { element={} /> } /> @@ -367,7 +363,7 @@ const GroupsRoutes = React.memo(({ isMobile, isSmall }: RoutesProps) => { element={} /> } /> @@ -508,6 +504,10 @@ const GroupsRoutes = React.memo(({ isMobile, isSmall }: RoutesProps) => { path="/dm?/groups/:ship/:name/channels/:chType/:chShip/:chName/volume" element={} /> + } + /> } @@ -543,7 +543,7 @@ const GroupsRoutes = React.memo(({ isMobile, isSmall }: RoutesProps) => { element={} /> } /> { const isMobile = useIsMobile(); const isSmall = useMedia('(max-width: 1023px)'); + useActivityFirehose(); useChannelsFirehose(); useEffect(() => { if (isNativeApp()) { diff --git a/apps/tlon-web/src/channels/ChannelActions.tsx b/apps/tlon-web/src/channels/ChannelActions.tsx index 325b1b879d..ab76e7f358 100644 --- a/apps/tlon-web/src/channels/ChannelActions.tsx +++ b/apps/tlon-web/src/channels/ChannelActions.tsx @@ -109,7 +109,7 @@ const ChannelActions = React.memo( {channel?.meta.title || `~${nest}`} - + ), keepOpenOnClick: true, diff --git a/apps/tlon-web/src/channels/ChannelVolumeDialog.tsx b/apps/tlon-web/src/channels/ChannelVolumeDialog.tsx index bc574c3c2a..4246ca1d8e 100644 --- a/apps/tlon-web/src/channels/ChannelVolumeDialog.tsx +++ b/apps/tlon-web/src/channels/ChannelVolumeDialog.tsx @@ -45,7 +45,7 @@ export default function ChannelVolumeDialog({ title }: ViewProps) { {channel?.meta ? `${channel.meta.title}` : null} - + ); diff --git a/apps/tlon-web/src/channels/ThreadVolumeDialog.tsx b/apps/tlon-web/src/channels/ThreadVolumeDialog.tsx new file mode 100644 index 0000000000..8b491f63d6 --- /dev/null +++ b/apps/tlon-web/src/channels/ThreadVolumeDialog.tsx @@ -0,0 +1,64 @@ +import { ViewProps } from '@tloncorp/shared/dist/urbit/groups'; +import { Helmet } from 'react-helmet'; +import { useParams } from 'react-router'; + +import Dialog from '@/components/Dialog'; +import VolumeSetting from '@/components/VolumeSetting'; +import { useDismissNavigate } from '@/logic/routing'; +import { firstInlineSummary } from '@/logic/tiptap'; +import { getMessageKey } from '@/logic/utils'; +import { usePost } from '@/state/channel/channel'; +import { useGroupChannel, useRouteGroup } from '@/state/groups'; + +export default function ThreadVolumeDialog({ title }: ViewProps) { + const { chType, chShip, chName, idTime } = useParams<{ + chType: string; + chShip: string; + chName: string; + idTime: string; + }>(); + const flag = useRouteGroup(); + const dismiss = useDismissNavigate(); + const nest = `${chType}/${chShip}/${chName}`; + const channel = useGroupChannel(flag, nest); + const { post } = usePost(nest, idTime!); + const line = post ? firstInlineSummary(post.essay.content) : 'Thread'; + const shortenedLine = line.length > 50 ? `${line.slice(0, 50)}...` : line; + const msgKey = post ? getMessageKey(post) : null; + + const onOpenChange = (open: boolean) => { + if (!open) { + dismiss(); + } + }; + + if (!msgKey) { + return null; + } + + return ( + + + + {channel?.meta + ? `Notifcation settings for thread in ${channel.meta.title} ${title}` + : title} + + +
+
+ Notification Settings + {`Thread: ${shortenedLine}`} +
+ +
+
+ ); +} diff --git a/apps/tlon-web/src/chat/ChatChannel.tsx b/apps/tlon-web/src/chat/ChatChannel.tsx index fd2cf4dff5..b1895d1588 100644 --- a/apps/tlon-web/src/chat/ChatChannel.tsx +++ b/apps/tlon-web/src/chat/ChatChannel.tsx @@ -190,10 +190,7 @@ const ChatChannel = React.memo(({ title }: ViewProps) => { {isSmall ? null : ( - } - /> + } /> )} diff --git a/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx b/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx index 96b06a07f5..a1729803b3 100644 --- a/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx +++ b/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx @@ -1,5 +1,6 @@ import * as Popover from '@radix-ui/react-popover'; import { Editor } from '@tiptap/react'; +import { getKey } from '@tloncorp/shared/dist/urbit/activity'; import { CacheId, Cite, @@ -70,6 +71,7 @@ import { } from '@/state/chat'; import { useGroupFlag } from '@/state/groups'; import { useFileStore, useUploader } from '@/state/storage'; +import { useUnreadsStore } from '@/state/unreads'; interface ChatInputProps { whom: string; @@ -392,7 +394,7 @@ export default function ChatInput({ setDraft(inlinesToJSON([''])); setTimeout(() => { // TODO: chesterton's fence, but why execute a read here? - useChatStore.getState().read(whom); + useUnreadsStore.getState().read(getKey(whom)); clearAttachments(); }, 0); }, diff --git a/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx b/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx index c65b7a925f..272ba49005 100644 --- a/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx +++ b/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx @@ -1,15 +1,20 @@ /* eslint-disable react/no-unused-prop-types */ import { Editor } from '@tiptap/react'; +import { + ActivitySummary, + getKey, + getThreadKey, +} from '@tloncorp/shared/dist/urbit/activity'; import { Post, Story, - Unread, VerseBlock, constructStory, } from '@tloncorp/shared/dist/urbit/channel'; -import { DMUnread } from '@tloncorp/shared/dist/urbit/dms'; import { daToUnix } from '@urbit/api'; +import { formatUd, unixToDa } from '@urbit/aura'; import { BigInteger } from 'big-integer'; +import bigInt from 'big-integer'; import cn from 'classnames'; import { format, formatDistanceToNow, formatRelative, isToday } from 'date-fns'; import debounce from 'lodash/debounce'; @@ -34,14 +39,19 @@ import MessageEditor, { useMessageEditor } from '@/components/MessageEditor'; import UnreadIndicator from '@/components/Sidebar/UnreadIndicator'; import CheckIcon from '@/components/icons/CheckIcon'; import DoubleCaretRightIcon from '@/components/icons/DoubleCaretRightIcon'; +import { useMarkChannelRead } from '@/logic/channel'; import { JSONToInlines, diaryMixedToJSON } from '@/logic/tiptap'; import useLongPress from '@/logic/useLongPress'; import { useIsMobile } from '@/logic/useMedia'; -import { useIsDmOrMultiDm, whomIsDm, whomIsMultiDm } from '@/logic/utils'; +import { + useIsDmOrMultiDm, + whomIsDm, + whomIsFlag, + whomIsMultiDm, +} from '@/logic/utils'; import { useEditPostMutation, useIsEdited, - useMarkReadMutation, usePostToggler, useTrackedPostStatus, } from '@/state/channel/channel'; @@ -50,14 +60,15 @@ import { useMessageToggler, useTrackedMessageStatus, } from '@/state/chat'; +import { Unread, useUnread, useUnreadsStore } from '@/state/unreads'; import ReactionDetails from '../ChatReactions/ReactionDetails'; -import { getUnreadStatus, threadIsOlderThanLastRead } from '../unreadUtils'; import { useChatDialog, useChatDialogs, useChatHovering, useChatInfo, + useChatKeys, useChatStore, } from '../useChatStore'; @@ -78,27 +89,27 @@ export interface ChatMessageProps { } function getUnreadDisplay( - unread: Unread | DMUnread | undefined, - id: string -): 'none' | 'top' | 'thread' { - if (!unread) { - return 'none'; - } + unread: Unread | undefined, + id: string, + thread: Unread | undefined +): 'none' | 'top' | 'thread' | 'top-with-thread' { + const isTop = unread?.lastUnread?.id === id; - const { unread: mainChat, threads } = unread; - const { hasMainChatUnreads } = getUnreadStatus(unread); - const threadIsOlder = threadIsOlderThanLastRead(unread, id); - const hasThread = !!threads[id]; + // if this message is the oldest unread in the main chat, + // and has an unread thread, show the divider and thread indicator + if (thread && isTop) { + return 'top-with-thread'; + } // if we have a thread, only mark it as explicitly unread // if it's not nested under main chat unreads - if (hasThread && (!hasMainChatUnreads || threadIsOlder)) { + if (thread && thread.status !== 'read') { return 'thread'; } // if this message is the oldest unread in the main chat, // show the divider - if (hasMainChatUnreads && mainChat!.id === id) { + if (isTop) { return 'top'; } @@ -163,16 +174,32 @@ const ChatMessage = React.memo< const isMobile = useIsMobile(); const isThreadOnMobile = isThread && isMobile; const isDMOrMultiDM = useIsDmOrMultiDm(whom); + const isChannel = whomIsFlag(whom); + const unreadId = !isChannel ? seal.id : formatUd(bigInt(seal.id)); const chatInfo = useChatInfo(whom); - const unread = chatInfo?.unread; + const unreadsKey = getKey(whom); + const unread = useUnread(unreadsKey); + const threadUr = useUnread(getThreadKey(whom, unreadId)); const unreadDisplay = useMemo( - () => getUnreadDisplay(unread?.unread, seal.id), - [unread, seal.id] + () => + getUnreadDisplay( + unread, + !isChannel ? unreadId : `${essay.author}/${unreadId}`, + threadUr + ), + [unread, threadUr, unreadId] ); + const topUnread = + unreadDisplay === 'top' || unreadDisplay === 'top-with-thread'; + const threadNotify = threadUr?.notify; + const threadUnread = + unreadDisplay === 'thread' || + unreadDisplay === 'top-with-thread' || + threadNotify; const { hovering, setHovering } = useChatHovering(whom, seal.id); const { open: pickerOpen } = useChatDialog(whom, seal.id, 'picker'); - const { mutate: markChatRead } = useMarkReadMutation(); - const { mutate: markDmRead } = useMarkDmReadMutation(); + const { markRead: markReadChannel } = useMarkChannelRead(`chat/${whom}`); + const { markDmRead } = useMarkDmReadMutation(whom); const { mutate: editPost } = useEditPostMutation(); const { isHidden: isMessageHidden } = useMessageToggler(seal.id); const { isHidden: isPostHidden } = usePostToggler(seal.id); @@ -189,33 +216,44 @@ const ChatMessage = React.memo< return; } - const { unread: brief, seen } = unread; + const unseen = unread.status === 'unread'; /* the first fire of this function which we don't to do anything with. */ - if (!inView && !seen) { + if (!inView && unseen) { return; } - const { seen: markSeen, delayedRead } = useChatStore.getState(); - + const { seen: markSeen, delayedRead } = useUnreadsStore.getState(); /* once the unseen marker comes into view we need to mark it as seen and start a timer to mark it read so it goes away. we ensure that the brief matches and hasn't changed before doing so. we don't want to accidentally clear unreads when the state has changed */ - if (inView && unreadDisplay === 'top' && !seen) { - markSeen(whom); - delayedRead(whom, () => { + if ( + inView && + (unreadDisplay === 'top' || + unreadDisplay === 'top-with-thread') && + unseen + ) { + markSeen(unreadsKey); + delayedRead(unreadsKey, () => { if (isDMOrMultiDM) { - markDmRead({ whom }); + markDmRead(); } else { - markChatRead({ nest: `chat/${whom}` }); + markReadChannel(); } }); } }, - [unreadDisplay, unread, whom, isDMOrMultiDM, markChatRead, markDmRead] + [ + unreadDisplay, + unread, + unreadsKey, + isDMOrMultiDM, + markReadChannel, + markDmRead, + ] ), }); @@ -404,12 +442,8 @@ const ChatMessage = React.memo< id="chat-message-target" {...handlers} > - {unread && unreadDisplay === 'top' ? ( - + {unread && topUnread ? ( + ) : null} {newDay && unreadDisplay === 'none' ? ( @@ -529,14 +563,23 @@ const ChatMessage = React.memo< {replyCount} {replyCount > 1 ? 'replies' : 'reply'}{' '} - {unreadDisplay === 'thread' ? ( + {threadUnread && threadUr?.status === 'unread' ? ( ) : null} diff --git a/apps/tlon-web/src/chat/ChatMessage/DateDivider.tsx b/apps/tlon-web/src/chat/ChatMessage/DateDivider.tsx index 318a7c0056..8c4a2d7783 100644 --- a/apps/tlon-web/src/chat/ChatMessage/DateDivider.tsx +++ b/apps/tlon-web/src/chat/ChatMessage/DateDivider.tsx @@ -27,7 +27,7 @@ function DateDividerComponent( {prettyDay} diff --git a/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx b/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx index 3fb4997509..f54ccd80b1 100644 --- a/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx +++ b/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx @@ -1,4 +1,5 @@ import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual'; +import { MessageKey } from '@tloncorp/shared/dist/urbit/activity'; import { PostTuple, ReplyTuple } from '@tloncorp/shared/dist/urbit/channel'; import { WritTuple } from '@tloncorp/shared/dist/urbit/dms'; import { BigInteger } from 'big-integer'; @@ -29,7 +30,7 @@ import { } from '@/logic/scroll'; import { useIsMobile } from '@/logic/useMedia'; import { - ChatMessageListItemData, + MessageListItemData, useMessageData, } from '@/logic/useScrollerMessages'; import { createDevLogger, useObjectChangeLogging } from '@/logic/utils'; @@ -51,7 +52,7 @@ const ChatScrollerItem = React.memo( item, isScrolling, }: { - item: ChatMessageListItemData | CustomScrollItemData; + item: MessageListItemData | CustomScrollItemData; isScrolling: boolean; }) => { if (item.type === 'custom') { @@ -61,12 +62,17 @@ const ChatScrollerItem = React.memo( const { writ, time, ...rest } = item; if ('memo' in writ) { + if (!rest.parent) { + return; + } + return ( ); @@ -168,6 +174,7 @@ const loaderPadding = { export interface ChatScrollerProps { whom: string; + parent?: MessageKey; messages: PostTuple[] | WritTuple[] | ReplyTuple[]; onAtTop?: () => void; onAtBottom?: () => void; @@ -189,6 +196,7 @@ export interface ChatScrollerProps { export default function ChatScroller({ whom, + parent, messages, onAtTop, onAtBottom, @@ -231,6 +239,7 @@ export default function ChatScroller({ scrollTo, messages, replying, + parent, }); const topItem: CustomScrollItemData | null = useMemo( diff --git a/apps/tlon-web/src/chat/ChatThread/ChatThread.tsx b/apps/tlon-web/src/chat/ChatThread/ChatThread.tsx index b56adaec00..b667fd50b0 100644 --- a/apps/tlon-web/src/chat/ChatThread/ChatThread.tsx +++ b/apps/tlon-web/src/chat/ChatThread/ChatThread.tsx @@ -1,31 +1,39 @@ +import { getThreadKey } from '@tloncorp/shared/dist/urbit/activity'; import { ReplyTuple } from '@tloncorp/shared/dist/urbit/channel'; +import { formatUd, unixToDa } from '@urbit/aura'; import bigInt from 'big-integer'; import cn from 'classnames'; import _ from 'lodash'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useParams } from 'react-router'; import { Link, useSearchParams } from 'react-router-dom'; import { VirtuosoHandle } from 'react-virtuoso'; import { useEventListener } from 'usehooks-ts'; import ChatInput from '@/chat/ChatInput/ChatInput'; import ChatScroller from '@/chat/ChatScroller/ChatScroller'; +import ActionMenu from '@/components/ActionMenu'; import useLeap from '@/components/Leap/useLeap'; import MobileHeader from '@/components/MobileHeader'; -import useActiveTab from '@/components/Sidebar/util'; +import useActiveTab, { useNavWithinTab } from '@/components/Sidebar/util'; +import VolumeSetting from '@/components/VolumeSetting'; import BranchIcon from '@/components/icons/BranchIcon'; +import EllipsisIcon from '@/components/icons/EllipsisIcon'; import X16Icon from '@/components/icons/X16Icon'; import keyMap from '@/keyMap'; -import { useChatInputFocus } from '@/logic/ChatInputFocusContext'; import { useDragAndDrop } from '@/logic/DragAndDropContext'; -import { useChannelCompatibility, useChannelFlag } from '@/logic/channel'; +import { + useChannelCompatibility, + useChannelFlag, + useMarkChannelRead, +} from '@/logic/channel'; import { useBottomPadding } from '@/logic/position'; import { useIsScrolling } from '@/logic/scroll'; +import { firstInlineSummary } from '@/logic/tiptap'; import useIsEditingMessage from '@/logic/useIsEditingMessage'; -import useMedia, { useIsMobile } from '@/logic/useMedia'; +import { useIsMobile } from '@/logic/useMedia'; import { useAddReplyMutation, - useMarkReadMutation, useMyLastReply, usePerms, usePost, @@ -36,9 +44,10 @@ import { useRouteGroup, useVessel, } from '@/state/groups/groups'; +import { useUnread, useUnreadsStore } from '@/state/unreads'; import ChatScrollerPlaceholder from '../ChatScroller/ChatScrollerPlaceholder'; -import { chatStoreLogger, useChatInfo, useChatStore } from '../useChatStore'; +import { chatStoreLogger, useChatStore } from '../useChatStore'; export default function ChatThread() { const { name, chShip, ship, chName, idTime } = useParams<{ @@ -49,7 +58,6 @@ export default function ChatThread() { idTime: string; }>(); const isMobile = useIsMobile(); - const { isChatInputFocused } = useChatInputFocus(); const isEditing = useIsEditingMessage(); const scrollerRef = useRef(null); const flag = useChannelFlag()!; @@ -58,8 +66,8 @@ export default function ChatThread() { const groupFlag = useRouteGroup(); const { mutate: sendMessage } = useAddReplyMutation(); const location = useLocation(); + const [isMenuOpen, setIsMenuOpen] = useState(false); const scrollTo = new URLSearchParams(location.search).get('reply'); - const { mutate: markRead } = useMarkReadMutation(); const channel = useGroupChannel(groupFlag, nest)!; const [searchParams, setSearchParams] = useSearchParams(); const replyId = useMemo(() => searchParams.get('replyTo'), [searchParams]); @@ -70,6 +78,14 @@ export default function ChatThread() { const dropZoneId = `chat-thread-input-dropzone-${idTime}`; const { isDragging, isOver } = useDragAndDrop(dropZoneId); const { post: note, isLoading } = usePost(nest, idTime!); + const time = formatUd(bigInt(idTime!)); + const id = note ? `${note.essay.author}/${time}` : ''; + const msgKey = { + id, + time: formatUd(bigInt(idTime!)), + }; + const chatUnreadsKey = getThreadKey(flag, time); + const { markRead } = useMarkChannelRead(nest, msgKey); const replies = note?.seal.replies || null; const idTimeIsNumber = !Number.isNaN(Number(idTime)); if (note && replies !== null && idTimeIsNumber) { @@ -101,7 +117,7 @@ export default function ChatThread() { }), [replies] ); - const navigate = useNavigate(); + const { navigate } = useNavWithinTab(); const threadRef = useRef(null); const perms = usePerms(nest); const vessel = useVessel(groupFlag, window.our); @@ -113,9 +129,12 @@ export default function ChatThread() { _.intersection(perms.writers, vessel.sects).length !== 0; const { compatible, text } = useChannelCompatibility(`chat/${flag}`); const { paddingBottom } = useBottomPadding(); - const readTimeout = useChatInfo(flag).unread?.readTimeout; - const isSmall = useMedia('(max-width: 1023px)'); - const clearOnNavRef = useRef({ isSmall, readTimeout, nest, flag, markRead }); + const readTimeout = useUnread(chatUnreadsKey)?.readTimeout; + const clearOnNavRef = useRef({ + readTimeout, + chatUnreadsKey, + markRead, + }); const activeTab = useActiveTab(); const returnURL = useCallback( @@ -135,10 +154,11 @@ export default function ChatThread() { ); const onAtBottom = useCallback(() => { - const { bottom, delayedRead } = useChatStore.getState(); + const { bottom } = useChatStore.getState(); + const { delayedRead } = useUnreadsStore.getState(); bottom(true); - delayedRead(flag, () => markRead({ nest })); - }, [nest, flag, markRead]); + delayedRead(chatUnreadsKey, markRead); + }, [chatUnreadsKey, markRead]); const onEscape = useCallback( (e: KeyboardEvent) => { @@ -152,20 +172,20 @@ export default function ChatThread() { // read the messages once navigated away useEffect(() => { - clearOnNavRef.current = { isSmall, readTimeout, nest, flag, markRead }; - }, [readTimeout, nest, flag, isSmall, markRead]); + clearOnNavRef.current = { + readTimeout, + chatUnreadsKey, + markRead, + }; + }, [readTimeout, chatUnreadsKey, markRead]); useEffect( () => () => { const curr = clearOnNavRef.current; - if ( - curr.isSmall && - curr.readTimeout !== undefined && - curr.readTimeout !== 0 - ) { + if (curr.readTimeout !== undefined && curr.readTimeout !== 0) { chatStoreLogger.log('unmount read from thread'); - useChatStore.getState().read(curr.flag); - curr.markRead({ nest: curr.nest }); + useUnreadsStore.getState().read(curr.chatUnreadsKey); + curr.markRead(); } }, [] @@ -178,6 +198,8 @@ export default function ChatThread() { }, [replies, idTimeIsNumber, navigate, returnURLWithoutMsg]); const BackButton = isMobile ? Link : 'div'; + const line = note ? firstInlineSummary(note.essay.content) : 'Thread'; + const shortenedLine = line.length > 50 ? `${line.slice(0, 50)}...` : line; return (
} pathBack={returnURL()} + action={ +
+ +
+ + Notification Settings + + + {`Thread: ${shortenedLine}`} + +
+ +
+ ), + keepOpenOnClick: true, + }, + ]} + > + + +
+ } /> ) : (
@@ -231,13 +300,29 @@ export default function ChatThread() { - - - +
+ + + + +
)} @@ -249,6 +334,7 @@ export default function ChatThread() { key={idTime} messages={orderedReplies || []} whom={flag} + parent={msgKey} isLoadingOlder={false} isLoadingNewer={false} scrollerRef={scrollerRef} diff --git a/apps/tlon-web/src/chat/ChatUnreadAlerts.tsx b/apps/tlon-web/src/chat/ChatUnreadAlerts.tsx deleted file mode 100644 index 3383c588fb..0000000000 --- a/apps/tlon-web/src/chat/ChatUnreadAlerts.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Unread, UnreadPoint } from '@tloncorp/shared/dist/urbit/channel'; -import { daToUnix } from '@urbit/api'; -import bigInt from 'big-integer'; -import { format, isToday } from 'date-fns'; -import { useCallback } from 'react'; -import { Link } from 'react-router-dom'; - -import XIcon from '@/components/icons/XIcon'; -import { nestToFlag, pluralize } from '@/logic/utils'; -import { useMarkReadMutation } from '@/state/channel/channel'; - -import { getUnreadStatus, threadIsOlderThanLastRead } from './unreadUtils'; -import { useChatInfo, useChatStore } from './useChatStore'; - -interface ChatUnreadAlertsProps { - nest: string; - root: string; -} - -export default function ChatUnreadAlerts({ - nest, - root, -}: ChatUnreadAlertsProps) { - const { mutate: markChatRead } = useMarkReadMutation(); - const [, flag] = nestToFlag(nest); - const chatInfo = useChatInfo(flag); - const markRead = useCallback(() => { - markChatRead({ nest }); - useChatStore.getState().read(flag); - }, [nest, flag, markChatRead]); - - if (!chatInfo?.unread || chatInfo.unread.seen || !chatInfo?.unread.unread) { - return null; - } - - const unread = chatInfo.unread.unread as Unread; - const { unread: mainChat, threads } = unread; - const { isEmpty, hasThreadUnreads } = getUnreadStatus(unread); - if (isEmpty) { - return null; - } - - const sortedThreads = Object.entries(threads).sort(([a], [b]) => - bigInt(a).compare(bigInt(b)) - ); - const oldestThread = sortedThreads[0] as [string, UnreadPoint] | undefined; - const threadIsOlder = threadIsOlderThanLastRead( - unread, - oldestThread ? oldestThread[0] : null - ); - - /* if we have thread unreads that are older than what's unseen - in the main chat, we should link to them in the banner instead of - just scrolling up - */ - let to = ''; - let date = new Date(); - if (hasThreadUnreads && threadIsOlder) { - const [id, thread] = oldestThread!; - to = `${root}/message/${id}?reply=${thread.id}`; - date = new Date(daToUnix(bigInt(thread.id))); - } else { - to = `${root}?msg=${mainChat!.id}`; - date = new Date(daToUnix(bigInt(mainChat!.id))); - } - - const since = isToday(date) - ? `${format(date, 'HH:mm')} today` - : format(date, 'LLLL d'); - - const unreadMessage = `${unread.count} new ${pluralize( - 'message', - unread.count - )} since ${since}`; - - return ( - <> -
- - - {unreadMessage} — Click to View - - - -
-
- - ); -} diff --git a/apps/tlon-web/src/chat/ChatWindow.tsx b/apps/tlon-web/src/chat/ChatWindow.tsx index 828537ad20..06054f9265 100644 --- a/apps/tlon-web/src/chat/ChatWindow.tsx +++ b/apps/tlon-web/src/chat/ChatWindow.tsx @@ -1,3 +1,4 @@ +import { getKey } from '@tloncorp/shared/dist/urbit/activity'; import bigInt from 'big-integer'; import React, { ReactElement, @@ -5,20 +6,22 @@ import React, { useEffect, useMemo, useRef, + useState, } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { VirtuosoHandle } from 'react-virtuoso'; import ChatScroller from '@/chat/ChatScroller/ChatScroller'; -import ChatUnreadAlerts from '@/chat/ChatUnreadAlerts'; import EmptyPlaceholder from '@/components/EmptyPlaceholder'; import ArrowS16Icon from '@/components/icons/ArrowS16Icon'; -import { useChannelCompatibility } from '@/logic/channel'; +import { useChannelCompatibility, useMarkChannelRead } from '@/logic/channel'; import { log } from '@/logic/utils'; -import { useInfinitePosts, useMarkReadMutation } from '@/state/channel/channel'; +import { useInfinitePosts } from '@/state/channel/channel'; +import { useUnread, useUnreadsStore } from '@/state/unreads'; import ChatScrollerPlaceholder from './ChatScroller/ChatScrollerPlaceholder'; -import { useChatInfo, useChatStore } from './useChatStore'; +import UnreadAlerts from './UnreadAlerts'; +import { useChatStore } from './useChatStore'; interface ChatWindowProps { whom: string; @@ -28,212 +31,235 @@ interface ChatWindowProps { isScrolling: boolean; } -const ChatWindow = React.memo( - ({ - whom, +const ChatWindow = React.memo(function ChatWindowRaw({ + whom, + root, + 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 { markRead } = useMarkChannelRead(nest); + const scrollerRef = useRef(null); + const fetchingNewest = + isFetching && (!isFetchingNextPage || !isFetchingPreviousPage); + const [showUnreadBanner, setShowUnreadBanner] = useState(false); + const unreadsKey = getKey(whom); + const readTimeout = useUnread(unreadsKey)?.readTimeout; + const clearOnNavRef = useRef({ readTimeout, nest, unreadsKey, 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, root, - 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 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, - 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]); + useEffect(() => { + useChatStore.getState().setCurrent(whom); - 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 }); - } - }, - [] - ); + return () => { + useChatStore.getState().setCurrent(''); + }; + }, [whom]); - 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]); + const onAtBottom = useCallback(() => { + const { bottom } = useChatStore.getState(); + const { delayedRead } = useUnreadsStore.getState(); + bottom(true); + delayedRead(unreadsKey, () => markRead()); + if (hasPreviousPage && !isFetching) { + log('fetching previous page'); + fetchPreviousPage(); + } + }, [unreadsKey, markRead, fetchPreviousPage, hasPreviousPage, isFetching]); - if (isLoading) { - return ( -
- -
- ); + const onAtTop = useCallback(() => { + if (hasNextPage && !isFetching) { + log('fetching next page'); + fetchNextPage(); } + }, [fetchNextPage, hasNextPage, isFetching]); - 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.

-
-
- ); + /** + * we want to show unread banner after messages have had a chance to + * render, so that we don't flash it right before removing it because + * we saw the unread marker + */ + useEffect(() => { + let timeout = 0; + setShowUnreadBanner(false); + if (!fetchingNewest) { + timeout = setTimeout(() => { + setShowUnreadBanner(true); + }, 250) as unknown as number; } + return () => { + clearTimeout(timeout); + }; + }, [fetchingNewest]); + + // read the messages once navigated away + useEffect(() => { + clearOnNavRef.current = { readTimeout, nest, unreadsKey, markRead }; + }, [readTimeout, nest, unreadsKey, markRead]); + + useEffect( + () => () => { + const curr = clearOnNavRef.current; + if (curr.readTimeout !== undefined && curr.readTimeout !== 0) { + useUnreadsStore.getState().read(curr.unreadsKey); + curr.markRead(); + } + }, + [] + ); + + 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 ( -
- -
- -
- {scrollToId && - (hasPreviousPage || latestIsMoreThan30NewerThanScrollTo) ? ( -
- -
- ) : null} +
+
); } -); + + 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 ( +
+ {showUnreadBanner && !fetchingNewest ? ( + + ) : null} +
+ +
+ {scrollToId && + (hasPreviousPage || latestIsMoreThan30NewerThanScrollTo) ? ( +
+ +
+ ) : null} +
+ ); +}); export default ChatWindow; diff --git a/apps/tlon-web/src/chat/DMUnreadAlerts.tsx b/apps/tlon-web/src/chat/DMUnreadAlerts.tsx deleted file mode 100644 index 983ab7ec77..0000000000 --- a/apps/tlon-web/src/chat/DMUnreadAlerts.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { DMUnread, UnreadThread } from '@tloncorp/shared/dist/urbit/dms'; -import { daToUnix } from '@urbit/api'; -import bigInt from 'big-integer'; -import { format, isToday } from 'date-fns'; -import { useCallback } from 'react'; -import { Link } from 'react-router-dom'; - -import XIcon from '@/components/icons/XIcon'; -import { pluralize } from '@/logic/utils'; -import { useMarkDmReadMutation } from '@/state/chat'; - -import { getUnreadStatus, threadIsOlderThanLastRead } from './unreadUtils'; -import { useChatInfo, useChatStore } from './useChatStore'; - -interface DMUnreadAlertsProps { - whom: string; - root: string; -} - -export default function DMUnreadAlerts({ whom, root }: DMUnreadAlertsProps) { - const chatInfo = useChatInfo(whom); - const { mutate: markDmRead } = useMarkDmReadMutation(); - const markRead = useCallback(() => { - markDmRead({ whom }); - useChatStore.getState().read(whom); - }, [whom, markDmRead]); - - if (!chatInfo?.unread || chatInfo.unread.seen) { - return null; - } - - const unread = chatInfo.unread.unread as DMUnread; - const { unread: mainChat, threads } = unread; - const { isEmpty, hasThreadUnreads } = getUnreadStatus(unread); - if (isEmpty) { - return null; - } - - const sortedThreads = Object.entries(threads).sort(([, a], [, b]) => - bigInt(a['parent-time']).compare(bigInt(b['parent-time'])) - ); - const oldestThread = sortedThreads[0] as [string, UnreadThread] | undefined; - const threadIsOlder = threadIsOlderThanLastRead( - unread, - oldestThread ? oldestThread[0] : null - ); - - /* if we have thread unreads that are older than what's unseen - in the main chat, we should link to them in the banner instead of - just scrolling up - */ - let to = ''; - let date = new Date(); - if (hasThreadUnreads && threadIsOlder) { - const [id, thread] = oldestThread!; - to = `${root}/message/${id}?reply=${thread.time}`; - date = new Date(daToUnix(bigInt(thread.time))); - } else { - to = `${root}?msg=${mainChat!.time}`; - date = new Date(daToUnix(bigInt(mainChat!.time))); - } - - const since = isToday(date) - ? `${format(date, 'HH:mm')} today` - : format(date, 'LLLL d'); - const unreadMessage = `${unread.count} new ${pluralize( - 'message', - unread.count - )} since ${since}`; - - return ( - <> -
- - - {unreadMessage} — Click to View - - - -
-
- - ); -} diff --git a/apps/tlon-web/src/chat/UnreadAlerts.tsx b/apps/tlon-web/src/chat/UnreadAlerts.tsx new file mode 100644 index 0000000000..dc9b5c0820 --- /dev/null +++ b/apps/tlon-web/src/chat/UnreadAlerts.tsx @@ -0,0 +1,74 @@ +import { ActivitySummary, getKey } from '@tloncorp/shared/dist/urbit/activity'; +import { daToUnix } from '@urbit/api'; +import bigInt from 'big-integer'; +import { format, isToday } from 'date-fns'; +import { useCallback } from 'react'; +import { Link } from 'react-router-dom'; + +import XIcon from '@/components/icons/XIcon'; +import { useMarkChannelRead } from '@/logic/channel'; +import { pluralize, whomIsFlag } from '@/logic/utils'; +import { useMarkDmReadMutation } from '@/state/chat'; +import { useUnread, useUnreadsStore } from '@/state/unreads'; + +import { useChatStore } from './useChatStore'; + +interface UnreadAlertsProps { + whom: string; + root: string; +} + +export default function UnreadAlerts({ whom, root }: UnreadAlertsProps) { + const unread = useUnread(getKey(whom)); + const { markRead: markReadChannel } = useMarkChannelRead(`chat/${whom}`); + const { markDmRead } = useMarkDmReadMutation(whom); + const markRead = useCallback(() => { + if (whomIsFlag(whom)) { + markReadChannel(); + } else { + markDmRead(); + } + useUnreadsStore.getState().read(getKey(whom)); + }, [whom, markReadChannel, markDmRead]); + + if (!unread || unread.status !== 'unread') { + return null; + } + + if (unread.count === 0 || !unread.lastUnread) { + return null; + } + + const to = `${root}?msg=${unread.lastUnread.time}`; + const date = new Date(daToUnix(bigInt(unread.lastUnread.time))); + + const since = isToday(date) + ? `${format(date, 'HH:mm')} today` + : format(date, 'LLLL d'); + const unreadMessage = `${unread.count} new ${pluralize( + 'message', + unread.count + )} since ${since}`; + + return ( + <> +
+ + + {unreadMessage} — Click to View + + + +
+
+ + ); +} diff --git a/apps/tlon-web/src/chat/unreadUtils.ts b/apps/tlon-web/src/chat/unreadUtils.ts deleted file mode 100644 index d936386817..0000000000 --- a/apps/tlon-web/src/chat/unreadUtils.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Unread } from '@tloncorp/shared/dist/urbit/channel'; -import { DMUnread } from '@tloncorp/shared/dist/urbit/dms'; -import bigInt from 'big-integer'; - -export function threadIsOlderThanLastRead( - unread: DMUnread | Unread, - threadId: string | null -): boolean { - if (!unread || !threadId) { - return false; - } - - const mainChatUnread = unread.unread; - const threadUnreads = unread.threads || {}; - const thread = threadUnreads[threadId]; - - const hasMainChatUnread = mainChatUnread && mainChatUnread.count > 0; - const hasThread = !!thread; - - if (hasThread && !hasMainChatUnread) { - return true; - } - - if (hasThread && hasMainChatUnread) { - return 'parent-time' in thread && 'time' in mainChatUnread - ? bigInt(thread['parent-time']).lesser(bigInt(mainChatUnread.time)) - : bigInt(threadId).lesser(bigInt(mainChatUnread.id)); - } - - return false; -} - -export function getUnreadStatus(unread: DMUnread | Unread) { - const mainChatUnread = unread.unread; - const threadUnreads = unread.threads || {}; - - const hasMainChatUnreads = mainChatUnread && mainChatUnread.count > 0; - const hasThreadUnreads = - Object.keys(threadUnreads).length > 0 && - Object.values(threadUnreads).some((thread) => thread.count > 0); - - return { - hasMainChatUnreads, - hasThreadUnreads, - isEmpty: unread.count === 0, - }; -} diff --git a/apps/tlon-web/src/chat/useChatStore.ts b/apps/tlon-web/src/chat/useChatStore.ts index b4014428ee..67c6ead712 100644 --- a/apps/tlon-web/src/chat/useChatStore.ts +++ b/apps/tlon-web/src/chat/useChatStore.ts @@ -1,19 +1,24 @@ -import { Block, Unread, Unreads } from '@tloncorp/shared/dist/urbit/channel'; -import { DMUnread, DMUnreads } from '@tloncorp/shared/dist/urbit/dms'; +import { + Activity, + ActivitySummary, +} from '@tloncorp/shared/dist/urbit/activity'; +import { Block } from '@tloncorp/shared/dist/urbit/channel'; import produce from 'immer'; import { useCallback } from 'react'; import create from 'zustand'; import { createDevLogger } from '@/logic/utils'; +export interface ChatInfoUnread { + readTimeout: number; + seen: boolean; + unread: ActivitySummary; // lags behind actual unread, only gets update if unread +} + export interface ChatInfo { replying: string | null; blocks: Block[]; - unread?: { - readTimeout: number; - seen: boolean; - unread: DMUnread | Unread; // lags behind actual unread, only gets update if unread - }; + unread?: ChatInfoUnread; dialogs: Record>; hovering: string; failedToLoadContent: Record>; @@ -42,14 +47,10 @@ export interface ChatStore { seen: (whom: string) => void; read: (whom: string) => void; delayedRead: (whom: string, callback: () => void) => void; - handleUnread: ( - whom: string, - unread: Unread | DMUnread, - markRead: (whm: string) => void - ) => void; + handleUnread: (whom: string, unread: ActivitySummary) => void; bottom: (atBottom: boolean) => void; setCurrent: (whom: string) => void; - update: (unreads: Unreads | DMUnreads) => void; + update: (unreads: Activity) => void; } const emptyInfo: () => ChatInfo = () => ({ @@ -63,9 +64,8 @@ const emptyInfo: () => ChatInfo = () => ({ export const chatStoreLogger = createDevLogger('ChatStore', false); -export function isUnread(unread: Unread | DMUnread): boolean { - const hasThreads = Object.keys(unread.threads || {}).length > 0; - return unread.count > 0 && (!!unread.unread || hasThreads); +export function isUnread(unread: ActivitySummary): boolean { + return unread.count > 0; } export const useChatStore = create((set, get) => ({ @@ -183,8 +183,9 @@ export const useChatStore = create((set, get) => ({ unread: { recency: 0, count: 0, - unread: { id: '', count: 0 }, - threads: {}, + notify: false, + unread: null, + children: [], }, readTimeout: 0, }; @@ -298,6 +299,10 @@ export function useChatInfo(flag: string): ChatInfo { return useChatStore(useCallback((s) => s.chats[flag] || defaultInfo, [flag])); } +export function useChatKeys(): string[] { + return useChatStore(useCallback((s) => Object.keys(s.chats), [])); +} + export function fetchChatBlocks(whom: string): Block[] { return useChatStore.getState().chats[whom]?.blocks || []; } diff --git a/apps/tlon-web/src/components/Leap/useLeap.tsx b/apps/tlon-web/src/components/Leap/useLeap.tsx index d392b4211d..dfb9ecb1e4 100644 --- a/apps/tlon-web/src/components/Leap/useLeap.tsx +++ b/apps/tlon-web/src/components/Leap/useLeap.tsx @@ -13,7 +13,7 @@ import { LEAP_RESULT_TRUNCATE_SIZE, } from '@/constants'; import { useCheckChannelUnread } from '@/logic/channel'; -import useIsGroupUnread from '@/logic/useIsGroupUnread'; +import useGroupUnread from '@/logic/useIsGroupUnread'; import { getFlagParts, nestToFlag } from '@/logic/utils'; import { useCheckDmUnread, useDms, useMultiDms } from '@/state/chat'; import { emptyContact, useContacts } from '@/state/contact'; @@ -92,8 +92,8 @@ export default function useLeap() { const navigate = useNavigate(); const groups = useGroups(); const currentGroupFlag = useGroupFlag(); - const { isGroupUnread } = useIsGroupUnread(); - const isChannelUnread = useCheckChannelUnread(); + const { getGroupUnread } = useGroupUnread(); + const { isChannelUnread } = useCheckChannelUnread(); const isDMUnread = useCheckDmUnread(); const pinnedGroups = usePinnedGroups(); const multiDms = useMultiDms(); @@ -312,8 +312,8 @@ export default function useLeap() { } // so are unread groups, but just a little - const isUnreadGroup = isGroupUnread(groupFlag); - if (isUnreadGroup) { + const groupUnread = getGroupUnread(groupFlag); + if (groupUnread.status === 'unread') { newScore += 2; } @@ -382,7 +382,7 @@ export default function useLeap() { groups, inputValue, isChannelUnread, - isGroupUnread, + getGroupUnread, navigate, pinnedChannels, pinnedGroups, @@ -412,8 +412,8 @@ export default function useLeap() { } // prefer unreads as well - const isUnread = isGroupUnread(groupFlag); - if (isUnread) { + const groupUnread = getGroupUnread(groupFlag); + if (groupUnread.status === 'unread') { newScore += 5; } @@ -470,7 +470,7 @@ export default function useLeap() { channelResults.length, groups, inputValue, - isGroupUnread, + getGroupUnread, navigate, pinnedGroups, shipResults.length, diff --git a/apps/tlon-web/src/components/RadioGroup.tsx b/apps/tlon-web/src/components/RadioGroup.tsx index 3550ce9218..2c42e533ec 100644 --- a/apps/tlon-web/src/components/RadioGroup.tsx +++ b/apps/tlon-web/src/components/RadioGroup.tsx @@ -49,7 +49,10 @@ export default function RadioGroup({ )} >
-
+ toggleShowCounts(!showUnreadCounts)} + status={showCountsStatus} + name="Show unread counts" + labelClassName="font-semibold" + > +

+ Show the number of unread activity in each channel and group +

+
toggleAvatars(!disableAvatars)} @@ -100,7 +114,7 @@ export default function Settings() {
- +
diff --git a/apps/tlon-web/src/components/Sidebar/ActivityIndicator.tsx b/apps/tlon-web/src/components/Sidebar/ActivityIndicator.tsx index 413adc7f08..cf42661365 100644 --- a/apps/tlon-web/src/components/Sidebar/ActivityIndicator.tsx +++ b/apps/tlon-web/src/components/Sidebar/ActivityIndicator.tsx @@ -1,6 +1,7 @@ import cn from 'classnames'; import { useNotifications } from '@/notifications/useNotifications'; +import { useCalmSetting } from '@/state/settings'; import BellIcon from '../icons/BellIcon'; import BulletIcon from '../icons/BulletIcon'; @@ -18,6 +19,7 @@ export default function ActivityIndicator({ bg = 'bg-gray-100', className, }: ActivityIndicatorProps) { + const showCounts = useCalmSetting('showUnreadCounts'); return (
- {count === 0 ? ( + {count === 0 || !showCounts ? ( ) : count > 99 ? ( '99+' diff --git a/apps/tlon-web/src/components/Sidebar/AppNav.tsx b/apps/tlon-web/src/components/Sidebar/AppNav.tsx index d92b7830f9..b4d4ce08bf 100644 --- a/apps/tlon-web/src/components/Sidebar/AppNav.tsx +++ b/apps/tlon-web/src/components/Sidebar/AppNav.tsx @@ -7,13 +7,15 @@ import Asterisk16Icon from '@/components/icons/Asterisk16Icon'; import { useChatInputFocus } from '@/logic/ChatInputFocusContext'; import { isNativeApp, useSafeAreaInsets } from '@/logic/native'; import useAppUpdates, { AppUpdateContext } from '@/logic/useAppUpdates'; -import { useIsAnyGroupUnread } from '@/logic/useIsGroupUnread'; import { useIsDark, useIsMobile } from '@/logic/useMedia'; import useShowTabBar from '@/logic/useShowTabBar'; import { useNotifications } from '@/notifications/useNotifications'; -import { useHasUnreadMessages } from '@/state/chat'; import { useCharge } from '@/state/docket'; import { useLocalState } from '@/state/local'; +import { + useCombinedChatUnreads, + useCombinedGroupUnreads, +} from '@/state/unreads'; import Avatar from '../Avatar'; import useLeap from '../Leap/useLeap'; @@ -30,7 +32,7 @@ import useActiveTab, { ActiveTab } from './util'; function GroupsTab(props: { isInactive: boolean; isDarkMode: boolean }) { const navigate = useNavigate(); const { groupsLocation } = useLocalState.getState(); - const groupsUnread = useIsAnyGroupUnread(); + const groupsUnread = useCombinedGroupUnreads(); const isMobile = useIsMobile(); const onSingleClick = () => { @@ -62,7 +64,11 @@ function GroupsTab(props: { isInactive: boolean; isDarkMode: boolean }) {
@@ -89,7 +95,11 @@ function GroupsTab(props: { isInactive: boolean; isDarkMode: boolean }) {
@@ -99,7 +109,7 @@ function GroupsTab(props: { isInactive: boolean; isDarkMode: boolean }) { function MessagesTab(props: { isInactive: boolean; isDarkMode: boolean }) { const navigate = useNavigate(); const { messagesLocation } = useLocalState.getState(); - const hasUnreads = useHasUnreadMessages(); + const unreads = useCombinedChatUnreads(); const isMobile = useIsMobile(); const onSingleClick = () => { @@ -131,7 +141,7 @@ function MessagesTab(props: { isInactive: boolean; isDarkMode: boolean }) {
@@ -158,7 +168,7 @@ function MessagesTab(props: { isInactive: boolean; isDarkMode: boolean }) {
diff --git a/apps/tlon-web/src/components/Sidebar/MobileSidebar.tsx b/apps/tlon-web/src/components/Sidebar/MobileSidebar.tsx index 4f7916caaf..6c1b1d657b 100644 --- a/apps/tlon-web/src/components/Sidebar/MobileSidebar.tsx +++ b/apps/tlon-web/src/components/Sidebar/MobileSidebar.tsx @@ -6,7 +6,7 @@ import Asterisk16Icon from '@/components/icons/Asterisk16Icon'; import { useChatInputFocus } from '@/logic/ChatInputFocusContext'; import { isNativeApp, useSafeAreaInsets } from '@/logic/native'; import { AppUpdateContext } from '@/logic/useAppUpdates'; -import { useIsAnyGroupUnread } from '@/logic/useIsGroupUnread'; +import { useCombinedGroupsUnread } from '@/logic/useIsGroupUnread'; import { useIsDark } from '@/logic/useMedia'; import useShowTabBar from '@/logic/useShowTabBar'; import { useNotifications } from '@/notifications/useNotifications'; @@ -23,7 +23,7 @@ import MessagesIcon from '../icons/MessagesIcon'; function GroupsTab(props: { isInactive: boolean; isDarkMode: boolean }) { const navigate = useNavigate(); const { groupsLocation } = useLocalState.getState(); - const groupsUnread = useIsAnyGroupUnread(); + const groupsUnread = useCombinedGroupsUnread(); const onSingleClick = () => { if (isNativeApp()) { @@ -53,7 +53,11 @@ function GroupsTab(props: { isInactive: boolean; isDarkMode: boolean }) {
diff --git a/apps/tlon-web/src/components/Sidebar/SidebarItem.tsx b/apps/tlon-web/src/components/Sidebar/SidebarItem.tsx index d06ab19790..ce6f67769c 100644 --- a/apps/tlon-web/src/components/Sidebar/SidebarItem.tsx +++ b/apps/tlon-web/src/components/Sidebar/SidebarItem.tsx @@ -192,7 +192,7 @@ const SidebarItem = React.forwardRef( )} {actions ? ( -
+
{typeof actions === 'function' ? actions({ hover }) : actions}
) : null} diff --git a/apps/tlon-web/src/components/Sidebar/UnreadIndicator.tsx b/apps/tlon-web/src/components/Sidebar/UnreadIndicator.tsx index a34b693277..a3d0688b27 100644 --- a/apps/tlon-web/src/components/Sidebar/UnreadIndicator.tsx +++ b/apps/tlon-web/src/components/Sidebar/UnreadIndicator.tsx @@ -1,14 +1,24 @@ import cn from 'classnames'; -import React from 'react'; import ActivityIndicator from './ActivityIndicator'; -export default function UnreadIndicator({ className }: { className?: string }) { +interface UnreadIndicatorProps { + className?: string; + count: number; + notify?: boolean; +} + +export default function UnreadIndicator({ + className, + count, + notify, +}: UnreadIndicatorProps) { + const color = notify ? 'text-blue' : 'text-gray-400'; return ( ); } diff --git a/apps/tlon-web/src/components/VolumeSetting.tsx b/apps/tlon-web/src/components/VolumeSetting.tsx index 9dc6b8219f..6b34d9d89d 100644 --- a/apps/tlon-web/src/components/VolumeSetting.tsx +++ b/apps/tlon-web/src/components/VolumeSetting.tsx @@ -1,115 +1,90 @@ import { - LevelNames, - Scope, - VolumeValue, -} from '@tloncorp/shared/dist/urbit/volume'; -import React, { useEffect, useState } from 'react'; + NotificationLevel, + NotificationNames, + Source, + getDefaultVolumeOption, + getLevelFromVolumeMap, + getUnreadsFromVolumeMap, + getVolumeMap, + sourceToString, +} from '@tloncorp/shared/dist/urbit/activity'; +import React, { useCallback } from 'react'; -import { - useBaseVolumeSetMutation, - useGroupChannelVolumeSetMutation, - useGroupVolumeSetMutation, - useRouteGroup, - useVolume, -} from '@/state/groups'; +import { useVolumeAdjustMutation, useVolumeSettings } from '@/state/activity'; +import { useRouteGroup } from '@/state/groups'; import RadioGroup, { RadioGroupOption } from './RadioGroup'; +import Setting from './Settings/Setting'; -export default function VolumeSetting({ scope }: { scope?: Scope }) { +export default function VolumeSetting({ source }: { source: Source }) { const groupFlag = useRouteGroup(); - const [value, setValue] = useState(''); - const { volume: currentVolume, isLoading } = useVolume(scope); - const { volume: baseVolume, isLoading: baseVolumeIsLoading } = useVolume(); - const { volume: groupVolume, isLoading: groupVolumeIsLoading } = useVolume({ - group: groupFlag, - }); - const { mutate: setBaseVolume } = useBaseVolumeSetMutation(); - const { mutate: setGroupVoulume } = useGroupVolumeSetMutation(); - const { mutate: setChannelVolume } = useGroupChannelVolumeSetMutation(); + const { data: settings, isLoading } = useVolumeSettings(); + const currentSettings = source ? settings[sourceToString(source)] : null; + const currentVolume = currentSettings + ? getLevelFromVolumeMap(currentSettings) + : null; + const currentUnreads = currentSettings + ? getUnreadsFromVolumeMap(currentSettings) + : true; + const { label, volume } = getDefaultVolumeOption(source, settings, groupFlag); + const { mutate: setVolume, isLoading: settingVolume } = + useVolumeAdjustMutation(); const options: RadioGroupOption[] = [ - { label: LevelNames.loud, value: 'loud' }, - { label: LevelNames.soft, value: 'soft' }, - { label: LevelNames.hush, value: 'hush' }, + { label: NotificationNames.loud, value: 'loud' }, + { label: NotificationNames.medium, value: 'medium' }, + { label: NotificationNames.soft, value: 'soft' }, + { label: NotificationNames.hush, value: 'hush' }, ]; - const defaultLevel = - scope && 'channel' in scope - ? groupVolume === null - ? baseVolume - : groupVolume - : baseVolume; - - if (scope) { + if (!('base' in source)) { options.unshift({ - label: - 'channel' in scope && groupVolume !== null - ? 'Use group default' - : 'Use default setting', + label, value: 'default', - secondaryLabel: `Your default: ${ - LevelNames[defaultLevel === null ? 'soft' : defaultLevel] - }`, + secondaryLabel: `Your default: ${NotificationNames[volume]}`, }); } - useEffect(() => { - if (value === '' && currentVolume && !isLoading) { - setValue(currentVolume); - } - if ( - value === '' && - currentVolume == null && - baseVolume && - !isLoading && - !baseVolumeIsLoading - ) { - setValue('default'); - } - }, [ - currentVolume, - value, - isLoading, - scope, - baseVolume, - baseVolumeIsLoading, - groupVolume, - groupVolumeIsLoading, - ]); - - useEffect(() => { - if (value !== '' && currentVolume !== value && !isLoading) { - if (!scope) { - setBaseVolume({ volume: value as VolumeValue }); - } else if ('group' in scope) { - if (value !== 'default') { - setGroupVoulume({ flag: scope.group, volume: value as VolumeValue }); - } else { - setGroupVoulume({ flag: scope.group, volume: null }); - } - } else if ('channel' in scope) { - if (value !== 'default') { - setChannelVolume({ - nest: scope.channel, - volume: value as VolumeValue, - }); - } else { - setChannelVolume({ - nest: scope.channel, - volume: null, - }); - } + const toggle = useCallback( + (enabled: boolean) => { + if (currentVolume === null) { + return; } - } - }, [ - value, - currentVolume, - isLoading, - scope, - setBaseVolume, - setGroupVoulume, - setChannelVolume, - ]); - return ; + setVolume({ + source: source || { base: null }, + volume: getVolumeMap(currentVolume, enabled), + }); + }, + [source, currentVolume, setVolume] + ); + + const adjust = useCallback( + (value: NotificationLevel) => { + setVolume({ + source: source || { base: null }, + volume: + value === 'default' ? null : getVolumeMap(value, currentUnreads), + }); + }, + [currentUnreads, source, setVolume] + ); + + return ( +
+ + void} + options={options} + /> +
+ ); } diff --git a/apps/tlon-web/src/diary/DiaryChannel.tsx b/apps/tlon-web/src/diary/DiaryChannel.tsx index a1b9281c5b..275525e933 100644 --- a/apps/tlon-web/src/diary/DiaryChannel.tsx +++ b/apps/tlon-web/src/diary/DiaryChannel.tsx @@ -13,13 +13,12 @@ import api from '@/api'; import EmptyPlaceholder from '@/components/EmptyPlaceholder'; import Layout from '@/components/Layout/Layout'; import DiaryGridView from '@/diary/DiaryList/DiaryGridView'; -import { useFullChannel } from '@/logic/channel'; +import { useFullChannel, useMarkChannelRead } from '@/logic/channel'; import useDismissChannelNotifications from '@/logic/useDismissChannelNotifications'; import { useArrangedPosts, useDisplayMode, useInfinitePosts, - useMarkReadMutation, useSortMode, } from '@/state/channel/channel'; import { useRouteGroup } from '@/state/groups/groups'; @@ -47,7 +46,7 @@ function DiaryChannel({ title }: ViewProps) { hasNextPage, fetchNextPage, } = useInfinitePosts(nest); - const { mutateAsync: markRead, isLoading: isMarking } = useMarkReadMutation(); + const { markRead, isLoading: isMarking } = useMarkChannelRead(nest); const loadOlderNotes = useCallback( (atBottom: boolean) => { if (atBottom && hasNextPage) { @@ -133,10 +132,7 @@ function DiaryChannel({ title }: ViewProps) { useDismissChannelNotifications({ nest, - markRead: useCallback( - () => markRead({ nest: `diary/${chFlag}` }), - [markRead, chFlag] - ), + markRead, isMarking, }); diff --git a/apps/tlon-web/src/diary/DiaryNote.tsx b/apps/tlon-web/src/diary/DiaryNote.tsx index eb4fa8938f..5a9864f1c1 100644 --- a/apps/tlon-web/src/diary/DiaryNote.tsx +++ b/apps/tlon-web/src/diary/DiaryNote.tsx @@ -16,7 +16,7 @@ import { import getKindDataFromEssay from '@/logic/getKindData'; import { useBottomPadding } from '@/logic/position'; import { useGroupsAnalyticsEvent } from '@/logic/useAnalyticsEvent'; -import { getFlagParts, pluralize } from '@/logic/utils'; +import { getFlagParts, getMessageKey, pluralize } from '@/logic/utils'; import ReplyMessage from '@/replies/ReplyMessage'; import { groupReplies, setNewDaysForReplies } from '@/replies/replies'; import { @@ -25,7 +25,6 @@ import { usePerms, usePost, usePostsOnHost, - useUnread, } from '@/state/channel/channel'; import { useAmAdmin, @@ -35,6 +34,7 @@ import { useVessel, } from '@/state/groups/groups'; import { useDiaryCommentSortMode } from '@/state/settings'; +import { useUnread } from '@/state/unreads'; import { useConnectivityCheck } from '@/state/vitals'; import DiaryCommentField from './DiaryCommentField'; @@ -65,7 +65,7 @@ export default function DiaryNote({ title }: ViewProps) { const vessel = useVessel(groupFlag, window.our); const joined = useChannelIsJoined(nest); const isAdmin = useAmAdmin(groupFlag); - const unread = useUnread(nest); + const unread = useUnread(`channel/${nest}`); const sort = useDiaryCommentSortMode(chFlag); const perms = usePerms(nest); const { paddingBottom } = useBottomPadding(); @@ -161,8 +161,9 @@ export default function DiaryNote({ title }: ViewProps) { : []; const canWrite = canWriteChannel(perms, vessel, group?.bloc); const { title: noteTitle, image } = getKindDataFromEssay(note.essay); + const msgKey = getMessageKey(note); const groupedReplies = setNewDaysForReplies( - groupReplies(noteId, replyArray, unread).sort(([a], [b]) => { + groupReplies(msgKey, replyArray, unread).sort(([a], [b]) => { if (sort === 'asc') { return a.localeCompare(b); } diff --git a/apps/tlon-web/src/dms/DMOptions.tsx b/apps/tlon-web/src/dms/DMOptions.tsx index 099bd59302..ad3c5f7d6f 100644 --- a/apps/tlon-web/src/dms/DMOptions.tsx +++ b/apps/tlon-web/src/dms/DMOptions.tsx @@ -1,3 +1,4 @@ +import { getKey } from '@tloncorp/shared/dist/urbit/activity'; import cn from 'classnames'; import React, { PropsWithChildren, @@ -11,16 +12,15 @@ import { Link } from 'react-router-dom'; import { useChatStore } from '@/chat/useChatStore'; import ActionMenu, { Action } from '@/components/ActionMenu'; import Dialog from '@/components/Dialog'; -import BulletIcon from '@/components/icons/BulletIcon'; +import UnreadIndicator from '@/components/Sidebar/UnreadIndicator'; import EllipsisIcon from '@/components/icons/EllipsisIcon'; -import { useCheckChannelUnread } from '@/logic/channel'; +import { useMarkChannelRead } from '@/logic/channel'; import { useIsMobile } from '@/logic/useMedia'; import { useIsDmOrMultiDm, whomIsDm, whomIsMultiDm } from '@/logic/utils'; -import { useLeaveMutation, useMarkReadMutation } from '@/state/channel/channel'; +import { useLeaveMutation } from '@/state/channel/channel'; import { useArchiveDm, useDmRsvpMutation, - useIsDmUnread, useMarkDmReadMutation, useMutliDmRsvpMutation, } from '@/state/chat'; @@ -29,6 +29,7 @@ import { useDeletePinMutation, usePinnedChats, } from '@/state/pins'; +import { useUnread, useUnreadsStore } from '@/state/unreads'; import DmInviteDialog from './DmInviteDialog'; @@ -62,17 +63,16 @@ export default function DmOptions({ const navigate = useNavigate(); const isMobile = useIsMobile(); const pinned = usePinnedChats(); - const isUnread = useIsDmUnread(whom); - const isChannelUnread = useCheckChannelUnread(); + const chatUnread = useUnread(getKey(whom)); const isDMorMultiDm = useIsDmOrMultiDm(whom); - const hasActivity = - isUnread || pending || (!isDMorMultiDm && isChannelUnread(whom)); + const hasNotify = !!chatUnread?.notify; + const hasActivity = pending || chatUnread?.status === 'unread'; const { mutate: leaveChat } = useLeaveMutation(); const { mutateAsync: addPin } = useAddPinMutation(); const { mutateAsync: delPin } = useDeletePinMutation(); const { mutate: archiveDm } = useArchiveDm(); - const { mutate: markDmRead } = useMarkDmReadMutation(); - const { mutate: markChannelRead } = useMarkReadMutation(); + const { markRead: markReadChannel } = useMarkChannelRead(`chat/${whom}`); + const { markDmRead } = useMarkDmReadMutation(whom); const { mutate: multiDmRsvp } = useMutliDmRsvpMutation(); const { mutate: dmRsvp } = useDmRsvpMutation(); @@ -97,13 +97,13 @@ export default function DmOptions({ const markRead = useCallback(async () => { if (isDMorMultiDm) { - markDmRead({ whom }); + markDmRead(); } else { - markChannelRead({ nest: whom }); + markReadChannel(); } - useChatStore.getState().read(whom); - }, [whom, markDmRead, markChannelRead, isDMorMultiDm]); + useUnreadsStore.getState().read(getKey(whom)); + }, [whom, markReadChannel, markDmRead, isDMorMultiDm]); const [dialog, setDialog] = useState(false); @@ -156,12 +156,11 @@ export default function DmOptions({ if (!isHovered && !alwaysShowEllipsis && !isOpen) { return hasActivity ? ( - + ) : null; } @@ -241,16 +240,17 @@ export default function DmOptions({ ) : (
{!alwaysShowEllipsis && (isMobile || !isOpen) && hasActivity ? ( - ) : null} {!isMobile && (
), keepOpenOnClick: true, @@ -349,8 +348,12 @@ const GroupActions = React.memo( > {children || (
- {(isMobile || !isOpen) && hasActivity ? ( + {(isMobile || !isOpen) && + activity && + activity.combined.status !== 'read' ? ( @@ -359,7 +362,11 @@ const GroupActions = React.memo(
); diff --git a/apps/tlon-web/src/groups/Groups.tsx b/apps/tlon-web/src/groups/Groups.tsx index 8286af7299..4f9a89ab64 100644 --- a/apps/tlon-web/src/groups/Groups.tsx +++ b/apps/tlon-web/src/groups/Groups.tsx @@ -10,7 +10,6 @@ import useGroupPrivacy from '@/logic/useGroupPrivacy'; import { useIsMobile } from '@/logic/useMedia'; import useRecentChannel from '@/logic/useRecentChannel'; import { getFlagParts } from '@/logic/utils'; -import { useUnreads } from '@/state/channel/channel'; import { useGang, useGroup, @@ -21,6 +20,7 @@ import { useVessel, } from '@/state/groups/groups'; import { useNewGroupFlags, usePutEntryMutation } from '@/state/settings'; +import { useUnreads } from '@/state/unreads'; function Groups() { const navigate = useNavigate(); @@ -94,7 +94,7 @@ function Groups() { const allUnreads = _.mapKeys(unreads, (k, v) => k); const channel = Object.entries(group.channels).find( - ([nest]) => nest in allUnreads + ([nest]) => `channel/${nest}` in allUnreads ); canRead = channel && canReadChannel(channel[1], vessel, group?.bloc); diff --git a/apps/tlon-web/src/heap/HeapChannel.tsx b/apps/tlon-web/src/heap/HeapChannel.tsx index 0c3729d846..6b28f88d4e 100644 --- a/apps/tlon-web/src/heap/HeapChannel.tsx +++ b/apps/tlon-web/src/heap/HeapChannel.tsx @@ -14,11 +14,11 @@ import { PASTEABLE_MEDIA_TYPES } from '@/constants'; import HeapBlock from '@/heap/HeapBlock'; import HeapRow from '@/heap/HeapRow'; import { useDragAndDrop } from '@/logic/DragAndDropContext'; -import { useFullChannel } from '@/logic/channel'; +import { useFullChannel, useMarkChannelRead } from '@/logic/channel'; import getKindDataFromEssay from '@/logic/getKindData'; import useDismissChannelNotifications from '@/logic/useDismissChannelNotifications'; import { useIsMobile } from '@/logic/useMedia'; -import { useInfinitePosts, useMarkReadMutation } from '@/state/channel/channel'; +import { useInfinitePosts } from '@/state/channel/channel'; import { useRouteGroup } from '@/state/groups/groups'; import { useHeapDisplayMode, useHeapSortMode } from '@/state/settings'; import { useUploader } from '@/state/storage'; @@ -48,7 +48,7 @@ function HeapChannel({ title }: ViewProps) { const sortMode = useHeapSortMode(chFlag); const { posts, fetchNextPage, hasNextPage, isLoading } = useInfinitePosts(nest); - const { mutateAsync: markRead, isLoading: isMarking } = useMarkReadMutation(); + const { markRead, isLoading: isMarking } = useMarkChannelRead(nest); const dropZoneId = useMemo(() => `new-curio-input-${chFlag}`, [chFlag]); const { isDragging, isOver, droppedFiles, setDroppedFiles } = @@ -63,7 +63,7 @@ function HeapChannel({ title }: ViewProps) { useDismissChannelNotifications({ nest, - markRead: useCallback(() => markRead({ nest }), [markRead, nest]), + markRead, isMarking, }); diff --git a/apps/tlon-web/src/heap/HeapDetail.tsx b/apps/tlon-web/src/heap/HeapDetail.tsx index decfd7b06d..329cf6b0dc 100644 --- a/apps/tlon-web/src/heap/HeapDetail.tsx +++ b/apps/tlon-web/src/heap/HeapDetail.tsx @@ -15,6 +15,7 @@ import getKindDataFromEssay from '@/logic/getKindData'; import { useBottomPadding } from '@/logic/position'; import { useGroupsAnalyticsEvent } from '@/logic/useAnalyticsEvent'; import { useIsMobile } from '@/logic/useMedia'; +import { getMessageKey } from '@/logic/utils'; import { useIsPostUndelivered, useOrderedPosts, @@ -53,6 +54,7 @@ export default function HeapDetail({ title }: ViewProps) { const { title: curioTitle } = getKindDataFromEssay(note?.essay); const { paddingBottom } = useBottomPadding(); const essay = note?.essay || initialNote?.essay; + const msgKey = note ? getMessageKey(note) : { id: '', time: '' }; const curioHref = (id?: bigInt.BigInteger) => { if (!id) { @@ -144,7 +146,7 @@ export default function HeapDetail({ title }: ViewProps) { {idTime && !isUndelivered && ( diff --git a/apps/tlon-web/src/heap/HeapDetail/HeapDetailSidebar/HeapDetailComments.tsx b/apps/tlon-web/src/heap/HeapDetail/HeapDetailSidebar/HeapDetailComments.tsx index 56c9fd158c..ac5846329a 100644 --- a/apps/tlon-web/src/heap/HeapDetail/HeapDetailSidebar/HeapDetailComments.tsx +++ b/apps/tlon-web/src/heap/HeapDetail/HeapDetailSidebar/HeapDetailComments.tsx @@ -1,3 +1,4 @@ +import { MessageKey } from '@tloncorp/shared/dist/urbit/activity'; import { ReplyTuple } from '@tloncorp/shared/dist/urbit/channel'; import bigInt from 'big-integer'; import { useMemo } from 'react'; @@ -8,20 +9,21 @@ import LoadingSpinner from '@/components/LoadingSpinner/LoadingSpinner'; import { canWriteChannel, useChannelFlag } from '@/logic/channel'; import ReplyMessage from '@/replies/ReplyMessage'; import { groupReplies, setNewDaysForReplies } from '@/replies/replies'; -import { usePerms, useUnread } from '@/state/channel/channel'; +import { usePerms } from '@/state/channel/channel'; import { useGroup, useRouteGroup, useVessel } from '@/state/groups/groups'; import { useDiaryCommentSortMode } from '@/state/settings'; +import { useUnread } from '@/state/unreads'; import HeapDetailCommentField from './HeapDetailCommentField'; interface HeapDetailCommentsProps { - time: string; + parent: MessageKey; comments: ReplyTuple[] | null; loading: boolean; } export default function HeapDetailComments({ - time, + parent, comments, loading, }: HeapDetailCommentsProps) { @@ -38,7 +40,7 @@ export default function HeapDetailComments({ const sort = useDiaryCommentSortMode(chFlag ?? ''); const vessel = useVessel(groupFlag, window.our); const canWrite = canWriteChannel(perms, vessel, group?.bloc); - const unread = useUnread(nest); + const unread = useUnread(`thread/${nest}/${parent.id}`); const sortedComments = comments?.sort(([a], [b]) => { if (sort === 'asc') { @@ -49,7 +51,7 @@ export default function HeapDetailComments({ const groupedReplies = !comments ? [] : setNewDaysForReplies( - groupReplies(time, sortedComments, unread).sort(([a], [b]) => { + groupReplies(parent, sortedComments, unread).sort(([a], [b]) => { if (sort === 'asc') { return a.localeCompare(b); } diff --git a/apps/tlon-web/src/logic/channel.ts b/apps/tlon-web/src/logic/channel.ts index 4e1cce9ad4..fa7989143d 100644 --- a/apps/tlon-web/src/logic/channel.ts +++ b/apps/tlon-web/src/logic/channel.ts @@ -1,4 +1,9 @@ -import { Perm, Story, Unreads } from '@tloncorp/shared/dist/urbit/channel'; +import { + Activity, + MessageKey, + Source, +} from '@tloncorp/shared/dist/urbit/activity'; +import { Perm, Story } from '@tloncorp/shared/dist/urbit/channel'; import { isLink } from '@tloncorp/shared/dist/urbit/content'; import { Channels, @@ -9,7 +14,7 @@ import { } from '@tloncorp/shared/dist/urbit/groups'; import _, { get, groupBy } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router'; +import { useParams } from 'react-router'; import { ChatStore, useChatStore } from '@/chat/useChatStore'; import { useNavWithinTab } from '@/components/Sidebar/util'; @@ -19,15 +24,17 @@ import { RECENT_SORT, SortMode, } from '@/constants'; -import { - useChannel, - useJoinMutation, - usePerms, - useUnreads, -} from '@/state/channel/channel'; +import { useMarkReadMutation } from '@/state/activity'; +import { useChannel, useJoinMutation, usePerms } from '@/state/channel/channel'; import { useGroup, useRouteGroup } from '@/state/groups'; import { useLastReconnect } from '@/state/local'; import { useNegotiate } from '@/state/negotiation'; +import { + Unread, + UnreadsStore, + useUnreads, + useUnreadsStore, +} from '@/state/unreads'; import useRecentChannel from './useRecentChannel'; import useSidebarSort, { @@ -37,12 +44,12 @@ import useSidebarSort, { } from './useSidebarSort'; import { getFirstInline, getFlagParts, getNestShip, nestToFlag } from './utils'; -export function isChannelJoined(nest: string, unreads: Unreads) { +export function isChannelJoined(nest: string, unreads: Record) { const [flag] = nestToFlag(nest); const { ship } = getFlagParts(flag); const isChannelHost = window.our === ship; - return isChannelHost || (nest && nest in unreads); + return isChannelHost || (nest && `channel/${nest}` in unreads); } export function canReadChannel( @@ -98,44 +105,45 @@ export function useChannelFlag() { ); } -const selChats = (s: ChatStore) => s.chats; - -function channelUnread( - nest: string, - unreads: Unreads, - chats: ChatStore['chats'] -) { - const [app, flag] = nestToFlag(nest); - const unread = chats[flag]?.unread; - - if (app === 'chat') { - return Boolean(unread && !unread.seen); +function channelUnread(nest: string, unreads: Record) { + const unread = unreads[`channel/${nest}`]; + if (!unread) { + return false; } - return (unreads[nest]?.count ?? 0) > 0; + return unread.status === 'unread'; } export function useCheckChannelUnread() { const unreads = useUnreads(); - const chats = useChatStore(selChats); + const isChannelUnread = useCallback( + (nest: string) => { + return channelUnread(nest, unreads); + }, + [unreads] + ); - return useCallback( + const getUnread = useCallback( (nest: string) => { - if (!unreads || !chats) { - return false; + if (!unreads) { + return null; } - return channelUnread(nest, unreads, chats); + return unreads[`channel/${nest}`]; }, - [unreads, chats] + [unreads] ); + + return { + isChannelUnread, + getUnread, + }; } export function useIsChannelUnread(nest: string) { const unreads = useUnreads(); - const chats = useChatStore(selChats); - return channelUnread(nest, unreads, chats); + return channelUnread(nest, unreads); } export const useIsChannelHost = (flag: string) => @@ -244,18 +252,21 @@ export function useChannelSections(groupFlag: string) { }; } +const getLoaded = (s: UnreadsStore) => s.loaded; export function useChannelIsJoined(nest: string) { + const loaded = useUnreadsStore(getLoaded); const unreads = useUnreads(); - return isChannelJoined(nest, unreads); + return !loaded || isChannelJoined(nest, unreads); } export function useCheckChannelJoined() { + const loaded = useUnreadsStore(getLoaded); const unreads = useUnreads(); return useCallback( (nest: string) => { - return isChannelJoined(nest, unreads); + return !loaded || isChannelJoined(nest, unreads); }, - [unreads] + [unreads, loaded] ); } @@ -391,3 +402,22 @@ export function linkUrlFromContent(content: Story) { return undefined; } + +export function useMarkChannelRead(nest: string, thread?: MessageKey) { + const group = useRouteGroup(); + const { mutateAsync, ...rest } = useMarkReadMutation(); + const markRead = useCallback(() => { + const source: Source = thread + ? { + thread: { + group, + channel: nest, + key: thread, + }, + } + : { channel: { group, nest } }; + return mutateAsync({ source }); + }, [group, nest, thread, mutateAsync]); + + return { markRead, ...rest }; +} diff --git a/apps/tlon-web/src/logic/useGroupSort.ts b/apps/tlon-web/src/logic/useGroupSort.ts index cd762652e2..f944e16954 100644 --- a/apps/tlon-web/src/logic/useGroupSort.ts +++ b/apps/tlon-web/src/logic/useGroupSort.ts @@ -32,15 +32,7 @@ export default function useGroupSort() { const accessors: Record string> = { [ALPHABETICAL_SORT]: (_flag: string, group: Group) => get(group, 'meta.title'), - [RECENT_SORT]: (flag: string, group: Group) => { - /** - * Use the latest channel flag associated with the Group; otherwise - * fallback to the Group flag itself, which won't be in the briefs and - * thus use INFINITY by default - */ - const channels = sortChannels(group.channels); - return channels.length > 0 ? channels[0][0] : flag; - }, + [RECENT_SORT]: (flag: string, group: Group) => `group/${flag}`, }; return sortRecordsBy( diff --git a/apps/tlon-web/src/logic/useIsGroupUnread.ts b/apps/tlon-web/src/logic/useIsGroupUnread.ts index 66bfd95ca0..cc034eac79 100644 --- a/apps/tlon-web/src/logic/useIsGroupUnread.ts +++ b/apps/tlon-web/src/logic/useIsGroupUnread.ts @@ -1,40 +1,19 @@ import { useCallback } from 'react'; -import { useGroups } from '@/state/groups'; +import { Unread, emptyUnread, useUnreads } from '@/state/unreads'; -import { useCheckChannelUnread } from './channel'; +const defaultUnread = emptyUnread(); -export default function useIsGroupUnread() { - const groups = useGroups(); - const isChannelUnread = useCheckChannelUnread(); - - /** - * A Group is unread if - * - any of it's Channels have new items in their corresponding unreads - * - any of its Channels are unread (bin is unread, rope channel matches - * chFlag) - */ - const isGroupUnread = useCallback( - (flag: string) => { - const group = groups[flag]; - const chNests = group ? Object.keys(group.channels) : []; - - return chNests.reduce( - (memo, nest) => memo || isChannelUnread(nest), - false - ); +export default function useGroupUnread() { + const unreads = useUnreads(); + const getGroupUnread = useCallback( + (flag: string): Unread => { + return unreads?.[`group/${flag}`] || defaultUnread; }, - [groups, isChannelUnread] + [unreads] ); return { - isGroupUnread, + getGroupUnread, }; } - -export function useIsAnyGroupUnread() { - const groups = useGroups(); - const { isGroupUnread } = useIsGroupUnread(); - if (!groups) return undefined; - return Object.keys(groups).some((flag) => isGroupUnread(flag)); -} diff --git a/apps/tlon-web/src/logic/useMessageSelector.ts b/apps/tlon-web/src/logic/useMessageSelector.ts index 5ecec173fe..a7d80c7127 100644 --- a/apps/tlon-web/src/logic/useMessageSelector.ts +++ b/apps/tlon-web/src/logic/useMessageSelector.ts @@ -8,7 +8,6 @@ import { ShipOption } from '@/components/ShipSelector'; import { SendMessageVariables, useCreateMultiDm, - useDmUnreads, useMultiDms, useSendMessage, } from '@/state/chat'; @@ -16,6 +15,7 @@ import { useForceNegotiationUpdate, useNegotiateMulti, } from '@/state/negotiation'; +import { useUnreads } from '@/state/unreads'; import { createStorageKey, newUv } from './utils'; @@ -30,7 +30,7 @@ export default function useMessageSelector() { const isMultiDm = ships.length > 1; const shipValues = useMemo(() => ships.map((o) => o.value), [ships]); const multiDms = useMultiDms(); - const { data: unreads } = useDmUnreads(); + const unreads = useUnreads(); const { mutate: sendMessage } = useSendMessage(); const { mutateAsync: createMultiDm } = useCreateMultiDm(); @@ -52,9 +52,9 @@ export default function useMessageSelector() { } return ( - Object.entries(unreads).find(([flag, _unread]) => { + Object.entries(unreads).find(([source, _unread]) => { const theShip = ships[0].value; - const sameDM = theShip === flag; + const sameDM = `ship/${theShip}` === source; return sameDM; })?.[0] ?? null ); @@ -74,8 +74,8 @@ export default function useMessageSelector() { const sameDM = difference(shipValues, theShips).length === 0 && shipValues.length === theShips.length; - const unread = unreads[key]; - const newUnread = unreads[k]; + const unread = unreads[`club/${key}`]; + const newUnread = unreads[`club/${k}`]; const newer = !unread || (unread && newUnread && newUnread.recency > unread.recency); if (sameDM && newer) { diff --git a/apps/tlon-web/src/logic/useMessageSort.ts b/apps/tlon-web/src/logic/useMessageSort.ts index bc484a6bf3..c7fe5b4f19 100644 --- a/apps/tlon-web/src/logic/useMessageSort.ts +++ b/apps/tlon-web/src/logic/useMessageSort.ts @@ -1,7 +1,5 @@ -import { Unread } from '@tloncorp/shared/dist/urbit/channel'; -import { DMUnread } from '@tloncorp/shared/dist/urbit/dms'; - import { RECENT_SORT } from '@/constants'; +import { Unread } from '@/state/unreads'; import useSidebarSort, { Sorter, useRecentSort } from './useSidebarSort'; @@ -15,12 +13,9 @@ export default function useMessageSort() { sortOptions, }); - function sortMessages(unreads: Record) { - const accessors: Record< - string, - (k: string, v: Unread | DMUnread) => string - > = { - [RECENT_SORT]: (flag: string, _unread: Unread | DMUnread) => flag, + function sortMessages(unreads: Record) { + const accessors: Record string> = { + [RECENT_SORT]: (flag: string, _unread: Unread) => flag, }; return sortRecordsBy( diff --git a/apps/tlon-web/src/logic/useReactQueryScry.ts b/apps/tlon-web/src/logic/useReactQueryScry.ts index 5bcd640e59..c91e68531a 100644 --- a/apps/tlon-web/src/logic/useReactQueryScry.ts +++ b/apps/tlon-web/src/logic/useReactQueryScry.ts @@ -8,25 +8,27 @@ export default function useReactQueryScry({ queryKey, app, path, + onScry, priority = 3, options, }: { queryKey: QueryKey; app: string; path: string; + onScry?: (data: T) => T; priority?: number; options?: UseQueryOptions; }) { const fetchData = useCallback( async () => - useSchedulerStore.getState().wait( - async () => - api.scry({ - app, - path, - }), - priority - ), + useSchedulerStore.getState().wait(async () => { + const result = await api.scry({ + app, + path, + }); + + return onScry ? onScry(result) : result; + }, priority), [app, path, priority] ); diff --git a/apps/tlon-web/src/logic/useReactQuerySubscription.tsx b/apps/tlon-web/src/logic/useReactQuerySubscription.tsx index 77a16949c0..30d9c56edb 100644 --- a/apps/tlon-web/src/logic/useReactQuerySubscription.tsx +++ b/apps/tlon-web/src/logic/useReactQuerySubscription.tsx @@ -18,6 +18,7 @@ export default function useReactQuerySubscription({ scryApp = app, priority = 3, onEvent, + onScry, options, }: { queryKey: QueryKey; @@ -27,6 +28,7 @@ export default function useReactQuerySubscription({ scryApp?: string; priority?: number; onEvent?: (data: Event) => void; + onScry?: (data: T) => T; options?: UseQueryOptions; }) { const queryClient = useQueryClient(); @@ -42,11 +44,12 @@ export default function useReactQuerySubscription({ const fetchData = async () => useSchedulerStore.getState().wait(async () => { - console.log('scrying', scryApp, scry); - return api.scry({ + const result = await api.scry({ app: scryApp, path: scry, }); + + return onScry ? onScry(result) : result; }, priority); useEffect(() => { diff --git a/apps/tlon-web/src/logic/useScrollerMessages.ts b/apps/tlon-web/src/logic/useScrollerMessages.ts index 8d3bee2d89..3cc809f0b7 100644 --- a/apps/tlon-web/src/logic/useScrollerMessages.ts +++ b/apps/tlon-web/src/logic/useScrollerMessages.ts @@ -1,3 +1,4 @@ +import { MessageKey } from '@tloncorp/shared/dist/urbit/activity'; import { Post, Reply } from '@tloncorp/shared/dist/urbit/channel'; import { Writ } from '@tloncorp/shared/dist/urbit/dms'; import { daToUnix } from '@urbit/api'; @@ -79,7 +80,7 @@ const emptyWrit = { }, }; -export type ChatMessageListItemData = { +export type MessageListItemData = { writ: Writ | Post | Reply; type: 'message'; time: bigInt.BigInteger; @@ -89,13 +90,16 @@ export type ChatMessageListItemData = { isLast: boolean; isLinked: boolean; hideReplies: boolean; + parent?: MessageKey; }; function useMessageItems({ messages, + parent, }: { scrollTo?: bigInt.BigInteger; messages: WritArray; + parent?: MessageKey; }): [ bigInt.BigInteger[], { @@ -138,6 +142,7 @@ function useMessageItems({ time: key, newAuthor, newDay, + parent, }; } @@ -163,28 +168,34 @@ function useMessageItems({ export function useMessageData({ whom, scrollTo, + parent, messages, replying, }: { whom: string; + parent?: MessageKey; scrollTo?: bigInt.BigInteger; messages: WritArray; replying: boolean; }) { const [activeMessageKeys, messageEntries, activeMessages] = useMessageItems({ messages, + parent, }); - const activeMessageEntries: ChatMessageListItemData[] = useMemo( + const activeMessageEntries: MessageListItemData[] = useMemo( () => - messageEntries.map((props, index) => ({ - type: 'message', - whom, - isLast: index === messageEntries.length - 1, - isLinked: !!scrollTo && (props.time?.eq(scrollTo) ?? false), - hideReplies: replying, - ...props, - })), + messageEntries.map( + (props, index) => + ({ + type: 'message', + whom, + isLast: index === messageEntries.length - 1, + isLinked: !!scrollTo && (props.time?.eq(scrollTo) ?? false), + hideReplies: replying, + ...props, + }) as MessageListItemData + ), [whom, scrollTo, messageEntries, replying] ); diff --git a/apps/tlon-web/src/logic/useSidebarSort.ts b/apps/tlon-web/src/logic/useSidebarSort.ts index 36107b9485..79d1ea0541 100644 --- a/apps/tlon-web/src/logic/useSidebarSort.ts +++ b/apps/tlon-web/src/logic/useSidebarSort.ts @@ -1,17 +1,13 @@ -import { Unreads } from '@tloncorp/shared/dist/urbit/channel'; -import { DMUnreads } from '@tloncorp/shared/dist/urbit/dms'; +import _ from 'lodash'; import { useCallback, useMemo } from 'react'; import { DEFAULT_SORT, RECENT_SORT, SortMode } from '@/constants'; -import { useUnreads } from '@/state/channel/channel'; -import { useDmUnreads } from '@/state/chat'; import { useGroupSideBarSort, usePutEntryMutation, useSideBarSortMode, } from '@/state/settings'; - -import { whomIsDm, whomIsMultiDm } from './utils'; +import { useUnreads } from '@/state/unreads'; export interface Sorter { (a: string, b: string): number; @@ -25,51 +21,20 @@ interface UseSidebarSort { export const sortAlphabetical = (aNest: string, bNest: string) => aNest.localeCompare(bNest); -interface MergeUnreadsAccumulatorType { - [key: string]: number; -} - export function useRecentSort() { - const channelUnreads = useUnreads(); - const { data: dmUnreads } = useDmUnreads(); - // pre-compute unreads before sorting - const processedUnreads = useMemo(() => { - const mergeUnreads = (unreads: DMUnreads | Unreads) => - Object.entries(unreads).reduce( - (acc, [nest, { recency }]) => { - // using a param re-assign is much faster than making a copy of an object. - // eslint-disable-next-line no-param-reassign - acc[nest] = recency ?? Number.NEGATIVE_INFINITY; - return acc; - }, - {} - ); - - return { - dmUnreads: mergeUnreads(dmUnreads), - channelUnreads: mergeUnreads(channelUnreads), - }; - }, [dmUnreads, channelUnreads]); + const unreads = useUnreads(); + const recencyMap = useMemo(() => { + return _.mapValues(unreads, ({ recency }) => recency); + }, [unreads]); const sortRecent = useCallback( - (aNest: string, bNest: string) => { - const aUnreads = - whomIsDm(aNest) || whomIsMultiDm(aNest) - ? processedUnreads.dmUnreads - : processedUnreads.channelUnreads; - // if the nest is not in the unreads, default to negative infinity - const aLast = aUnreads[aNest] ?? Number.NEGATIVE_INFINITY; - - const bUnreads = - whomIsDm(bNest) || whomIsMultiDm(bNest) - ? processedUnreads.dmUnreads - : processedUnreads.channelUnreads; - // if the nest is not in the unreads, default to negative infinity - const bLast = bUnreads[bNest] ?? Number.NEGATIVE_INFINITY; + (aIndex: string, bIndex: string) => { + const aLast = recencyMap[aIndex] ?? Number.NEGATIVE_INFINITY; + const bLast = recencyMap[bIndex] ?? Number.NEGATIVE_INFINITY; return Math.sign(aLast - bLast); }, - [processedUnreads] + [recencyMap] ); return sortRecent; diff --git a/apps/tlon-web/src/logic/utils.ts b/apps/tlon-web/src/logic/utils.ts index a4a74536df..e2cd38f329 100644 --- a/apps/tlon-web/src/logic/utils.ts +++ b/apps/tlon-web/src/logic/utils.ts @@ -1,8 +1,10 @@ +import { MessageKey } from '@tloncorp/shared/dist/urbit/activity'; import { CacheId, ChatStory, Cite, Listing, + Post, Story, Verse, VerseBlock, @@ -32,9 +34,8 @@ import { DocketHref, Treaty, udToDec, - unixToDa, } from '@urbit/api'; -import { formatUv } from '@urbit/aura'; +import { formatUd, formatUv, unixToDa } from '@urbit/aura'; import anyAscii from 'any-ascii'; import bigInt, { BigInteger } from 'big-integer'; import { hsla, parseToHsla, parseToRgba } from 'color2k'; @@ -103,7 +104,7 @@ export function useObjectChangeLogging( const lastValues = useRef(o); Object.entries(o).forEach(([k, v]) => { if (v !== lastValues.current[k]) { - logger.log('[change]', k); + logger.log('[change]', k, 'old:', lastValues.current[k], 'new:', v); lastValues.current[k] = v; } }); @@ -1296,3 +1297,10 @@ export function cacheIdFromString(str: string): CacheId { sent: parseInt(udToDec(sentStr), 10), }; } + +export function getMessageKey(post: Post): MessageKey { + return { + id: `${post.essay.author}/${formatUd(unixToDa(post.essay.sent))}`, + time: formatUd(bigInt(post.seal.id)), + }; +} diff --git a/apps/tlon-web/src/mocks/chat.ts b/apps/tlon-web/src/mocks/chat.ts index 7d03dae717..06d35b7406 100644 --- a/apps/tlon-web/src/mocks/chat.ts +++ b/apps/tlon-web/src/mocks/chat.ts @@ -1,11 +1,11 @@ import { faker } from '@faker-js/faker'; +import { Activity } from '@tloncorp/shared/dist/urbit/activity'; import { Post, Posts, Story, storyFromChatStory, } from '@tloncorp/shared/dist/urbit/channel'; -import { DMUnreads } from '@tloncorp/shared/dist/urbit/dms'; import { decToUd, unixToDa } from '@urbit/api'; import { subDays, subMinutes } from 'date-fns'; import _ from 'lodash'; @@ -135,54 +135,62 @@ export const makeFakeChatWrits = (offset: number) => { export const chatKeys = ['~zod/test']; -export const dmList: DMUnreads = { +export const dmList: Activity = { '~fabled-faster': { recency: 0, count: 0, + notify: false, unread: null, - threads: {}, + children: [], }, '~nocsyx-lassul': { recency: 1652302200000, count: 3, + notify: false, unread: null, - threads: {}, + children: [], }, '~fallyn-balfus': { recency: 0, count: 0, + notify: false, unread: null, - threads: {}, + children: [], }, '~finned-palmer': { recency: 1652302200000, count: 2, + notify: false, unread: null, - threads: {}, + children: [], }, '~datder-sonnet': { recency: 1652302200000, count: 1, + notify: false, unread: null, - threads: {}, + children: [], }, '~hastuc-dibtux': { recency: 0, count: 0, + notify: false, unread: null, - threads: {}, + children: [], }, '~rilfun-lidlen': { recency: 0, count: 0, + notify: false, unread: null, - threads: {}, + children: [], }, '~mister-dister-dozzod-dozzod': { recency: 0, count: 0, + notify: false, unread: null, - threads: {}, + children: [], }, }; diff --git a/apps/tlon-web/src/mocks/handlers.ts b/apps/tlon-web/src/mocks/handlers.ts index 4e98ce8779..b28b5f8286 100644 --- a/apps/tlon-web/src/mocks/handlers.ts +++ b/apps/tlon-web/src/mocks/handlers.ts @@ -429,8 +429,9 @@ const dms: Handler[] = [ const unread = { recency: 1652302200000, count: 1, + notify: false, unread: null, - threads: {}, + children: [], }; dmList[req.json.ship] = unread; diff --git a/apps/tlon-web/src/replies/ReplyMessage.tsx b/apps/tlon-web/src/replies/ReplyMessage.tsx index 061ac81bc4..0514dba570 100644 --- a/apps/tlon-web/src/replies/ReplyMessage.tsx +++ b/apps/tlon-web/src/replies/ReplyMessage.tsx @@ -1,14 +1,15 @@ import { Editor } from '@tiptap/core'; +import { MessageKey } from '@tloncorp/shared/dist/urbit'; +import { getThreadKey } from '@tloncorp/shared/dist/urbit/activity'; import { Reply, Story, - Unread, constructStory, emptyReply, } from '@tloncorp/shared/dist/urbit/channel'; -import { DMUnread } from '@tloncorp/shared/dist/urbit/dms'; import { daToUnix } from '@urbit/api'; -import { BigInteger } from 'big-integer'; +import { formatUd, unixToDa } from '@urbit/aura'; +import bigInt, { BigInteger } from 'big-integer'; import cn from 'classnames'; import { format } from 'date-fns'; import debounce from 'lodash/debounce'; @@ -36,14 +37,14 @@ import { import MessageEditor, { useMessageEditor } from '@/components/MessageEditor'; import CheckIcon from '@/components/icons/CheckIcon'; import DoubleCaretRightIcon from '@/components/icons/DoubleCaretRightIcon'; +import { useMarkChannelRead } from '@/logic/channel'; import { JSONToInlines, diaryMixedToJSON } from '@/logic/tiptap'; import useLongPress from '@/logic/useLongPress'; import { useIsMobile } from '@/logic/useMedia'; -import { nestToFlag, useIsDmOrMultiDm, whomIsNest } from '@/logic/utils'; +import { useIsDmOrMultiDm, whomIsFlag, whomIsNest } from '@/logic/utils'; import { useEditReplyMutation, useIsEdited, - useMarkReadMutation, usePostToggler, useTrackedPostStatus, } from '@/state/channel/channel'; @@ -51,13 +52,16 @@ import { useMarkDmReadMutation, useMessageToggler, useTrackedMessageStatus, + useWrit, } from '@/state/chat'; +import { useUnread, useUnreadsStore } from '@/state/unreads'; import ReplyMessageOptions from './ReplyMessageOptions'; import ReplyReactions from './ReplyReactions/ReplyReactions'; export interface ReplyMessageProps { whom: string; + parent: MessageKey; time: BigInteger; reply: Reply; newAuthor?: boolean; @@ -69,19 +73,6 @@ export interface ReplyMessageProps { showReply?: boolean; } -function amUnread(unread?: Unread | DMUnread, parent?: string, id?: string) { - if (!unread || !parent || !id) { - return false; - } - - const thread = unread.threads[parent]; - if (typeof thread === 'object') { - return thread.id === id; - } - - return thread === id; -} - const mergeRefs = (...refs: any[]) => (node: any) => { @@ -111,6 +102,7 @@ const ReplyMessage = React.memo< ( { whom, + parent, time, reply, newAuthor = false, @@ -134,14 +126,25 @@ const ReplyMessage = React.memo< const isThreadOp = seal['parent-id'] === seal.id; const isMobile = useIsMobile(); const isThreadOnMobile = isMobile; - const chatInfo = useChatInfo(whom); + const id = !whomIsFlag(whom) + ? seal.id + : `${memo.author}/${formatUd(bigInt(seal.id))}`; + const threadKey = getThreadKey( + whom, + !whomIsFlag(whom) ? seal.id : parent.time + ); + const chatInfo = useChatInfo(`${whom}/${parent.id}`); + const unread = useUnread(threadKey); const isDMOrMultiDM = useIsDmOrMultiDm(whom); - const unread = chatInfo?.unread; - const isUnread = amUnread(unread?.unread, seal['parent-id'], seal.id); + const isUnread = + unread?.status !== 'read' && unread?.lastUnread?.id === id; const { hovering, setHovering } = useChatHovering(whom, seal.id); const { open: pickerOpen } = useChatDialog(whom, seal.id, 'picker'); - const { mutate: markChatRead } = useMarkReadMutation(); - const { mutate: markDmRead } = useMarkDmReadMutation(); + const { markRead: markChannelRead } = useMarkChannelRead( + `chat/${whom}`, + parent + ); + const { markDmRead } = useMarkDmReadMutation(whom, parent); const { mutate: editReply } = useEditReplyMutation(); const { isHidden: isMessageHidden } = useMessageToggler(seal.id); const { isHidden: isPostHidden } = usePostToggler(seal.id); @@ -158,14 +161,14 @@ const ReplyMessage = React.memo< return; } - const { unread: brief, seen } = unread; + const unseen = unread.status === 'unread'; /* the first fire of this function which we don't to do anything with. */ - if (!inView && !seen) { + if (!inView && unseen) { return; } - const { seen: markSeen, delayedRead } = useChatStore.getState(); + const { seen: markSeen, delayedRead } = useUnreadsStore.getState(); /* once the unseen marker comes into view we need to mark it as seen and start a timer to mark it read so it goes away. @@ -173,18 +176,18 @@ const ReplyMessage = React.memo< doing so. we don't want to accidentally clear unreads when the state has changed */ - if (inView && isUnread && !seen) { - markSeen(whom); - delayedRead(whom, () => { + if (inView && isUnread && unseen) { + markSeen(threadKey); + delayedRead(threadKey, () => { if (isDMOrMultiDM) { - markDmRead({ whom }); + markDmRead(); } else { - markChatRead({ nest: `chat/${whom}` }); + markChannelRead(); } }); } }, - [unread, whom, isDMOrMultiDM, markChatRead, markDmRead, isUnread] + [unread, whom, isDMOrMultiDM, markChannelRead, markDmRead, isUnread] ), }); @@ -345,11 +348,7 @@ const ReplyMessage = React.memo< {...handlers} > {unread && isUnread ? ( - + ) : null} {newDay && !isUnread ? : null} {newAuthor ? ( diff --git a/apps/tlon-web/src/replies/replies.ts b/apps/tlon-web/src/replies/replies.ts index 57a56dcf9f..d068e734f1 100644 --- a/apps/tlon-web/src/replies/replies.ts +++ b/apps/tlon-web/src/replies/replies.ts @@ -1,16 +1,15 @@ -import { - Kind, - Reply, - ReplyTuple, - Unread, -} from '@tloncorp/shared/dist/urbit/channel'; -import { daToUnix } from '@urbit/aura'; +import { MessageKey } from '@tloncorp/shared/dist/urbit/activity'; +import { Kind, Reply, ReplyTuple } from '@tloncorp/shared/dist/urbit/channel'; +import { daToUnix, parseUd } from '@urbit/aura'; import bigInt, { BigInteger } from 'big-integer'; import { isSameDay } from 'date-fns'; +import { Unread } from '@/state/unreads'; + export interface ReplyProps { han: Kind; noteId: string; + parent: MessageKey; time: BigInteger; reply: Reply; newAuthor: boolean; @@ -43,9 +42,9 @@ export function setNewDaysForReplies( } export function groupReplies( - noteId: string, + parent: MessageKey, replies: ReplyTuple[], - unread: Unread + unread?: Unread ) { const grouped: Record = {}; let currentTime: string; @@ -61,7 +60,7 @@ export function groupReplies( const newAuthor = prev && prev[1] !== null ? author !== prev[1].memo.author : true; const unreadUnread = - unread && unread.unread?.id === q.seal.id ? unread : undefined; + unread && unread.lastUnread?.id === q.seal.id ? unread : undefined; if (newAuthor) { currentTime = time; @@ -74,11 +73,12 @@ export function groupReplies( grouped[currentTime].push({ han: 'diary', time: t, + parent, reply: q, newAuthor, - noteId, + noteId: parseUd(parent.time).toString(), newDay: false, - unreadCount: unreadUnread && unread.count, + unreadCount: (unreadUnread && unread?.count) || 0, }); }); diff --git a/apps/tlon-web/src/state/activity.ts b/apps/tlon-web/src/state/activity.ts new file mode 100644 index 0000000000..da77603b23 --- /dev/null +++ b/apps/tlon-web/src/state/activity.ts @@ -0,0 +1,264 @@ +import { useMutation } from '@tanstack/react-query'; +import { + Activity, + ActivityAction, + ActivityDeleteUpdate, + ActivityReadUpdate, + ActivitySummary, + ActivityUpdate, + ActivityVolumeUpdate, + ReadAction, + Source, + VolumeMap, + VolumeSettings, + sourceToString, + stripPrefixes, +} from '@tloncorp/shared/dist/urbit/activity'; +import _ from 'lodash'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import api from '@/api'; +import { useChatStore } from '@/chat/useChatStore'; +import useReactQueryScry from '@/logic/useReactQueryScry'; +import { createDevLogger, nestToFlag } from '@/logic/utils'; +import queryClient from '@/queryClient'; + +import { useUnreadsStore } from './unreads'; + +const actLogger = createDevLogger('activity', false); + +export const unreadsKey = ['activity', 'unreads']; +export const volumeKey = ['activity', 'volume']; + +export function activityAction(action: ActivityAction) { + return { + app: 'activity', + mark: 'activity-action', + json: action, + }; +} + +function activityReadUpdates(events: ActivityReadUpdate[]) { + const unreads: Record = {}; + + events.forEach((event) => { + const { source, activity } = event.read; + if ('base' in source) { + return; + } + + unreads[sourceToString(source)] = activity; + }); + + return unreads; +} + +function activityVolumeUpdates(events: ActivityVolumeUpdate[]) { + return events.reduce((acc, event) => { + const { source, volume } = event.adjust; + if (volume === null) { + return acc; + } + + // eslint-disable-next-line no-param-reassign + acc[sourceToString(source)] = volume; + return acc; + }, {} as VolumeSettings); +} + +function processActivityUpdates(updates: ActivityUpdate[]) { + const readEvents = updates.filter((e) => 'read' in e) as ActivityReadUpdate[]; + actLogger.log('checking read events', readEvents); + if (readEvents.length > 0) { + const unreads = activityReadUpdates(readEvents); + useUnreadsStore.getState().update(unreads); + queryClient.setQueryData(unreadsKey, (d: Activity | undefined) => { + if (d === undefined) { + return undefined; + } + + return { + ...d, + ...unreads, + }; + }); + } + + const adjustEvents = updates.filter( + (e) => 'adjust' in e + ) as ActivityVolumeUpdate[]; + if (adjustEvents.length > 0) { + const volumes = activityVolumeUpdates(adjustEvents); + console.log('new volumes', volumes); + queryClient.setQueryData(volumeKey, (v) => + v === undefined ? undefined : { ...v, ...volumes } + ); + } + + const delEvents = updates.filter((e) => 'del' in e) as ActivityDeleteUpdate[]; + if (delEvents.length > 0) { + queryClient.setQueryData(unreadsKey, (unreads: Activity | undefined) => { + if (unreads === undefined) { + return undefined; + } + + return delEvents.reduce((acc, event) => { + const source = sourceToString(event.del, true); + delete acc[source]; + return acc; + }, unreads); + }); + } +} + +export function useActivityFirehose() { + const [eventQueue, setEventQueue] = useState([]); + const eventHandler = useCallback((event: ActivityUpdate) => { + actLogger.log('received activity', event); + setEventQueue((prev) => [...prev, event]); + }, []); + actLogger.log('events', eventQueue); + + useEffect(() => { + api.subscribe({ + app: 'activity', + path: '/', + event: eventHandler, + }); + }, [eventHandler]); + + const processQueue = useRef( + _.debounce( + (events: ActivityUpdate[]) => { + actLogger.log('processing events', events); + processActivityUpdates(events); + setEventQueue([]); + }, + 300, + { leading: true, trailing: true } + ) + ); + + useEffect(() => { + actLogger.log('checking queue', eventQueue.length); + if (eventQueue.length === 0) { + return; + } + + actLogger.log('attempting to process queue', eventQueue.length); + processQueue.current(eventQueue); + }, [eventQueue]); +} + +export function useMarkReadMutation() { + const mutationFn = async (variables: { + source: Source; + action?: ReadAction; + }) => { + await api.poke( + activityAction({ + read: { + source: variables.source, + action: variables.action || { all: null }, + }, + }) + ); + }; + + return useMutation({ + mutationFn, + onSuccess: () => { + queryClient.invalidateQueries(unreadsKey, undefined, { + cancelRefetch: true, + }); + }, + }); +} + +const emptyUnreads: Activity = {}; +export function useUnreads(): Activity { + const { data, ...rest } = useReactQueryScry({ + queryKey: unreadsKey, + app: 'activity', + path: '/activity', + onScry: stripPrefixes, + }); + + if (rest.isLoading || rest.isError || data === undefined) { + return emptyUnreads; + } + + return data as Activity; +} + +const emptySettings: VolumeSettings = {}; +export function useVolumeSettings() { + const { data, ...rest } = useReactQueryScry({ + queryKey: volumeKey, + app: 'activity', + path: '/volume-settings', + options: { + keepPreviousData: true, + }, + }); + + if (rest.isLoading || rest.isError || data === undefined) { + return { + ...rest, + data: emptySettings, + }; + } + + return { + ...rest, + data, + }; +} + +export function useVolume(source?: Source) { + const { data, ...rest } = useVolumeSettings(); + if (data === undefined || source === undefined) { + return { + ...rest, + volume: 'default', + }; + } + + return { + ...rest, + volume: data[sourceToString(source)], + }; +} + +export function useVolumeAdjustMutation() { + return useMutation({ + mutationFn: async (variables: { + source: Source; + volume: VolumeMap | null; + }) => { + return api.poke( + activityAction({ + adjust: variables, + }) + ); + }, + onMutate: async (variables) => { + const current = queryClient.getQueryData(volumeKey); + queryClient.setQueryData(volumeKey, (v) => { + if (v === undefined) { + return undefined; + } + + return { + ...v, + [sourceToString(variables.source)]: variables.volume, + }; + }); + + return current; + }, + onSuccess: () => { + queryClient.invalidateQueries(volumeKey); + }, + }); +} diff --git a/apps/tlon-web/src/state/bootstrap.ts b/apps/tlon-web/src/state/bootstrap.ts index 67a53fde59..7a66f66c0b 100644 --- a/apps/tlon-web/src/state/bootstrap.ts +++ b/apps/tlon-web/src/state/bootstrap.ts @@ -3,10 +3,10 @@ import Urbit from '@urbit/http-api'; import _ from 'lodash'; import api from '@/api'; -import { useChatStore } from '@/chat/useChatStore'; import { asyncWithDefault } from '@/logic/utils'; import queryClient from '@/queryClient'; +import { unreadsKey } from './activity'; import { initializeChat } from './chat'; import useContactState from './contact'; import useDocketState from './docket'; @@ -17,29 +17,29 @@ import usePalsState from './pals'; import { pinsKey } from './pins'; import useSchedulerStore from './scheduler'; import { useStorage } from './storage'; +import { useUnreadsStore } from './unreads'; const emptyGroupsInit: GroupsInit = { groups: {}, gangs: {}, channels: {}, - unreads: {}, + activity: {}, pins: [], chat: { dms: [], clubs: {}, - unreads: {}, invited: [], }, }; async function startGroups() { // make sure if this errors we don't kill the entire app - const { channels, unreads, groups, gangs, pins, chat } = + const { channels, groups, gangs, pins, chat, activity } = await asyncWithDefault( () => api.scry({ app: 'groups-ui', - path: '/v1/init', + path: '/v2/init', }), emptyGroupsInit ); @@ -47,17 +47,11 @@ async function startGroups() { queryClient.setQueryData(['groups'], groups); queryClient.setQueryData(['gangs'], gangs); queryClient.setQueryData(['channels'], channels); - queryClient.setQueryData(['unreads'], unreads); queryClient.setQueryData(pinsKey(), pins); initializeChat(chat); - // make sure we remove the app part from the nest before handing it over - useChatStore.getState().update( - _.mapKeys( - _.pickBy(unreads, (v, k) => k.startsWith('chat')), - (v, k) => k.replace(/\w*\//, '') - ) - ); + useUnreadsStore.getState().update(activity); + queryClient.setQueryData(unreadsKey, activity); } type Bootstrap = 'initial' | 'reset' | 'full-reset'; diff --git a/apps/tlon-web/src/state/channel/channel.ts b/apps/tlon-web/src/state/channel/channel.ts index 31dfd539e1..9fdfdc7c1e 100644 --- a/apps/tlon-web/src/state/channel/channel.ts +++ b/apps/tlon-web/src/state/channel/channel.ts @@ -31,16 +31,10 @@ import { Said, SortMode, TogglePost, - UnreadUpdate, - Unreads, newChatMap, newPostTupleArray, } from '@tloncorp/shared/dist/urbit/channel'; -import { - PagedWrits, - Writ, - newWritTupleArray, -} from '@tloncorp/shared/dist/urbit/dms'; +import { PagedWrits, Writ } from '@tloncorp/shared/dist/urbit/dms'; import { Flag } from '@tloncorp/shared/dist/urbit/hark'; import { daToUnix, decToUd, udToDec, unixToDa } from '@urbit/api'; import { Poke } from '@urbit/http-api'; @@ -64,16 +58,20 @@ import { cacheIdFromString, cacheIdToString, checkNest, + createDevLogger, nestToFlag, stringToTa, whomIsFlag, } from '@/logic/utils'; import queryClient from '@/queryClient'; -import ChatQueryKeys from '../chat/keys'; +import { unreadsKey } from '../activity'; +import { useUnreads } from '../unreads'; import { channelKey, infinitePostsKey, postKey } from './keys'; import shouldAddPostToCache from './util'; +const chLogger = createDevLogger('channels-update', false); + const POST_PAGE_SIZE = isNativeApp() ? STANDARD_MESSAGE_FETCH_PAGE_SIZE : LARGE_MESSAGE_FETCH_PAGE_SIZE; @@ -1139,6 +1137,7 @@ export function useChannelsFirehose() { }, []); const eventHandler = useCallback((event: ChannelsSubscribeResponse) => { + chLogger.log('received channel update', event); setEventQueue((prev) => [...prev, event]); }, []); @@ -1153,7 +1152,9 @@ export function useChannelsFirehose() { const processQueue = useRef( _.debounce( (events: ChannelsSubscribeResponse[]) => { + chLogger.log('processing channel queue', events); eventProcessor(events); + setEventQueue([]); }, 300, { leading: true, trailing: true } @@ -1161,10 +1162,12 @@ export function useChannelsFirehose() { ); useEffect(() => { + chLogger.log('checking channel queue', eventQueue.length); if (eventQueue.length === 0) { return; } + chLogger.log('attempting to process channel queue', eventQueue.length); processQueue.current(eventQueue); }, [eventQueue]); } @@ -1344,79 +1347,6 @@ export function useReply( }, [post, replyId]); } -export function useMarkReadMutation() { - const mutationFn = async (variables: { nest: Nest }) => { - checkNest(variables.nest); - - await api.poke(channelAction(variables.nest, { read: null })); - }; - - return useMutation({ - mutationFn, - onSuccess: () => { - queryClient.invalidateQueries(['unreads']); - }, - }); -} - -const emptyUnreads: Unreads = {}; -export function useUnreads(): Unreads { - const { mutate: markRead } = useMarkReadMutation(); - const invalidate = useRef( - _.debounce( - () => { - queryClient.invalidateQueries({ - queryKey: ['unreads'], - refetchType: 'none', - }); - }, - 300, - { leading: true, trailing: true } - ) - ); - - const eventHandler = (event: UnreadUpdate) => { - const { nest, unread } = event; - - if (unread !== null) { - const [app, flag] = nestToFlag(nest); - - if (app === 'chat') { - useChatStore - .getState() - .handleUnread(flag, unread, () => markRead({ nest: `chat/${flag}` })); - } - - queryClient.setQueryData(['unreads'], (d: Unreads | undefined) => { - if (d === undefined) { - return undefined; - } - - const newUnreads = { ...d }; - newUnreads[event.nest] = unread; - - return newUnreads; - }); - } - - invalidate.current(); - }; - - const { data, ...rest } = useReactQuerySubscription({ - queryKey: ['unreads'], - app: 'channels', - path: '/unreads', - scry: '/unreads', - onEvent: eventHandler, - }); - - if (rest.isLoading || rest.isError || data === undefined) { - return emptyUnreads; - } - - return data as Unreads; -} - export function useChatStoreChannelUnreads() { const chats = useChatStore((s) => s.chats); @@ -1440,15 +1370,7 @@ export function useIsJoined(nest: Nest) { checkNest(nest); const unreads = useUnreads(); - return Object.keys(unreads).includes(nest); -} - -export function useUnread(nest: Nest) { - checkNest(nest); - - const unreads = useUnreads(); - - return unreads[nest]; + return Object.keys(unreads).includes(`channel/${nest}`); } export function useChats(): Channels { @@ -1564,7 +1486,7 @@ export function useLeaveMutation() { onMutate: async (variables) => { const [han, flag] = nestToFlag(variables.nest); await queryClient.cancelQueries(channelKey()); - await queryClient.cancelQueries(['unreads']); + await queryClient.cancelQueries(unreadsKey); await queryClient.cancelQueries([han, 'perms', flag]); await queryClient.cancelQueries([han, 'posts', flag]); queryClient.removeQueries([han, 'perms', flag]); @@ -1572,7 +1494,7 @@ export function useLeaveMutation() { }, onSettled: async (_data, _error) => { await queryClient.invalidateQueries(channelKey()); - await queryClient.invalidateQueries(['unreads']); + await queryClient.invalidateQueries(unreadsKey); }, }); } diff --git a/apps/tlon-web/src/state/chat/chat.ts b/apps/tlon-web/src/state/chat/chat.ts index a3bd848cfc..e44435ed3a 100644 --- a/apps/tlon-web/src/state/chat/chat.ts +++ b/apps/tlon-web/src/state/chat/chat.ts @@ -1,4 +1,9 @@ import { QueryKey, useInfiniteQuery, useMutation } from '@tanstack/react-query'; +import { + MessageKey, + Source, + getKey, +} from '@tloncorp/shared/dist/urbit/activity'; import { CacheId, ChannelsAction, @@ -14,7 +19,6 @@ import { ClubDelta, Clubs, DMInit, - DMUnreadUpdate, DMUnreads, DmAction, HiddenMessages, @@ -30,7 +34,6 @@ import { WritResponse, WritResponseDelta, WritSeal, - WritTuple, Writs, newWritTupleArray, } from '@tloncorp/shared/dist/urbit/dms'; @@ -44,7 +47,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import create from 'zustand'; import api from '@/api'; -import { ChatStore, useChatInfo, useChatStore } from '@/chat/useChatStore'; +import { ChatStore, useChatStore } from '@/chat/useChatStore'; import { LARGE_MESSAGE_FETCH_PAGE_SIZE, STANDARD_MESSAGE_FETCH_PAGE_SIZE, @@ -52,10 +55,12 @@ import { import { isNativeApp } from '@/logic/native'; import useReactQueryScry from '@/logic/useReactQueryScry'; import useReactQuerySubscription from '@/logic/useReactQuerySubscription'; -import { whomIsDm } from '@/logic/utils'; +import { whomIsDm, whomIsNest } from '@/logic/utils'; import queryClient from '@/queryClient'; +import { unreadsKey, useMarkReadMutation } from '../activity'; import { PostStatus, TrackedPost } from '../channel/channel'; +import { useUnread } from '../unreads'; import ChatKeys from './keys'; import emptyMultiDm, { appendWritToLastPage, @@ -176,13 +181,10 @@ function resolveHiddenMessages(toggle: ToggleMessage) { }; } -export function initializeChat({ dms, clubs, invited, unreads }: DMInit) { +export function initializeChat({ dms, clubs, invited }: DMInit) { queryClient.setQueryData(['dms', 'dms'], () => dms || []); queryClient.setQueryData(['dms', 'multi'], () => clubs || {}); queryClient.setQueryData(ChatKeys.pending(), () => invited || []); - queryClient.setQueryData(ChatKeys.unreads(), () => unreads || {}); - - useChatStore.getState().update(unreads); } interface PageParam { @@ -664,98 +666,24 @@ export function useDmIsPending(ship: string) { return pending.includes(ship); } -export function useMarkDmReadMutation() { - const mutationFn = async (variables: { whom: string }) => { - const { whom } = variables; - await api.poke({ - app: 'chat', - mark: 'chat-remark-action', - json: { - whom, - diff: { read: null }, - }, - }); - }; - - return useMutation({ - mutationFn, - }); -} - -export function useDmUnreads() { - const dmUnreadsKey = ChatKeys.unreads(); - const { mutate: markDmRead } = useMarkDmReadMutation(); - const { pending } = usePendingDms(); - const pendingRef = useRef(pending); - pendingRef.current = pending; - - const invalidate = useRef( - _.debounce( - () => { - queryClient.invalidateQueries({ - queryKey: dmUnreadsKey, - refetchType: 'none', - }); - }, - 300, - { leading: true, trailing: true } - ) - ); - - const eventHandler = (event: DMUnreadUpdate) => { - invalidate.current(); - const { whom, unread } = event; - - // we don't get an update on the pending subscription when rsvps are accepted - // but we do get an unread notification, so we use it here for invalidation - if (pendingRef.current.includes(whom)) { - queryClient.invalidateQueries(ChatKeys.pending()); - } - - if (unread !== null) { - useChatStore - .getState() - .handleUnread(whom, unread, () => markDmRead({ whom })); - } - - queryClient.setQueryData(dmUnreadsKey, (d: DMUnreads | undefined) => { - if (d === undefined) { - return undefined; - } - - const newUnreads = { ...d }; - newUnreads[event.whom] = unread; - - return newUnreads; +export function useMarkDmReadMutation(whom: string, thread?: MessageKey) { + const { mutateAsync, ...rest } = useMarkReadMutation(); + const markDmRead = useCallback(() => { + const whomObj = whomIsDm(whom) ? { ship: whom } : { club: whom }; + const source: Source = thread + ? { 'dm-thread': { whom: whomObj, key: thread } } + : { dm: whomObj }; + return mutateAsync({ + source, }); - }; - - const { data, ...query } = useReactQuerySubscription< - DMUnreads, - DMUnreadUpdate - >({ - queryKey: dmUnreadsKey, - app: 'chat', - path: '/unreads', - scry: '/unreads', - onEvent: eventHandler, - options: { - retryOnMount: true, - refetchOnMount: true, - }, - }); + }, [whom, thread, mutateAsync]); return { - data: data || {}, - ...query, + ...rest, + markDmRead, }; } -export function useDmUnread(whom: string) { - const unreads = useDmUnreads(); - return unreads.data[whom]; -} - export function useArchiveDm() { const mutationFn = async ({ whom }: { whom: string }) => { await api.poke({ @@ -808,6 +736,7 @@ export function useUnarchiveDm() { } export function useDmRsvpMutation() { + const { mutateAsync: markRead } = useMarkReadMutation(); const mutationFn = async ({ ship, accept, @@ -815,7 +744,16 @@ export function useDmRsvpMutation() { ship: string; accept: boolean; }) => { - await api.poke({ + markRead({ + source: { dm: { ship } }, + action: { + event: { + 'dm-invite': { ship }, + }, + }, + }); + + return api.poke({ app: 'chat', mark: 'chat-dm-rsvp', json: { @@ -838,7 +776,7 @@ export function useDmRsvpMutation() { } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries(ChatKeys.unreads()); + queryClient.invalidateQueries(unreadsKey); queryClient.invalidateQueries(ChatKeys.pending()); queryClient.invalidateQueries(['dms', 'dms']); queryClient.invalidateQueries(['dms', variables.ship]); @@ -968,6 +906,7 @@ export function useRemoveFromMultiDm() { } export function useMutliDmRsvpMutation() { + const { mutateAsync: markRead } = useMarkReadMutation(); const mutationFn = async ({ id, accept, @@ -978,7 +917,16 @@ export function useMutliDmRsvpMutation() { const action = multiDmAction(id, { team: { ship: window.our, ok: accept }, }); - await api.poke(action); + + markRead({ + source: { dm: { club: id } }, + action: { + event: { + 'dm-invite': { club: id }, + }, + }, + }); + return api.poke(action); }; return useMutation({ @@ -1278,7 +1226,7 @@ export const infiniteDMsQueryFn = }; export function useInfiniteDMs(whom: string, initialTime?: string) { - const unread = useDmUnread(whom); + const unread = useUnread(getKey(whom)); const isDM = useMemo(() => whomIsDm(whom), [whom]); const type = useMemo(() => (isDM ? 'dm' : 'club'), [isDM]); const queryKey = useMemo(() => ['dms', whom, 'infinite'], [whom]); @@ -1671,12 +1619,6 @@ export function useDeleteDMReplyReactMutation() { }); } -export function useIsDmUnread(whom: string) { - const chatInfo = useChatInfo(whom); - const unread = chatInfo?.unread; - return Boolean(unread && !unread.seen); -} - const selChats = (s: ChatStore) => s.chats; export function useCheckDmUnread() { const chats = useChatStore(selChats); @@ -1711,7 +1653,7 @@ export function useChatStoreDmUnreads(): string[] { } export function useMultiDmIsPending(id: string): boolean { - const unread = useDmUnread(id); + const unread = useUnread(getKey(id)); const chat = useMultiDm(id); const isPending = chat && chat.hive.includes(window.our); diff --git a/apps/tlon-web/src/state/settings.ts b/apps/tlon-web/src/state/settings.ts index 2d40554f8a..50f2072968 100644 --- a/apps/tlon-web/src/state/settings.ts +++ b/apps/tlon-web/src/state/settings.ts @@ -78,6 +78,7 @@ export interface SettingsState { disableRemoteContent: boolean; disableSpellcheck: boolean; disableNicknames: boolean; + showUnreadCounts: boolean; }; tiles: { order: string[]; @@ -202,6 +203,7 @@ const emptyCalm: SettingsState['calmEngine'] = { disableRemoteContent: false, disableSpellcheck: false, disableNicknames: false, + showUnreadCounts: false, }; const loadingCalm: SettingsState['calmEngine'] = { @@ -210,6 +212,7 @@ const loadingCalm: SettingsState['calmEngine'] = { disableRemoteContent: true, disableSpellcheck: true, disableNicknames: true, + showUnreadCounts: false, }; export function useCalm() { diff --git a/apps/tlon-web/src/state/unreads.ts b/apps/tlon-web/src/state/unreads.ts new file mode 100644 index 0000000000..44977369dd --- /dev/null +++ b/apps/tlon-web/src/state/unreads.ts @@ -0,0 +1,245 @@ +import { + Activity, + ActivitySummary, +} from '@tloncorp/shared/dist/urbit/activity'; +import produce from 'immer'; +import { useCallback } from 'react'; +import create from 'zustand'; + +import { createDevLogger } from '@/logic/utils'; + +export type ReadStatus = 'read' | 'seen' | 'unread'; + +/** + * + */ +export interface Unread { + status: ReadStatus; + notify: boolean; + count: number; + combined: { + status: ReadStatus; + count: number; + notify: boolean; + }; + recency: number; + lastUnread?: { + id: string; + time: string; + }; + children: string[]; + readTimeout: number; + summary: ActivitySummary; // lags behind actual unread, only gets update if unread +} + +export interface Unreads { + [source: string]: Unread; +} + +export interface UnreadsStore { + loaded: boolean; + sources: Unreads; + seen: (whom: string) => void; + read: (whom: string) => void; + delayedRead: (whom: string, callback: () => void) => void; + handleUnread: (whom: string, unread: ActivitySummary) => void; + update: (unreads: Activity) => void; +} + +export const unreadStoreLogger = createDevLogger('UnreadsStore', true); + +export function isUnread(count: number, notify: boolean): boolean { + return Boolean(count > 0 || notify); +} + +function sumChildren(source: string, unreads: Unreads): number { + const children = unreads[source]?.children || []; + return children.reduce((acc, child) => acc + (unreads[child]?.count || 0), 0); +} + +function getUnread( + source: string, + summary: ActivitySummary, + unreads: Unreads +): Unread { + const topNotify = summary.unread?.notify || false; + const topCount = summary.unread?.count || 0; + const combinedCount = source.startsWith('group/') + ? sumChildren(source, unreads) + : summary.count || 0; + const combinedNotify = summary.notify; + return { + children: summary.children, + recency: summary.recency, + readTimeout: 0, + notify: topNotify, + count: topCount, + status: isUnread(topCount, topNotify) ? 'unread' : 'read', + combined: { + count: combinedCount, + notify: combinedNotify, + status: isUnread(combinedCount, combinedNotify) ? 'unread' : 'read', + }, + lastUnread: !summary.unread + ? undefined + : { + id: summary.unread.id, + time: summary.unread.time, + }, + // todo account for children "seen" + summary, + }; +} + +export const emptyUnread = (): Unread => ({ + summary: { + recency: 0, + count: 0, + notify: false, + unread: null, + children: [], + }, + recency: 0, + status: 'read', + notify: false, + count: 0, + combined: { + status: 'read', + count: 0, + notify: false, + }, + readTimeout: 0, + children: [], +}); +export const useUnreadsStore = create((set, get) => ({ + sources: {}, + loaded: false, + update: (summaries) => { + set( + produce((draft: UnreadsStore) => { + draft.loaded = true; + Object.entries(summaries).forEach(([key, summary]) => { + const source = draft.sources[key]; + unreadStoreLogger.log('update', key, source, summary, draft.sources); + draft.sources[key] = getUnread(key, summary, draft.sources); + }); + }) + ); + }, + handleUnread: (key, summary) => { + set( + produce((draft: UnreadsStore) => { + const source = draft.sources[key] || emptyUnread(); + unreadStoreLogger.log('unread', key, source, summary); + draft.sources[key] = getUnread(key, summary, draft.sources); + }) + ); + }, + seen: (key) => { + set( + produce((draft: UnreadsStore) => { + if (!draft.sources[key]) { + draft.sources[key] = emptyUnread(); + } + + const source = draft.sources[key]; + + unreadStoreLogger.log('seen', key); + draft.sources[key] = { + ...source, + status: 'seen', + }; + }) + ); + }, + read: (key) => { + set( + produce((draft: UnreadsStore) => { + const source = draft.sources[key]; + if (!source) { + return; + } + + if (source.readTimeout) { + unreadStoreLogger.log('clear delayedRead', key); + clearTimeout(source.readTimeout); + } + + unreadStoreLogger.log('read', key, JSON.stringify(source)); + draft.sources[key] = { + ...source, + status: 'read', + readTimeout: 0, + }; + unreadStoreLogger.log('post read', JSON.stringify(draft.sources[key])); + }) + ); + }, + delayedRead: (key, cb) => { + const { sources, read } = get(); + const source = sources[key] || emptyUnread(); + + if (source.readTimeout) { + clearTimeout(source.readTimeout); + } + + const readTimeout = setTimeout(() => { + read(key); + cb(); + }, 15 * 1000); // 15 seconds + + set( + produce((draft) => { + const latest = draft.sources[key] || emptyUnread(); + unreadStoreLogger.log('delayedRead', key, source, { ...latest }); + draft.sources[key] = { + ...latest, + readTimeout, + }; + }) + ); + }, +})); + +const defaultUnread = { + unread: false, + count: 0, + notify: false, +}; +export function useCombinedChatUnreads() { + const sources = useUnreadsStore(useCallback((s) => s.sources, [])); + return Object.entries(sources).reduce((acc, [key, source]) => { + if (key === 'base' || key.startsWith('group')) { + return acc; + } + + return { + unread: acc.unread || source.status === 'unread', + count: acc.count + source.count, + notify: acc.notify || source.notify, + }; + }, defaultUnread); +} + +export function useCombinedGroupUnreads() { + const sources = useUnreadsStore(useCallback((s) => s.sources, [])); + return Object.entries(sources).reduce((acc, [key, source]) => { + if (!key.startsWith('group')) { + return acc; + } + + return { + unread: acc.unread || source.combined.status === 'unread', + count: acc.count + source.combined.count, + notify: acc.notify || source.combined.notify, + }; + }, defaultUnread); +} + +export function useUnreads() { + return useUnreadsStore(useCallback((s) => s.sources, [])); +} + +export function useUnread(key: string): Unread | undefined { + return useUnreadsStore(useCallback((s) => s.sources[key], [key])); +} diff --git a/desk/app/activity.hoon b/desk/app/activity.hoon index 84dc8afc28..968b6cf999 100644 --- a/desk/app/activity.hoon +++ b/desk/app/activity.hoon @@ -1,20 +1,20 @@ :: -/- a=activity -/+ default-agent, verb, dbug +/- a=activity, c=channels, ch=chat, g=groups +/+ default-agent, verb, dbug, ch-utils=channel-utils, v=volume :: => |% +$ card card:agent:gall :: +$ versioned-state - $% state-0 + $% state-1 == :: - +$ state-0 - [%0 =stream:a =indices:a =volume:a] + +$ state-1 + [%1 =indices:a =activity:a =volume-settings:a] -- :: -=| state-0 +=| state-1 =* state - :: %- agent:dbug @@ -71,36 +71,199 @@ :: ++ init ^+ cor - cor + (emit %pass /migrate %agent [our.bowl dap.bowl] %poke noun+!>(%migrate)) +:: +++ migrate + =. indices (~(put by indices) [%base ~] [*stream:a *reads:a]) + =. cor set-chat-reads + =+ .^(=channels:c %gx (welp channels-prefix /v2/channels/full/noun)) + =. cor (set-volumes channels) + (set-channel-reads channels) :: ++ load |= =vase ^+ cor + ?: ?=([%0 *] q.vase) init =+ !<(old=versioned-state vase) - ?> ?=(%0 -.old) + ?> ?=(%1 -.old) =. state old cor :: +++ groups-prefix /(scot %p our.bowl)/groups/(scot %da now.bowl) +++ channels-prefix /(scot %p our.bowl)/channels/(scot %da now.bowl) +++ set-channel-reads + |= =channels:c + ^+ cor + =+ .^(=unreads:c %gx (welp channels-prefix /v1/unreads/noun)) + =/ entries ~(tap by unreads) + =; events=(list [time incoming-event:a]) + |- + ?~ events cor + =. cor (%*(. add should-notify |, start-time -.i.events) +.i.events) + $(events t.events) + |- ^- (list [time incoming-event:a]) + ?~ entries ~ + =/ head i.entries + =* next $(entries t.entries) + =/ [=nest:c =unread:c] head + =/ channel (~(get by channels) nest) + ?~ channel next + =/ group group.perm.u.channel + =; events=(list [time incoming-event:a]) + (weld events next) + =/ posts=(list [time incoming-event:a]) + ?~ unread.unread ~ + %+ murn + (tab:on-posts:c posts.u.channel `(sub id.u.unread.unread 1) count.u.unread.unread) + |= [=time post=(unit post:c)] + ?~ post ~ + =/ key=message-key:a + :_ time + [author.u.post time] + =/ mention + (was-mentioned:ch-utils content.u.post our.bowl) + `[time %post key nest group content.u.post mention] + =/ replies=(list [time incoming-event:a]) + %- zing + %+ murn + ~(tap by threads.unread) + |= [=id-post:c [id=id-reply:c count=@ud]] + ^- (unit (list [time incoming-event:a])) + =/ post=(unit (unit post:c)) (get:on-posts:c posts.u.channel id-post) + ?~ post ~ + ?~ u.post ~ + %- some + %+ turn + (tab:on-replies:c replies.u.u.post `(sub id 1) count) + |= [=time =reply:c] + =/ key=message-key:a + :_ time + [author.reply time] + =/ parent=message-key:a + :_ id-post + [author.u.u.post id-post] + =/ mention + (was-mentioned:ch-utils content.reply our.bowl) + [time %reply key parent nest group content.reply mention] + =/ init-time + ?: &(=(posts ~) =(replies ~)) recency.unread + *@da + :- [init-time %chan-init nest group] + (welp posts replies) +++ chat-prefix /(scot %p our.bowl)/chat/(scot %da now.bowl) +++ set-chat-reads + ^+ cor + =+ .^(=unreads:ch %gx (welp chat-prefix /unreads/noun)) + =+ .^ [dms=(map ship dm:ch) clubs=(map id:club:ch club:ch)] + %gx (welp chat-prefix /full/noun) + == + =/ entries ~(tap by unreads) + =; events=(list [time incoming-event:a]) + |- + ?~ events cor + =. cor (%*(. add should-notify |, start-time -.i.events) +.i.events) + $(events t.events) + |- ^- (list [time incoming-event:a]) + ?~ entries ~ + =/ head i.entries + =* next $(entries t.entries) + =/ [=whom:ch =unread:unreads:ch] head + =/ =pact:ch + ?- -.whom + %ship pact:(~(gut by dms) p.whom *dm:ch) + %club pact:(~(gut by clubs) p.whom *club:ch) + == + =; events=(list [time incoming-event:a]) + (weld events next) + =/ writs=(list [time incoming-event:a]) + ?~ unread.unread ~ + %+ murn + (tab:on:writs:ch wit.pact `(sub time.u.unread.unread 1) count.u.unread.unread) + |= [=time =writ:ch] + =/ key=message-key:a [id.writ time] + =/ mention + (was-mentioned:ch-utils content.writ our.bowl) + `[time %dm-post key whom content.writ mention] + =/ replies=(list [time incoming-event:a]) + %- zing + %+ murn + ~(tap by threads.unread) + |= [parent=message-key:ch [key=message-key:ch count=@ud]] + ^- (unit (list [time incoming-event:a])) + =/ writ=(unit writ:ch) (get:on:writs:ch wit.pact time.parent) + ?~ writ ~ + %- some + %+ turn + (tab:on:replies:ch replies.u.writ `(sub time.key 1) count) + |= [=time =reply:ch] + =/ mention + (was-mentioned:ch-utils content.reply our.bowl) + [time %dm-reply key parent whom content.reply mention] + =/ init-time + ?: &(=(writs ~) =(replies ~)) recency.unread + *@da + :- [init-time %dm-invite whom] + (welp writs replies) +++ set-volumes + |= =channels:c + :: set all existing channels to old default since new default is different + =. cor + =/ entries ~(tap by channels) + |- + ?~ entries cor + =/ [=nest:c =channel:c] i.entries + =. cor + %+ adjust [%channel nest group.perm.channel] + `(my [%post & |] ~) + $(entries t.entries) + =+ .^(=volume:v %gx (welp groups-prefix /volume/all/noun)) + :: set any overrides from previous volume settings + =. cor (adjust [%base ~] `(~(got by old-volumes:a) base.volume)) + =. cor + =/ entries ~(tap by chan.volume) + |- + ?~ entries cor + =/ [=nest:g =level:v] i.entries + ?. ?=(?(%chat %diary %heap) -.nest) $(entries t.entries) + =/ channel (~(get by channels) nest) + ?~ channel $(entries t.entries) + =. cor + %+ adjust [%channel nest group.perm.u.channel] + `(~(got by old-volumes:a) level) + $(entries t.entries) + =/ entries ~(tap by area.volume) + |- + ?~ entries cor + =* head i.entries + =. cor + %+ adjust [%group -.head] + `(~(got by old-volumes:a) +.head) + $(entries t.entries) ++ poke |= [=mark =vase] ^+ cor ?+ mark ~|(bad-poke+mark !!) + %noun + ?+ q.vase ~|(bad-poke+mark !!) + %migrate + =. state *state-1 + migrate + == + :: %activity-action =+ !<(=action:a vase) ?- -.action - %add - (add +.action) - %read - (read +.action) - %adjust - (adjust +.action) + %add (add +.action) + %del (del +.action) + %read (read +.action) + %adjust (adjust +.action) == == :: ++ watch |= =(pole knot) ^+ cor - ?+ pole ~|(bat-watch-path+pole !!) + ?+ pole ~|(bad-watch-path+pole !!) ~ ?>(from-self cor) [%notifications ~] ?>(from-self cor) [%unreads ~] ?>(from-self cor) @@ -111,178 +274,213 @@ ^- (unit (unit cage)) ?+ pole [~ ~] [%x ~] - ``activity-full+!>([stream indices (~(run by indices) summarize-unreads)]) + ``activity-full+!>([indices activity volume-settings]) + :: + :: /all: unified feed (equality of opportunity) + :: [%x %all ~] - ``activity-stream+!>((tap:on-event:a stream)) + ``activity-stream+!>(stream:base) + :: [%x %all start=@ count=@ ~] - =- ``activity-stream+!>(-) - (tab:on-event:a stream `(slav %da start.pole) (slav %ud count.pole)) + =- ``activity-stream+!>((gas:on-event:a *stream:a -)) + (tab:on-event:a stream:base `(slav %da start.pole) (slav %ud count.pole)) + :: + :: /each: unified feed (equality of outcome) + ::TODO want to be able to filter for specific events kind too, but that will + :: suffer from the "search range" "problem", where we want .count to + :: mean entries trawled, not entries returned... + :: + [%x %each start=@ count=@ ~] + =; =stream:a + ``activity-stream+!>(-) + =/ start (slav %da start.pole) + =/ count (slav %ud count.pole) + %- ~(rep by indices) + |= [[=source:a =stream:a =reads:a] out=stream:a] + ^+ out + (gas:on-event:a out (tab:on-event:a stream `start count)) + :: + :: /indexed: per-index + :: + [%x %indexed concern=?([%channel nk=kind:c:a ns=@ nt=@ gs=@ gt=@ rest=*] [%dm whom=@ rest=*])] + =/ =source:a + ?- -.concern.pole + %dm + :- %dm + ?^ ship=(slaw %p whom.concern.pole) + [%ship u.ship] + [%club (slav %uv whom.concern.pole)] + :: + %channel + =, concern.pole + [%channel [nk (slav %p ns) nt] [(slav %p gs) gt]] + == + =/ rest=(^pole knot) + ?- -.concern.pole + %dm rest.concern.pole + %channel rest.concern.pole + == + ?~ dice=(~(get by indices) source) [~ ~] + ?+ rest ~ + ~ + ``activity-stream+!>(stream.u.dice) + :: + [start=@ count=@ ~] + =/ start (slav %da start.rest) + =/ count (slav %ud count.rest) + =/ ls (tab:on-event:a stream.u.dice `start count) + ``activity-stream+!>((gas:on-event:a *stream:a ls)) + == + :: /event: individual events + :: [%u %event id=@ ~] - ``loob+!>((has:on-event:a stream (slav %da id.pole))) + ``loob+!>((has:on-event:a stream:base (slav %da id.pole))) + :: [%x %event id=@ ~] - ``activity-event+!>([id.pole (got:on-event:a stream (slav %da id.pole))]) - [%x %unreads ~] - ``activity-unreads+!>((~(run by indices) summarize-unreads)) + ``activity-event+!>([id.pole (got:on-event:a stream:base (slav %da id.pole))]) + :: + [%x %activity ~] + ``activity-summary+!>(activity) + :: + [%x %volume-settings ~] + ``activity-settings+!>(volume-settings) == :: +++ base + ^- index:a + (~(got by indices) [%base ~]) ++ add - |= =event:a + =/ should-notify=? & + =/ start-time=time now.bowl + |= inc=incoming-event:a ^+ cor =/ =time-id:a - =/ t now.bowl + =/ t start-time |- - ?. (has:on-event:a stream t) t + ?. (has:on-event:a stream:base t) t $(t +(t)) + =/ notify &(should-notify notify:(get-volume inc)) + =/ =event:a [inc notify |] =. cor - (give %fact ~[/] activity-event+!>([time-id event])) - =? cor (notifiable event) + (give %fact ~[/] activity-update+!>([%add time-id event])) + =? cor notify (give %fact ~[/notifications] activity-event+!>([time-id event])) - =. stream - (put:on-event:a stream time-id event) - ?+ -.event cor - %dm-post - =/ index [%dm whom.event] - =? indices !(~(has by indices) index) - (~(put by indices) index [*stream:a *reads:a]) - =/ indy (~(got by indices) index) - =/ new - :* (put:on-event:a stream.indy time-id event) - floor.reads.indy - %^ put:on-parent:a event-parents.reads.indy - time-id - [| time-id] - == - =. indices - (~(put by indices) index new) - cor + =. indices + =/ =stream:a (put:on-event:a stream:base time-id event) + (~(put by indices) [%base ~] [stream reads:base]) + =/ =source:a (determine-source inc) + ?+ -<.event (add-to-index source time-id event) + %chan-init + =/ group-src [%group group.event] + =. cor (add-to-index source time-id event) + (add-to-index group-src time-id event(child &)) + :: %dm-reply - =/ index [%dm whom.event] - =? indices !(~(has by indices) index) - (~(put by indices) index *[stream:a reads:a]) - =/ indy (~(got by indices) index) - =/ new - :- (put:on-event:a stream.indy time-id event) - reads.indy - =. indices - (~(put by indices) index new) - cor + =/ parent-src [%dm whom.event] + =. cor (add-to-index source time-id event) + (add-to-index parent-src time-id event(child &)) + :: %post - =/ index [%channel channel.event group.event] - =? indices !(~(has by indices) index) - (~(put by indices) index *[stream:a reads:a]) - =/ indy (~(got by indices) index) - =/ new - :* (put:on-event:a stream.indy time-id event) - floor.reads.indy - %^ put:on-parent:a event-parents.reads.indy - time-id - [| time-id] - == - =. indices - (~(put by indices) index new) - cor + =/ parent-src [%group group.event] + =. cor (add-to-index source time-id event) + (add-to-index parent-src time-id event(child &)) + :: %reply - =/ index [%channel channel.event group.event] - =? indices !(~(has by indices) index) - (~(put by indices) index *[stream:a reads:a]) - =/ indy (~(got by indices) index) - =/ new - :- (put:on-event:a stream.indy time-id event) - reads.indy - =. indices - (~(put by indices) index new) - cor - == -++ loudness - ^- (map flavor:a flavor-level:a) - %- malt - ^- (list [flavor:a flavor-level:a]) - :~ [%dm-invite %notify] - [%dm-post %notify] - [%dm-post-mention %notify] - [%dm-reply %notify] - [%dm-reply-mention %notify] - [%kick %default] - [%join %default] - [%post %default] - [%post-mention %notify] - [%reply %notify] - [%reply-mention %notify] - [%flag %default] + =/ chan-src [%channel channel.event group.event] + =/ group-src [%group group.event] + =. cor (add-to-index source time-id event) + =. cor (add-to-index chan-src time-id event(child &)) + (add-to-index group-src time-id event(child &)) == -++ notifiable - |= =event:a - ^- ? - =/ index (determine-index event) - =/ =index-level:a - ?~ index %soft - (~(gut by volume) u.index %soft) - ?- index-level - %loud & - %hush | - %soft - .= %notify - (~(gut by loudness) (determine-flavor event) %default) - == -++ determine-index - |= =event:a - ^- (unit index:a) - ?+ -.event ~ - %post `[%channel channel.event group.event] - %reply `[%channel channel.event group.event] - %dm-post `[%dm whom.event] - %dm-reply `[%dm whom.event] +:: +++ del + |= =source:a + ^+ cor + =. indices (~(del by indices) source) + =. volume-settings (~(del by volume-settings) source) + :: TODO: send notification removals? + (give %fact ~[/] activity-update+!>([%del source])) +++ add-to-index + |= [=source:a =time-id:a =event:a] + ^+ cor + =/ =index:a (~(gut by indices) source *index:a) + =/ new=_stream.index + (put:on-event:a stream.index time-id event) + (update-index source index(stream new) |) +++ update-index + |= [=source:a new=index:a new-floor=?] + =? new new-floor + (update-floor new) + =. indices + (~(put by indices) source new) + (refresh-summary source) +++ refresh-summary + |= =source:a + =/ summary (summarize-unreads source (get-index source)) + =. activity + (~(put by activity) source summary) + (give-unreads source) +++ get-volumes + |= =source:a + ^- volume-map:a + =/ target (~(get by volume-settings) source) + ?^ target u.target + ?- -.source + %base *volume-map:a + %group (get-volumes %base ~) + %dm (get-volumes %base ~) + %dm-thread (get-volumes %dm whom.source) + %channel (get-volumes %group group.source) + %thread (get-volumes %channel channel.source group.source) == -++ determine-flavor - |= =event:a - ^- flavor:a +++ get-volume + |= event=incoming-event:a + ^- volume:a + =/ source (determine-source event) + =/ loudness=volume-map:a (get-volumes source) + (~(gut by loudness) (determine-event-type event) [unreads=& notify=|]) +++ determine-source + |= event=incoming-event:a + ^- source:a ?- -.event - %dm-invite %dm-invite - %kick %kick - %join %join - %flag %flag - %post - ?: mention.event %post-mention %post - %reply - ?: mention.event %reply-mention %reply - %dm-post - ?: mention.event %dm-post-mention %dm-post - %dm-reply - ?: mention.event %dm-reply-mention %dm-reply + %chan-init [%channel channel.event group.event] + %post [%channel channel.event group.event] + %reply [%thread parent.event channel.event group.event] + %dm-invite [%dm whom.event] + %dm-post [%dm whom.event] + %dm-reply [%dm-thread parent.event whom.event] + %group-invite [%group group.event] + %group-kick [%group group.event] + %group-join [%group group.event] + %group-role [%group group.event] + %group-ask [%group group.event] + %flag-post [%group group.event] + %flag-reply [%group group.event] + == +++ determine-event-type + |= event=incoming-event:a + ^- event-type:a + ?+ -.event -.event + %post ?:(mention.event %post-mention %post) + %reply ?:(mention.event %reply-mention %reply) + %dm-post ?:(mention.event %dm-post-mention %dm-post) + %dm-reply ?:(mention.event %dm-reply-mention %dm-reply) == :: ++ find-floor - |= [=index:a mode=$%([%all ~] [%reply parent=time-id:a])] + |= [orig=stream:a =reads:a] ^- (unit time) - ?. (~(has by indices) index) ~ :: starting at the last-known first-unread location (floor), walk towards :: the present, to find the new first-unread location (new floor) :: - =/ [orig=stream:a =reads:a] - (~(got by indices) index) - ?> |(?=(%all -.mode) (has:on-parent:a event-parents.reads parent.mode)) :: slice off the earlier part of the stream, for efficiency :: - =/ =stream:a - =; beginning=time - (lot:on-event:a orig `beginning ~) - ?- -.mode - %all floor.reads - %reply reply-floor:(got:on-parent:a event-parents.reads parent.mode) - == + =/ =stream:a (lot:on-event:a orig `floor.reads ~) =| new-floor=(unit time) |- ?~ stream new-floor :: =/ [[=time =event:a] rest=stream:a] (pop:on-event:a stream) - ?: ?& ?=(%reply -.mode) - ?| !?=(%reply -.event) - ?&(?=(?(%dm-post %post) -.event) =(message-key.event parent.mode)) - == == - :: we're in reply mode, and it's not a reply event, or a reply to - :: something else, so, skip - :: - $(stream rest) =; is-read=? :: if we found something that's unread, we need look no further :: @@ -290,148 +488,167 @@ :: otherwise, continue our walk towards the present :: $(new-floor `time, stream rest) - ?+ -.event !! - ?(%dm-post %post) - =* id=time-id:a q.id.message-key.event - =/ par=(unit event-parent:a) (get:on-parent:a event-parents.reads id) - ?~(par | seen.u.par) - :: - %reply - =* id=time-id:a q.id.message-key.event - =/ par=(unit event-parent:a) (get:on-parent:a event-parents.reads id) - ?~(par | (gte time reply-floor.u.par)) + :: treat all other events as read + ?+ -<.event & + ?(%dm-post %dm-reply %post %reply) + ?=(^ (get:on-read-items:a items.reads time)) == :: ++ update-floor |= =index:a - ^+ cor - =/ new-floor=(unit time) (find-floor index %all ~) - =? indices ?=(^ new-floor) - %+ ~(jab by indices) index - |= [=stream:a =reads:a] - [stream reads(floor u.new-floor)] - cor + ^- index:a + =/ new-floor=(unit time) (find-floor index) + ?~ new-floor index + index(floor.reads u.new-floor) :: ++ read - |= [=index:a action=read-action:a] + |= [=source:a action=read-action:a] ^+ cor + %+ update-reads source ?- -.action - %thread - =/ indy (~(get by indices) index) - ?~ indy cor - =/ new - =- u.indy(event-parents.reads -) - %+ put:on-parent:a event-parents.reads.u.indy - =; new-reply-floor=(unit time) - [id.action [& (fall new-reply-floor id.action)]] - (find-floor index %reply id.action) - =. indices - (~(put by indices) index new) - =. cor (update-floor index) - (give-unreads index new) + %event + |= =index:a + ?> ?=(%event -.action) + =/ events + %+ murn + (tap:on-event:a stream.index) + |= [=time =event:a] + ?. =(-.event event.action) ~ + `[time event] + ?~ events index + =- index(items.reads -) + %+ put:on-read-items:a items.reads.index + [-<.events ~] :: - %post - =/ indy (~(get by indices) index) - ?~ indy cor - =/ old-event-parent (get:on-parent:a event-parents.reads.u.indy id.action) - ?~ old-event-parent cor - =/ new - =- u.indy(event-parents.reads -) - %+ put:on-parent:a event-parents.reads.u.indy - [id.action u.old-event-parent(seen &)] - =. indices - (~(put by indices) index new) - =. cor (update-floor index) - (give-unreads index new) + %item + |= =index:a + =- index(items.reads -) + %+ put:on-read-items:a items.reads.index + [id.action ~] :: %all - =/ indy (~(get by indices) index) - ?~ indy cor - =/ new - =/ latest=(unit [=time event:a]) - ::REVIEW is this taking the item from the correct end? lol - (ram:on-event:a stream.u.indy) - ?~ latest u.indy - u.indy(reads [time.u.latest ~]) - =. indices - (~(put by indices) index new) - (give-unreads index new) + |= =index:a + =/ latest=(unit [=time event:a]) + ::REVIEW is this taking the item from the correct end? lol + (ram:on-event:a stream.index) + index(reads [?~(latest now.bowl time.u.latest) ~]) == :: +++ get-index + |= =source:a + (~(gut by indices) source *index:a) +++ update-reads + |= [=source:a updater=$-(index:a index:a)] + ^+ cor + =/ new (updater (get-index source)) + =. cor (update-index source new &) + ?+ -.source cor + %channel (refresh-summary [%group group.source]) + %dm-thread (refresh-summary [%dm whom.source]) + :: + %thread + =. cor (refresh-summary [%channel channel.source group.source]) + (refresh-summary [%group group.source]) + == ++ give-unreads - |= [=index:a =stream:a =reads:a] + |= =source:a ^+ cor - (give %fact ~[/unreads] activity-index-unreads+!>([index (summarize-unreads [stream reads])])) + =/ summary (~(got by activity) source) + (give %fact ~[/ /unreads] activity-update+!>(`update:a`[%read source summary])) :: ++ adjust - |= [=index:a =index-level:a] + |= [=source:a volume-map=(unit volume-map:a)] ^+ cor - =. volume - (~(put by volume) index index-level) - cor + =. cor (give %fact ~[/] activity-update+!>([%adjust source volume-map])) + ?~ volume-map + cor(volume-settings (~(del by volume-settings) source)) + =/ target (~(gut by volume-settings) source *volume-map:a) + =. volume-settings + (~(put by volume-settings) source (~(uni by target) u.volume-map)) + :: recalculate activity summary with new settings + =. activity + %+ ~(put by activity) source + (summarize-unreads source (~(gut by indices) source *index:a)) + (give-unreads source) :: +++ get-children + |= =source:a + ^- (list source:a) + %+ skim + ~(tap in ~(key by indices)) + |= src=source:a + ?+ -.source | + %base ?!(?=(%base -.src)) + %group &(?=(%channel -.src) =(flag.source group.src)) + %channel &(?=(%thread -.src) =(nest.source channel.src)) + %dm &(?=(%dm-thread -.src) =(whom.source whom.src)) + == ++ summarize-unreads - |= [=stream:a =reads:a] - ^- unread-summary:a + |= [=source:a index:a] + ^- activity-summary:a =. stream (lot:on-event:a stream `floor.reads ~) - =/ event-parents event-parents.reads + =/ read-items items.reads :: for each item in reads - :: remove the post from the event stream - :: remove replies older than reply-floor from the event stream - :: then call stream-to-unreads - |- - ?~ event-parents - (stream-to-unreads stream) - =/ [[=time =event-parent:a] rest=event-parents:a] (pop:on-parent:a event-parents) - %= $ - event-parents - rest + :: omit: + :: if we don't have unreads enabled for that event + :: any items that are unread for some reason + :: then remove the post or reply from the event stream + :: and call stream-to-unreads :: - stream - =- +.- - %^ (dip:on-event:a @) stream - ~ - |= [@ key=@da =event:a] - ^- [(unit event:a) ? @] - ?> ?=(?(%post %reply %dm-post) -.event) - ?: &(seen.event-parent =(time key)) - [~ | ~] - ?. =(-.event %reply) - [`event | ~] - ?: (lth time.message-key.event reply-floor.event-parent) - [~ | ~] - [`event | ~] - == + :: TODO: flip around and iterate over stream once, cleaning reads out + :: and segment replies for unread threads tracking + |- + =; unread-stream=stream:a + =/ children (get-children source) + (stream-to-unreads unread-stream floor.reads children source) + %+ gas:on-event:a *stream:a + %+ murn + (tap:on-event:a stream) + |= [=time =event:a] + ?: (has:on-read-items:a items.reads time) ~ + ?: child.event ~ + `[time event] ++ stream-to-unreads - |= =stream:a - ^- unread-summary:a - =/ newest=(unit time) ~ - =/ count 0 - =/ threads=(map message-id:a [oldest-unread=time count=@ud]) ~ + |= [=stream:a floor=time children=(list source:a) =source:a] + ^- activity-summary:a + =/ cs=activity-summary:a + %+ roll + children + |= [=source:a sum=activity-summary:a] + =/ =index:a (~(gut by indices) source *index:a) + =/ as=activity-summary:a + (~(gut by activity) source (summarize-unreads source index)) + %= sum + count (^add count.sum count.as) + notify |(notify.sum notify.as) + newest ?:((gth newest.as newest.sum) newest.as newest.sum) + == + =/ newest=time ?:((gth newest.cs floor) newest.cs floor) + =/ total count.cs + =/ main 0 + =/ notified=? notify.cs + =/ main-notified=? | + =| last=(unit message-key:a) :: for each event :: update count and newest :: if reply, update thread state |- ?~ stream - :+ (fall newest now.bowl) count - %+ turn ~(val by threads) - |= [oldest-unread=time count=@ud] - [oldest-unread count] - =/ [[@ =event:a] rest=stream:a] (pop:on-event:a stream) - =. count +(count) - =. newest - ?> ?=(?(%dm-post %post %reply) -.event) - ::REVIEW should we take timestamp of parent post if reply?? - :: (in which case we would need to do (max newest time.mk.e)) - `time.message-key.event - =? threads ?=(%reply -.event) - =/ old - %+ ~(gut by threads) id.target.event - [oldest-unread=time.message-key.event count=0] - %+ ~(put by threads) id.target.event - :: we don't need to update the timestamp, because we always process the - :: oldest message first - :: - [oldest-unread.old +(count.old)] + [newest total notified ?~(last ~ `[u.last main main-notified]) children] + =/ [[=time =event:a] rest=stream:a] (pop:on-event:a stream) + =/ volume (get-volume -.event) + =? notified &(notify.volume notified.event) & + =. newest time + ?. ?& unreads.volume + ::TODO support other event types + ?=(?(%dm-post %dm-reply %post %reply) -<.event) + == + $(stream rest) + =. total +(total) + =. main +(main) + =? main-notified &(notify:volume notified.event) & + =. last + ?~ last `key.event + last $(stream rest) -- diff --git a/desk/app/channels.hoon b/desk/app/channels.hoon index 76d050bbf3..7b4814da28 100644 --- a/desk/app/channels.hoon +++ b/desk/app/channels.hoon @@ -8,7 +8,7 @@ :: note: all subscriptions are handled by the subscriber library so :: we can have resubscribe loop protection. :: -/- c=channels, g=groups, ha=hark +/- c=channels, g=groups, ha=hark, activity /- meta /+ default-agent, verb, dbug, sparse, neg=negotiate /+ utils=channel-utils, volume, s=subscriber @@ -527,6 +527,13 @@ ?~ p.sign cor %- (slog leaf+"Failed to hark" u.p.sign) cor + :: + [%activity %submit ~] + ?> ?=(%poke-ack -.sign) + ?~ p.sign cor + %- (slog leaf+"{} failed to submit activity" u.p.sign) + cor + :: [%contacts @ ~] ?> ?=(%poke-ack -.sign) ?~ p.sign cor @@ -605,9 +612,12 @@ [%v0 +.pole] ?+ pole [~ ~] [%x ?(%v0 %v1) %channels ~] ``channels+!>((uv-channels-1:utils v-channels)) - [%x %v2 %channels ~] ``channels-2+!>((uv-channels-2:utils v-channels)) + :: + [%x %v2 %channels full=?(~ [%full ~])] + ``channels-2+!>((uv-channels-2:utils v-channels !=(full ~))) + :: [%x ?(%v0 %v1) %init ~] ``noun+!>([unreads (uv-channels-1:utils v-channels)]) - [%x %v2 %init ~] ``noun+!>([unreads (uv-channels-2:utils v-channels)]) + [%x %v2 %init ~] ``noun+!>([unreads (uv-channels-2:utils v-channels |)]) [%x ?(%v0 %v1) %hidden-posts ~] ``hidden-posts+!>(hidden-posts) [%x ?(%v0 %v1) %unreads ~] ``channel-unreads+!>(unreads) [%x v=?(%v0 %v1) =kind:c ship=@ name=@ rest=*] @@ -695,6 +705,10 @@ :: ++ from-self =(our src):bowl :: +++ scry-path + |= [agent=term =path] + ^- ^path + (welp /(scot %p our.bowl)/[agent]/(scot %da now.bowl) path) ++ ca-core |_ [=nest:c channel=v-channel:c gone=_|] ++ ca-core . @@ -722,7 +736,63 @@ ++ ca-sub-wire (weld ca-area /updates) ++ ca-give-unread (give %fact ~[/unreads /v0/unreads /v1/unreads] channel-unread-update+!>([nest ca-unread])) -:: + :: + ++ ca-activity + =, activity + |% + ++ on-post + |= v-post:c + ^+ ca-core + ?: =(author our.bowl) ca-core + =/ mention=? (was-mentioned:utils content our.bowl) + =/ action + [%add %post [[author id] id] nest group.perm.perm.channel content mention] + (send ~[action]) + ++ on-reply + |= [parent=v-post:c v-reply:c] + ^+ ca-core + ?: =(author our.bowl) ca-core + =/ mention=? (was-mentioned:utils content our.bowl) + =/ in-replies + %+ lien (tap:on-v-replies:c replies.parent) + |= [=time reply=(unit v-reply:c)] + ?~ reply | + =(author.u.reply our.bowl) + =/ =path (scry-path %activity /volume-settings/noun) + =+ .^(settings=volume-settings %gx path) + =/ parent-key=message-key [[author id]:parent id.parent] + =/ =action + :* %add %reply + [[author id] id] + parent-key + nest + group.perm.perm.channel + content + mention + == + :: only follow thread if we haven't adjusted settings already + :: and if we're the author of the post, mentioned, or in the replies + =/ thread=source [%thread parent-key nest group.perm.perm.channel] + ?. ?& !(~(has by settings) thread) + ?| mention + in-replies + =(author.parent our.bowl) + == + == + (send ~[action]) + =/ vm=volume-map [[%reply & &] ~ ~] + (send ~[[%adjust thread `vm] action]) + ++ send + |= actions=(list action) + ^+ ca-core + ?. .^(? %gu (scry-path %activity /$)) + ca-core + %- emil + %+ turn actions + |= =action + =/ =cage activity-action+!>(action) + [%pass /activity/submit %agent [our.bowl %activity] %poke cage] + -- :: :: handle creating a channel :: @@ -734,6 +804,7 @@ =. channel *v-channel:c =. group.perm.perm.channel group.create =. last-read.remark.channel now.bowl + =. ca-core (send:ca-activity ~[[%add %chan-init nest group.create]]) =/ =cage [%channel-command !>([%create create])] (emit %pass (weld ca-area /create) %agent [our.bowl server] %poke cage) :: @@ -749,6 +820,7 @@ =. last-read.remark.channel now.bowl =. ca-core ca-give-unread =. ca-core (ca-response %join group) + =. ca-core (send:ca-activity ~[[%add %chan-init n group]]) (ca-safe-sub |) :: :: handle an action from the client @@ -772,7 +844,7 @@ =? ca-core =(%read -.a-remark) %- emil =/ last-read last-read.remark.channel - =+ .^(=carpet:ha %gx /(scot %p our.bowl)/hark/(scot %da now.bowl)/desk/groups/latest/noun) + =+ .^(=carpet:ha %gx (scry-path %hark /desk/groups/latest/noun)) %+ murn ~(tap by cable.carpet) |= [=rope:ha =thread:ha] @@ -808,6 +880,7 @@ == == =. ca-core ca-give-unread + ::TODO %read activity-action? (ca-response a-remark) :: :: proxy command to host @@ -1132,6 +1205,12 @@ ?: ?=(%set -.u-post) =? recency.remark.channel ?=(^ post.u-post) (max recency.remark.channel id-post) + =? ca-core ?& ?=(^ post.u-post) + |(?=(~ post) (gth rev.u.post.u-post rev.u.u.post)) + == + ::REVIEW this might re-submit on edits. is that what we want? + :: it looks like %activity inserts even if it's a duplicate. + (on-post:ca-activity u.post.u-post) ?~ post =/ post=(unit post:c) (bind post.u-post uv-post:utils) =? ca-core ?=(^ post.u-post) @@ -1203,6 +1282,8 @@ `(uv-reply:utils id-post u.reply.u-reply) =? ca-core ?=(^ reply.u-reply) (on-reply:ca-hark id-post post u.reply.u-reply) + =? ca-core ?=(^ reply.u-reply) + (on-reply:ca-activity post u.reply.u-reply) =? pending.channel ?=(^ reply.u-reply) =/ memo +.+.u.reply.u-reply =/ client-id [author sent]:memo @@ -2042,13 +2123,12 @@ ++ ca-recheck |= sects=(set sect:g) =/ =flag:g group.perm.perm.channel - =/ groups-prefix /(scot %p our.bowl)/groups/(scot %da now.bowl) - =/ exists-path (weld groups-prefix /exists/(scot %p p.flag)/[q.flag]) + =/ exists-path + (scry-path %groups /exists/(scot %p p.flag)/[q.flag]) =+ .^(exists=? %gx exists-path) ?. exists ca-core =/ =path - %+ weld - groups-prefix + %+ scry-path %groups /groups/(scot %p p.flag)/[q.flag]/v1/group-ui =+ .^(group=group-ui:g %gx path) ?. (~(has by channels.group) nest) ca-core @@ -2071,6 +2151,7 @@ ++ ca-leave =. ca-core ca-simple-leave =. ca-core (ca-response %leave ~) + =. ca-core (send:ca-activity [%del %channel nest group.perm.perm.channel] ~) =. gone & ca-core -- diff --git a/desk/app/chat.hoon b/desk/app/chat.hoon index e459cf29fd..586acdf5c3 100644 --- a/desk/app/chat.hoon +++ b/desk/app/chat.hoon @@ -1,4 +1,4 @@ -/- c=chat, d=channels, g=groups, u=ui, e=epic, old=chat-2 +/- c=chat, d=channels, g=groups, u=ui, e=epic, old=chat-2, activity /- meta /- ha=hark /- contacts @@ -573,6 +573,12 @@ ?~ p.sign cor %- (slog 'Failed to do chat data migration' u.p.sign) cor + :: + [%activity %submit ~] + ?> ?=(%poke-ack -.sign) + ?~ p.sign cor + %- (slog 'Failed to send activity' u.p.sign) + cor :: [%said *] :: old chat used to fetch previews, we don't do those here anymore @@ -624,6 +630,7 @@ |= =path ^- (unit (unit cage)) ?+ path [~ ~] + [%x %full ~] ``noun+!>([dms clubs]) [%x %old ~] ``noun+!>(old-chats) :: legacy data, for migration use :: [%x %clubs ~] ``clubs+!>((~(run by clubs) |=(=club:c crew.club))) @@ -754,6 +761,37 @@ =/ =cage hark-action-1+!>([%new-yarn new-yarn]) (pass-hark cage) :: +++ pass-activity + =, activity + |= $: =whom + $= concern + $% [%post key=message-key] + [%reply key=message-key top=message-key] + [%invite ~] + == + content=story:d + mention=? + == + ^+ cor + ?: ?& ?=(?(%post %reply) -.concern) + .= our.bowl + p.id:?-(-.concern %post key.concern, %reply key.concern) + == + cor + ?. .^(? %gu /(scot %p our.bowl)/activity/(scot %da now.bowl)/$) + cor + %- emit + =; =cage + [%pass /activity/submit %agent [our.bowl %activity] %poke cage] + :- %activity-action + !> ^- action + :- %add + ?- -.concern + %post [%dm-post key.concern whom content mention] + %reply [%dm-reply key.concern top.concern whom content mention] + %invite [%dm-invite whom] + == +:: ++ make-notice |= [=ship text=cord] ^- delta:writs:c @@ -1131,6 +1169,11 @@ =/ link (welp /dm/(scot %uv id) rest) [& & rope con link but] :: + ++ cu-activity !. + |* a=* + =. cor (pass-activity [%club id] a) + cu-core + :: ++ cu-pass |% ++ act @@ -1268,6 +1311,9 @@ ~ =? cor (want-hark %to-us) (emit (pass-yarn new-yarn)) + =/ concern [%post [. q]:p.diff.delta] + =/ mention (was-mentioned:utils content.memo our.bowl) + =. cu-core (cu-activity concern content.memo mention) (cu-give-writs-diff diff.delta) :: %reply @@ -1299,6 +1345,10 @@ ~ =? cor (want-hark %to-us) (emit (pass-yarn new-yarn)) + =/ top-con [. q]:p.diff.delta + =/ concern [%reply [. q]:id.q.diff.delta top-con] + =/ mention (was-mentioned:utils content.memo our.bowl) + =. cu-core (cu-activity concern content.memo mention) (cu-give-writs-diff diff.delta) == == @@ -1546,6 +1596,11 @@ ++ di-area `path`/dm/(scot %p ship) ++ di-area-writs `path`/dm/(scot %p ship)/writs :: + ++ di-activity !. + |* a=* + =. cor (pass-activity [%ship ship] a) + di-core + :: ++ di-spin |= [rest=path con=(list content:ha) but=(unit button:ha)] ^- new-yarn:ha @@ -1584,6 +1639,8 @@ =. pact.dm (reduce:di-pact now.bowl diff) =? cor &(=(net.dm %invited) !=(ship our.bowl)) (give-invites ship) + =? di-core &(=(net.dm %invited) !=(ship our.bowl)) + (di-activity [%invite ~] *story:d &) ?- -.q.diff ?(%del %add-react %del-react) (di-give-writs-diff diff) :: @@ -1607,6 +1664,11 @@ ~ =? cor (want-hark %to-us) (emit (pass-yarn new-yarn)) + =/ concern + ?: =(net.dm %invited) [%invite ~] + [%post p.diff now.bowl] + =/ mention (was-mentioned:utils content.memo our.bowl) + =. di-core (di-activity concern content.memo mention) (di-give-writs-diff diff) :: %reply @@ -1637,6 +1699,10 @@ ~ =? cor (want-hark %to-us) (emit (pass-yarn new-yarn)) + =/ top-con [id.writ.u.entry time.writ.u.entry] + =/ concern [%reply [id.q.diff now.bowl] top-con] + =/ mention (was-mentioned:utils content.memo our.bowl) + =. di-core (di-activity concern content.memo mention) (di-give-writs-diff diff) == == @@ -1657,6 +1723,7 @@ =? cor =(our src):bowl (emit (proxy-rsvp:di-pass ok)) ?> |(=(src.bowl ship) =(our src):bowl) + =. cor (pass-activity [%ship ship] [%invite ~] *story:d |) :: TODO hook into archive ?. ok %- (note:wood %odd leaf/"gone {}" ~) diff --git a/desk/app/groups-ui.hoon b/desk/app/groups-ui.hoon index 82ea6dedfc..30e40a9ba8 100644 --- a/desk/app/groups-ui.hoon +++ b/desk/app/groups-ui.hoon @@ -1,4 +1,4 @@ -/- u=ui, g=groups, c=chat, d=channels +/- u=ui, g=groups, c=chat, d=channels, a=activity /+ default-agent, dbug, verb, vita-client :: performance, keep warm /+ mark-warmer @@ -104,10 +104,12 @@ |= =(pole knot) ^- (unit (unit cage)) ?+ pole [~ ~] + [%x %pins ~] ``ui-pins+!>(pins) + :: [%x %init ~] =+ .^([=groups-ui:g =gangs:g] (scry %gx %groups /init/v1/noun)) =+ .^([=unreads:d channels=channels-0:d] (scry %gx %channels /v1/init/noun)) - =+ .^(=chat:u (scry %gx %chat /init/noun)) + =+ .^(chat=chat-0:u (scry %gx %chat /init/noun)) =+ .^(profile=? (scry %gx %profile /bound/loob)) =/ =init-0:u :* groups-ui @@ -118,13 +120,13 @@ chat profile == - ``ui-init+!>(init) + ``ui-init+!>(init-0) [%x %v1 %init ~] =+ .^([=groups-ui:g =gangs:g] (scry %gx %groups /init/v1/noun)) =+ .^([=unreads:d =channels:d] (scry %gx %channels /v2/init/noun)) - =+ .^(=chat:u (scry %gx %chat /init/noun)) + =+ .^(chat=chat-0:u (scry %gx %chat /init/noun)) =+ .^(profile=? (scry %gx %profile /bound/loob)) - =/ =init:u + =/ =init-1:u :* groups-ui gangs channels @@ -133,15 +135,29 @@ chat profile == - ``ui-init-1+!>(init) + ``ui-init-1+!>(init-1) :: [%x %v1 %heads since=?(~ [u=@ ~])] =+ .^(chan=channel-heads:d (scry %gx %channels %v2 %heads (snoc since.pole %channel-heads))) =+ .^(chat=chat-heads:c (scry %gx %chat %heads (snoc since.pole %chat-heads))) ``ui-heads+!>(`mixed-heads:u`[chan chat]) :: - [%x %pins ~] - ``ui-pins+!>(pins) + [%x %v2 %init ~] + =+ .^([=groups-ui:g =gangs:g] (scry %gx %groups /init/v1/noun)) + =+ .^([* =channels:d] (scry %gx %channels /v2/init/noun)) + =+ .^(chat=chat-0:u (scry %gx %chat /init/noun)) + =+ .^(=activity:a (scry %gx %activity /activity/noun)) + =+ .^(profile=? (scry %gx %profile /bound/loob)) + =/ =init:u + :* groups-ui + gangs + channels + activity + pins + [clubs dms invited]:chat + profile + == + ``ui-init-2+!>(init) == :: ++ poke diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index 0d93d171f4..07d06fa077 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -3,7 +3,8 @@ :: note: all subscriptions are handled by the subscriber library so :: we can have resubscribe loop protection. :: -/- g=groups, zero=groups-0, ha=hark, h=heap, d=channels, c=chat, tac=contacts +/- g=groups, zero=groups-0, ha=hark, h=heap, d=channels, c=chat, tac=contacts, + activity /- meta /- e=epic /+ default-agent, verb, dbug @@ -21,13 +22,7 @@ +$ current-state $: %3 groups=net-groups:g - :: - $= volume - $: base=level:v - area=(map flag:g level:v) :: override per group - chan=(map nest:g level:v) :: override per channel - == - :: + =volume:v xeno=gangs:g :: graph -> agent shoal=(map flag:g dude:gall) @@ -94,6 +89,16 @@ ++ emit |=(=card cor(cards [card cards])) ++ emil |=(caz=(list card) cor(cards (welp (flop caz) cards))) ++ give |=(=gift:agent:gall (emit %give gift)) +:: +++ submit-activity + |= =incoming-event:activity + ^+ cor + ?. .^(? %gu /(scot %p our.bowl)/activity/(scot %da now.bowl)/$) + cor + %- emit + =/ =cage [%activity-action !>(`action:activity`[%add incoming-event])] + [%pass /activity/submit %agent [our.bowl %activity] %poke cage] +:: ++ check-known |= =ship ^- ?(%alien %known) @@ -449,6 +454,9 @@ :: [%x %volume ~] ``volume-value+!>(base.volume) + :: + [%x %volume %all ~] + ``noun+!>(volume) :: [%x %volume ship=@ name=@ ~] =/ ship (slav %p ship.pole) @@ -533,6 +541,7 @@ ~ cor [%epic ~] (take-epic sign) [%helm *] cor + [%activity %submit *] cor [%groups %role ~] cor [?(%hark %groups %chat %heap %diary) ~] cor [%cast ship=@ name=@ ~] (take-cast [(slav %p ship.pole) name.pole] sign) @@ -811,6 +820,31 @@ out (~(put in out) who) :: + ++ go-activity + =, activity + |= $= concern + $% [%join =ship] + [%kick =ship] + [%flag-post key=message-key =nest:c group=flag:g] + [%flag-reply key=message-key parent=message-key =nest:c group=flag:g] + [%role =ship roles=(set sect:g)] + [%ask =ship] + == + ^+ go-core + =. cor + %- submit-activity + ^- incoming-event + =, concern + ?- -.concern + %ask [%group-ask ^flag ship] + %join [%group-join ^flag ship] + %kick [%group-kick ^flag ship] + %role [%group-role ^flag ship roles] + %flag-post [%flag-post key nest group] + %flag-reply [%flag-reply key parent nest group] + == + go-core + :: ++ go-channel-hosts ^- (set ship) %- ~(gas in *(set ship)) @@ -1277,6 +1311,8 @@ == == =. cor (emit (pass-hark new-yarn)) + ::TODO want to (go-activity %flag), but we would need + :: a more detailed "key" than just the post-key go-core ++ go-zone-update |= [=zone:g =delta:zone:g] @@ -1430,7 +1466,13 @@ == =? cor go-is-our-bloc (emit (pass-hark new-yarn)) - go-core + =+ ships=~(tap in ships) + |- + ?~ ships go-core + =. go-core + =< ?>(?=(%shut -.cordon.group) .) ::NOTE tmi + (go-activity %ask i.ships) + $(ships t.ships) :: [%del-ships %ask] ?> |(go-is-bloc =(~(tap in q.diff) ~[src.bowl])) @@ -1544,6 +1586,12 @@ == =? cor go-is-our-bloc (emit (pass-hark new-yarn)) + =. go-core + =+ ships=~(tap in ships) + |- + ?~ ships go-core + =. go-core (go-activity %join i.ships) + $(ships t.ships) ?- -.cordon ?(%open %afar) go-core %shut @@ -1585,6 +1633,12 @@ == =? cor go-is-our-bloc (emit (pass-hark new-yarn)) + =. go-core + =+ ships=~(tap in ships) + |- + ?~ ships go-core + =. go-core (go-activity %kick i.ships) + $(ships t.ships) ?: (~(has in ships) our.bowl) go-core(gone &) go-core @@ -1626,7 +1680,11 @@ == =? cor go-is-our-bloc (emit (pass-hark new-yarn)) - go-core + =+ ships=~(tap in ships) + |- + ?~ ships go-core + =. go-core (go-activity %role i.ships sects.diff) + $(ships t.ships) :: %del-sects ?> go-is-bloc @@ -1812,6 +1870,16 @@ =/ ga=gang:g (~(gut by xeno) f [~ ~ ~]) ga-core(flag f, gang ga) :: + ++ ga-activity + =, activity + |= concern=[%group-invite =ship] + ^+ ga-core + =. cor + %- submit-activity + ^- incoming-event + [%group-invite ^flag ship.concern] + ga-core + :: ++ ga-area `wire`/gangs/(scot %p p.flag)/[q.flag] ++ ga-pass |% @@ -1917,7 +1985,7 @@ == =? cor !(~(has by groups) flag) (emit (pass-hark new-yarn)) - ga-core + (ga-activity %group-invite src.bowl) :: == :: diff --git a/desk/desk.bill b/desk/desk.bill index 0b7f1f2fe5..6e12fa326c 100644 --- a/desk/desk.bill +++ b/desk/desk.bill @@ -8,4 +8,5 @@ %channels %channels-server %profile + %activity == diff --git a/desk/lib/activity-json.hoon b/desk/lib/activity-json.hoon new file mode 100644 index 0000000000..4dcc5de36f --- /dev/null +++ b/desk/lib/activity-json.hoon @@ -0,0 +1,519 @@ +/- a=activity, g=groups, c=chat +/+ gj=groups-json, cj=channel-json +=* z ..zuse +|% +++ enjs + =, enjs:format + |% + :: + +| %primitives + ++ ship + |= s=ship:z + s+(scot %p s) + ++ club-id + |= c=id:club:c + s+(scot %uv c) + ++ whom + |= w=whom:a + %+ frond -.w + ?- -.w + %ship (ship p.w) + %club (club-id p.w) + == + :: + ++ msg-key + |= k=message-key:a + %- pairs + :~ id/s+(msg-id id.k) + time+s+(scot %ud time.k) + == + :: + ++ msg-id + |= id=message-id:a + (rap 3 (scot %p p.id) '/' (scot %ud q.id) ~) + :: + ++ time-id + |= =@da + s+`@t`(rsh 4 (scot %ui da)) + :: + +| %basics + :: + ++ string-source + |= s=source:a + ^- cord + ?- -.s + %base 'base' + %group (rap 3 'group/' (flag:enjs:gj flag.s) ~) + %channel (rap 3 'channel/' (nest:enjs:gj nest.s) ~) + :: + %dm + ?- -.whom.s + %ship (rap 3 'ship/' (scot %p p.whom.s) ~) + %club (rap 3 'club/' (scot %uv p.whom.s) ~) + == + :: + %thread + %+ rap 3 + :~ 'thread/' + (nest:enjs:gj channel.s) + '/' + (scot %ud time.key.s) + == + :: + %dm-thread + %+ rap 3 + :~ 'dm-thread/' + ?- -.whom.s + %ship (scot %p p.whom.s) + %club (scot %uv p.whom.s) + == + '/' + (msg-id id.key.s) + == + == + :: + ++ source + |= s=source:a + %- pairs + ?- -.s + %base ~[base/~] + %group ~[group/s/(flag:enjs:gj flag.s)] + %dm ~[dm+(whom whom.s)] + :: + %channel + :~ :- %channel + %- pairs + :~ nest/s/(nest:enjs:gj nest.s) + group/s/(flag:enjs:gj group.s) + == + == + :: + %thread + :~ :- %thread + %- pairs + :~ channel/s/(nest:enjs:gj channel.s) + group/s/(flag:enjs:gj group.s) + key+(msg-key key.s) + == + == + :: + %dm-thread + :~ :- %dm-thread + %- pairs + :~ whom+(whom whom.s) + key+(msg-key key.s) + == + == + == + :: + ++ volume + |= v=volume:a + %- pairs + :~ unreads/b/unreads.v + notify/b/notify.v + == + ++ reads + |= r=reads:a + %- pairs + :~ floor+(time floor.r) + items+(read-items items.r) + == + :: + ++ read-items + |= ri=read-items:a + %- pairs + %+ turn (tap:on-read-items:a ri) + |= [id=time-id:a *] + [(scot %ud id) ~] + :: + ++ unread-point + |= up=unread-point:a + %- pairs + :~ id/s+(msg-id id.up) + time+(time-id time.up) + count/(numb count.up) + notify/b/notify.up + == + ++ activity-summary + |= sum=activity-summary:a + %- pairs + :~ recency+(time newest.sum) + count+(numb count.sum) + notify+b+notify.sum + unread/?~(unread.sum ~ (unread-point u.unread.sum)) + children+a+(turn children.sum |=(s=source:a s/(string-source s))) + == + :: + ++ event + |= e=event:a + %+ frond -<.e + ?- -<.e + %dm-invite (whom whom.e) + :: + %chan-init + %- pairs + :~ channel/s+(nest:enjs:gj channel.e) + group/s+(flag:enjs:gj group.e) + == + :: + ?(%group-kick %group-join %group-ask %group-invite) + %- pairs + :~ group+s+(flag:enjs:gj group.e) + ship+(ship ship.e) + == + :: + %flag-post + %- pairs + :~ key+(msg-key key.e) + channel/s+(nest:enjs:gj channel.e) + group/s+(flag:enjs:gj group.e) + == + :: + %flag-reply + %- pairs + :~ parent+(msg-key parent.e) + key+(msg-key key.e) + channel/s+(nest:enjs:gj channel.e) + group/s+(flag:enjs:gj group.e) + == + :: + %dm-post + %- pairs + :~ key+(msg-key key.e) + whom+(whom whom.e) + content+(story:enjs:cj content.e) + mention/b+mention.e + == + :: + %dm-reply + %- pairs + :~ parent+(msg-key parent.e) + key+(msg-key key.e) + whom+(whom whom.e) + content+(story:enjs:cj content.e) + mention/b+mention.e + == + :: + %post + %- pairs + :~ key+(msg-key key.e) + channel/s+(nest:enjs:gj channel.e) + group/s+(flag:enjs:gj group.e) + content+(story:enjs:cj content.e) + mention/b+mention.e + == + :: + %reply + %- pairs + :~ parent+(msg-key parent.e) + key+(msg-key key.e) + channel/s+(nest:enjs:gj channel.e) + group/s+(flag:enjs:gj group.e) + content+(story:enjs:cj content.e) + mention/b+mention.e + == + :: + %group-role + %- pairs + :~ group/s+(flag:enjs:gj group.e) + ship+(ship ship.e) + roles+a+(turn ~(tap in roles.e) |=(role=sect:g s+role)) + == + == + :: + ++ time-event + |= te=time-event:a + %- pairs + :~ time+(time time.te) + event+(event event.te) + == + +| %collections + :: + ++ stream + |= s=stream:a + %- pairs + %+ turn (tap:on-event:a s) + |= [=time:z e=event:a] + [(scot %ud time) (event e)] + :: + ++ indices + |= ind=indices:a + %- pairs + %+ turn ~(tap by ind) + |= [sc=source:a st=stream:a r=reads:a] + :- (string-source sc) + %- pairs + :~ stream+(stream st) + reads+(reads r) + == + :: + ++ activity + |= ac=activity:a + %- pairs + %+ turn ~(tap by ac) + |= [s=source:a sum=activity-summary:a] + [(string-source s) (activity-summary sum)] + :: + ++ full-info + |= fi=full-info:a + %- pairs + :~ indices+(indices indices.fi) + activity+(activity activity.fi) + settings+(volume-settings volume-settings.fi) + == + ++ volume-settings + |= vs=volume-settings:a + %- pairs + %+ turn ~(tap by vs) + |= [s=source:a v=volume-map:a] + [(string-source s) (volume-map v)] + :: + ++ volume-map + |= vm=volume-map:a + %- pairs + %+ turn ~(tap by vm) + |= [e=event-type:a v=volume:a] + [e (volume v)] + +| %updates + ++ update + |= u=update:a + %+ frond -.u + ?- -.u + %add (added +.u) + %del (source +.u) + %read (read +.u) + %adjust (adjusted +.u) + == + :: + ++ added + |= ad=time-event:a + %- pairs + :~ time+(time time.ad) + event+(event event.ad) + == + :: + ++ read + |= [s=source:a as=activity-summary:a] + %- pairs + :~ source+(source s) + activity+(activity-summary as) + == + :: + ++ adjusted + |= [s=source:a v=(unit volume-map:a)] + %- pairs + :~ source+(source s) + volume+?~(v ~ (volume-map u.v)) + == + -- +:: +++ dejs + =, dejs:format + |% + +| %primitives + ++ id (se %ud) + ++ club-id (se %uv) + ++ ship `$-(json ship:z)`(su ship-rule) + ++ ship-rule ;~(pfix sig fed:ag) + ++ whom + %- of + :~ ship/ship + club/club-id + == + :: + ++ msg-id + ^- $-(json message-id:a) + %- su + %+ cook |=([p=@p q=@] `id:c`[p `@da`q]) + ;~((glue fas) ;~(pfix sig fed:ag) dem:ag) + ++ msg-key + %- ot + :~ id/msg-id + time/(se %ud) + == + ++ event-type + %- perk + :~ %post-mention + %reply-mention + %dm-post-mention + %dm-reply-mention + %post + %reply + %dm-invite + %dm-post + %dm-reply + %flag-post + %flag-reply + %group-ask + %group-join + %group-kick + %group-role + %group-invite + == + +| %action + ++ action + ^- $-(json action:a) + %- of + :~ add/add + del/source + read/read + adjust/adjust + == + :: + ++ add + ^- $-(json incoming-event:a) + %- of + :~ post/post-event + reply/reply-event + chan-init/chan-init-event + dm-invite/whom + dm-post/dm-post-event + dm-reply/dm-reply-event + flag-post/flag-post-event + flag-reply/flag-reply-event + group-ask/group-event + group-join/group-event + group-kick/group-event + group-invite/group-event + group-role/group-role-event + == + :: + ++ adjust + %- ot + :~ source/source + volume/(mu volume-map) + == + :: + ++ read + ^- $-(json [source:a read-action:a]) + %- ot + :~ source/source + action/read-action + == + :: + ++ read-action + %- of + :~ all/ul + item/id + == + :: + +| %basics + ++ source + ^- $-(json source:a) + %- of + :~ base/ul + group/flag:dejs:gj + dm/whom + channel/channel-source + thread/thread-source + dm-thread/dm-thread-source + == + :: + ++ channel-source + %- ot + :~ nest/nest:dejs:cj + group/flag:dejs:gj + == + ++ thread-source + %- ot + :~ key/msg-key + channel/nest:dejs:cj + group/flag:dejs:gj + == + :: + ++ dm-thread-source + %- ot + :~ key/msg-key + whom/whom + == + :: + ++ volume-map + |= jon=^json + :: ^- $-(json volume-map:a) + =/ jom ((om volume) jon) + ~& jom + ~& + %- malt + %+ turn ~(tap by jom) + |* [a=cord b=*] + => .(+< [a b]=+<) + ~& a + [(rash a event-type) b] + ((op event-type volume) jon) + ++ volume + %- ot + :~ unreads/bo + notify/bo + == + :: + ++ chan-init-event + %- ot + :~ channel/nest:dejs:cj + group/flag:dejs:gj + == + :: + ++ post-event + %- ot + :~ key/msg-key + channel/nest:dejs:cj + group/flag:dejs:gj + content/story:dejs:cj + mention/bo + == + :: + ++ reply-event + %- ot + :~ key/msg-key + parent/msg-key + channel/nest:dejs:cj + group/flag:dejs:gj + content/story:dejs:cj + mention/bo + == + :: + ++ dm-post-event + %- ot + :~ key/msg-key + whom/whom + content/story:dejs:cj + mention/bo + == + :: + ++ dm-reply-event + %- ot + :~ key/msg-key + parent/msg-key + whom/whom + content/story:dejs:cj + mention/bo + == + :: + ++ flag-post-event + %- ot + :~ key/msg-key + channel/nest:dejs:cj + group/flag:dejs:gj + == + :: + ++ flag-reply-event + %- ot + :~ key/msg-key + parent/msg-key + channel/nest:dejs:cj + group/flag:dejs:gj + == + :: + ++ group-event + %- ot + :~ group/flag:dejs:gj + ship/ship + == + :: + ++ group-role-event + %- ot + :~ group/flag:dejs:gj + ship/ship + roles/(as (se %tas)) + == + -- +-- diff --git a/desk/lib/channel-utils.hoon b/desk/lib/channel-utils.hoon index 764ac4c87f..b63c836036 100644 --- a/desk/lib/channel-utils.hoon +++ b/desk/lib/channel-utils.hoon @@ -24,18 +24,24 @@ == :: ++ uv-channels-2 - |= =v-channels:c + |= [=v-channels:c full=?] ^- channels:c %- ~(run by v-channels) |= channel=v-channel:c ^- channel:c - %* . *channel:c - posts *posts:c - perm +.perm.channel - view +.view.channel - sort +.sort.channel - order +.order.channel - pending pending.channel + =/ base + %* . *channel:c + perm +.perm.channel + view +.view.channel + sort +.sort.channel + order +.order.channel + pending pending.channel + == + ?. full base + %_ base + posts (uv-posts posts.channel) + net net.channel + remark remark.channel == :: ++ uv-posts diff --git a/desk/lib/mark-warmer.hoon b/desk/lib/mark-warmer.hoon index 57c86e2ba2..b0e51e8eca 100644 --- a/desk/lib/mark-warmer.hoon +++ b/desk/lib/mark-warmer.hoon @@ -1,5 +1,6 @@ /$ init %ui-init %json /$ init-1 %ui-init-1 %json +/$ init-2 %ui-init-2 %json /$ heads %ui-heads %json /$ pins %ui-pins %json /$ groups %groups %json @@ -15,13 +16,13 @@ /$ unreads %channel-unreads %json /$ heads %channel-heads %json /$ posts %channel-posts %json -/$ simple-posts %channel-simple-posts %json /$ post %channel-post %json -/$ simple-post %channel-simple-post %json /$ replies %channel-replies %json -/$ simple-replies %channel-simple-replies %json /$ reply %channel-reply %json -/$ simple-reply %channel-simple-reply %json +/$ simple-posts %channel-simple-posts %json +/$ simple-post %channel-simple-post %json +/$ simple-replies %channel-simple-replies %json +/$ simple-reply %channel-simple-reply %json /$ hidden %hidden-posts %json /$ ch-unreads %chat-unreads %json /$ writ %writ %json @@ -29,4 +30,9 @@ /$ clubs %clubs %json /$ ch-hidden %hidden-messages %json /$ crew %chat-club-crew %json +/$ full-info %activity-full %json +/$ act-update %activity-update %json +/$ act-sum %activity-summary %json +/$ act-vol %activity-settings %json +/$ act-evt %activity-event %json ~ diff --git a/desk/lib/volume.hoon b/desk/lib/volume.hoon index f8bfec3cf8..c52f45295a 100644 --- a/desk/lib/volume.hoon +++ b/desk/lib/volume.hoon @@ -17,6 +17,12 @@ $% [%group flag:g] [%channel nest:g] == ++$ volume + $: base=level + area=(map flag:g level) :: override per group + chan=(map nest:g level) :: override per channel + == +:: :: +fit-level: full "do we want a notification for this" check :: ++ fit-level diff --git a/desk/mar/activity/action.hoon b/desk/mar/activity/action.hoon index 9b63fe85f1..b25ba44eea 100644 --- a/desk/mar/activity/action.hoon +++ b/desk/mar/activity/action.hoon @@ -1,4 +1,5 @@ /- a=activity +/+ aj=activity-json |_ =action:a ++ grad %noun ++ grow @@ -8,5 +9,6 @@ ++ grab |% ++ noun action:a + ++ json action:dejs:aj -- -- diff --git a/desk/mar/activity/event.hoon b/desk/mar/activity/event.hoon index 37c865fa88..01fd898a8c 100644 --- a/desk/mar/activity/event.hoon +++ b/desk/mar/activity/event.hoon @@ -1,9 +1,11 @@ /- a=activity +/+ aj=activity-json |_ =time-event:a ++ grad %noun ++ grow |% ++ noun time-event + ++ json (time-event:enjs:aj time-event) -- ++ grab |% diff --git a/desk/mar/activity/full.hoon b/desk/mar/activity/full.hoon index 04524bd14f..59fe80353f 100644 --- a/desk/mar/activity/full.hoon +++ b/desk/mar/activity/full.hoon @@ -1,9 +1,11 @@ /- a=activity +/+ aj=activity-json |_ =full-info:a ++ grad %noun ++ grow |% ++ noun full-info + ++ json (full-info:enjs:aj full-info) -- ++ grab |% diff --git a/desk/mar/activity/index-unreads.hoon b/desk/mar/activity/index-unreads.hoon deleted file mode 100644 index 6e4884a09b..0000000000 --- a/desk/mar/activity/index-unreads.hoon +++ /dev/null @@ -1,12 +0,0 @@ -/- a=activity -|_ [=index:a =unread-summary:a] -++ grad %noun -++ grow - |% - ++ noun [index unread-summary] - -- -++ grab - |% - ++ noun (pair index:a unread-summary:a) - -- --- diff --git a/desk/mar/activity/settings.hoon b/desk/mar/activity/settings.hoon new file mode 100644 index 0000000000..563e866e35 --- /dev/null +++ b/desk/mar/activity/settings.hoon @@ -0,0 +1,14 @@ +/- a=activity +/+ aj=activity-json +|_ vs=volume-settings:a +++ grad %noun +++ grow + |% + ++ noun vs + ++ json (volume-settings:enjs:aj vs) + -- +++ grab + |% + ++ noun volume-settings:a + -- +-- diff --git a/desk/mar/activity/stream.hoon b/desk/mar/activity/stream.hoon index 7f672eba15..dc1caeb0f4 100644 --- a/desk/mar/activity/stream.hoon +++ b/desk/mar/activity/stream.hoon @@ -1,12 +1,14 @@ /- a=activity -|_ events=(list time-event:a) +/+ aj=activity-json +|_ =stream:a ++ grad %noun ++ grow |% - ++ noun events + ++ noun stream + ++ json (stream:enjs:aj stream) -- ++ grab |% - ++ noun (list event:a) + ++ noun stream:a -- -- diff --git a/desk/mar/activity/summary.hoon b/desk/mar/activity/summary.hoon new file mode 100644 index 0000000000..037aa41eb9 --- /dev/null +++ b/desk/mar/activity/summary.hoon @@ -0,0 +1,14 @@ +/- a=activity +/+ aj=activity-json +|_ =activity:a +++ grad %noun +++ grow + |% + ++ noun activity + ++ json (activity:enjs:aj activity) + -- +++ grab + |% + ++ noun activity:a + -- +-- diff --git a/desk/mar/activity/update.hoon b/desk/mar/activity/update.hoon new file mode 100644 index 0000000000..b27342568d --- /dev/null +++ b/desk/mar/activity/update.hoon @@ -0,0 +1,14 @@ +/- a=activity +/+ aj=activity-json +|_ =update:a +++ grad %noun +++ grow + |% + ++ noun update + ++ json (update:enjs:aj update) + -- +++ grab + |% + ++ noun update:a + -- +-- diff --git a/desk/mar/ui/init-1.hoon b/desk/mar/ui/init-1.hoon index f28d7b87b9..0ea62b5e8a 100644 --- a/desk/mar/ui/init-1.hoon +++ b/desk/mar/ui/init-1.hoon @@ -1,7 +1,6 @@ /- u=ui /+ gj=groups-json, cj=chat-json, dj=channel-json -:: group flag + channel flag -|_ =init:u +|_ init=init-1:u ++ grad %noun ++ grow |% diff --git a/desk/mar/ui/init-2.hoon b/desk/mar/ui/init-2.hoon new file mode 100644 index 0000000000..7c083abd35 --- /dev/null +++ b/desk/mar/ui/init-2.hoon @@ -0,0 +1,31 @@ +/- u=ui +/+ gj=groups-json, cj=chat-json, dj=channel-json, aj=activity-json +|_ =init:u +++ grad %noun +++ grow + |% + ++ noun init + ++ json + =, enjs:format + ^- ^json + %- pairs + :~ groups/(groups-ui:enjs:gj groups.init) + gangs/(gangs:enjs:gj gangs.init) + channels/(channels-2:enjs:dj channels.init) + activity/(activity:enjs:aj activity.init) + pins/a/(turn pins.init whom:enjs:gj) + profile/b/profile.init + :: + :- %chat + %- pairs + :~ clubs/(clubs:enjs:cj clubs.chat.init) + dms/a/(turn ~(tap in dms.chat.init) ship:enjs:gj) + invited/a/(turn ~(tap in invited.chat.init) ship:enjs:gj) + == + == + -- +++ grab + |% + ++ noun init:u + -- +-- diff --git a/desk/sur/activity.hoon b/desk/sur/activity.hoon index 5aa3fdeef4..e307a3545e 100644 --- a/desk/sur/activity.hoon +++ b/desk/sur/activity.hoon @@ -1,82 +1,227 @@ -/- c=channels, g=groups +/- c=channels, ch=chat, g=groups /+ mp=mop-extensions |% -++ on-event ((on time event) lte) -++ ex-event ((mp time event) lte) -++ on-parent ((on time event-parent) lte) ++| %collections +:: $stream: the activity stream comprised of events from various agents +$ stream ((mop time event) lte) -+$ indices (map index [=stream =reads]) -+$ reads - $: floor=time :: latest time above which everything is read - =event-parents :: - == -+$ event-parent [seen=? reply-floor=time] -+$ event-parents ((mop time-id event-parent) lte) -+$ index - $% [%channel channel-concern] - [%dm dm-concern] +:: $indices: the stream and its read data split into various indices ++$ indices (map source index) +:: $volume-settings: the volume settings for each source ++$ volume-settings (map source volume-map) +:: $activity: the current state of activity for each source ++$ activity (map source activity-summary) +:: $full-info: the full state of the activity stream ++$ full-info [=indices =activity =volume-settings] +:: $volume-map: how to badge and notify for each event type ++$ volume-map + $~ default-volumes + (map event-type volume) ++| %actions +:: $action: how to interact with our activity stream +:: +:: actions are only ever performed for and by our selves +:: +:: $add: add an event to the stream +:: $del: remove a source and all its activity +:: $read: mark an event as read +:: $adjust: adjust the volume of an source +:: ++$ action + $% [%add =incoming-event] + [%del =source] + [%read =source =read-action] + [%adjust =source =(unit volume-map)] == -+$ flavor - $? %dm-invite - %dm-post - %dm-post-mention - %dm-reply - %dm-reply-mention - %kick - %join - %post - %post-mention - %reply - %reply-mention - %flag +:: +:: $read-action: mark activity read +:: +:: $item: mark an individual activity as read, indexed by id +:: $event: mark an individual activity as read, indexed by the event itself +:: $all: mark _everything_ as read for this source +:: ++$ read-action + $% [%item id=time-id] + [%event event=incoming-event] + [%all ~] == -+$ flavor-level ?(%notify %default) -+$ volume (map index index-level) -+$ index-level - $~ %soft - $? %loud :: always notify - %soft :: sometimes notify - %hush :: never notify +:: ++| %updates +:: +:: $update: what we hear after an action +:: +:: $add: an event was added to the stream +:: $del: a source and its activity were removed +:: $read: a source's activity state was updated +:: $adjust: the volume of a source was adjusted +:: ++$ update + $% [%add time-event] + [%del =source] + [%read =source =activity-summary] + [%adjust =source volume-map=(unit volume-map)] == +:: ++| %basics +:: $event: a single point of activity, from one of our sources +:: +:: $incoming-event: the event that was sent to us +:: $notified: if this event has been notified +:: $child: if this event is from a child source +:: +$ event - $% [%dm-invite dm-concern] - [%dm-post dm-post-concern content=story:c mention=?] - [%dm-reply dm-reply-concern content=story:c mention=?] - [%kick group-concern =ship] - [%join group-concern =ship] - [%post post-concern content=story:c mention=?] - [%reply reply-concern content=story:c mention=?] - [%flag post-concern] + $: incoming-event + notified=? + child=? == -+$ time-event [=time =event] -+$ group-concern group=flag:g -+$ channel-concern [channel=nest:c group=flag:g] -+$ dm-concern =whom -+$ dm-post-concern [=message-key =whom] -+$ dm-reply-concern [=message-key target=message-key =whom] -+$ post-concern [=message-key channel=nest:c group=flag:g] -+$ reply-concern [=message-key target=message-key channel=nest:c group=flag:g] ++$ incoming-event + $% [%post post-event] + [%reply reply-event] + [%dm-invite =whom] + [%dm-post dm-post-event] + [%dm-reply dm-reply-event] + [%group-ask group=flag:g =ship] + [%group-kick group=flag:g =ship] + [%group-join group=flag:g =ship] + [%group-invite group=flag:g =ship] + [%chan-init channel=nest:c group=flag:g] + [%group-role group=flag:g =ship roles=(set sect:g)] + [%flag-post key=message-key channel=nest:c group=flag:g] + [%flag-reply key=message-key parent=message-key channel=nest:c group=flag:g] + == +:: ++$ post-event + $: key=message-key + channel=nest:c + group=flag:g + content=story:c + mention=? + == +:: ++$ reply-event + $: key=message-key + parent=message-key + channel=nest:c + group=flag:g + content=story:c + mention=? + == +:: ++$ dm-post-event + $: key=message-key + =whom + content=story:c + mention=? + == +:: ++$ dm-reply-event + $: key=message-key + parent=message-key + =whom + content=story:c + mention=? + == +:: +:: $source: where the activity is happening ++$ source + $% [%base ~] + [%group =flag:g] + [%channel =nest:c group=flag:g] + [%thread key=message-key channel=nest:c group=flag:g] + [%dm =whom] + [%dm-thread key=message-key =whom] + == +:: +:: $index: the stream of activity and read state for a source ++$ index [=stream =reads] +:: +:: $reads: the read state for a source +:: +:: $floor: the time of the latest event that was read +:: $items: the set of events above the floor that have been read +:: ++$ reads + $: floor=time + items=read-items + == ++$ read-items ((mop time-id ,~) lte) +:: $activity-summary: the summary of activity for a source +:: +:: $newest: the time of the latest activity read or unread +:: $count: the total number of unread events including children +:: $notify: if there are any notifications here or in children +:: $unread: if the main stream of source is unread: which starting +:: message, how many there are, and if any are notifications +:: $children: the sources nested under this source +:: ++$ activity-summary + $: newest=time + count=@ud + notify=_| + unread=(unit unread-point) + children=(list source) + == ++$ unread-point [message-key count=@ud notify=_|] ++$ volume [unreads=? notify=?] ++| %primitives +$ whom $% [%ship p=ship] - [%club p=@uvH] + [%club p=id:club:ch] == +$ time-id time +$ message-id (pair ship time-id) +$ message-key [id=message-id =time] -+$ action - $% [%add =event] - [%read =index =read-action] - [%adjust =index =index-level] +:: ++$ event-type + $? %chan-init + %post + %post-mention + %reply + %reply-mention + %dm-invite + %dm-post + %dm-post-mention + %dm-reply + %dm-reply-mention + %group-invite + %group-kick + %group-join + %group-ask + %group-role + %flag-post + %flag-reply == -+$ unread-summary - $: newest=time - count=@ud - threads=(list [oldest-unread=time count=@ud]) ++| %helpers ++$ time-event [=time =event] +++ on-event ((on time event) lte) +++ ex-event ((mp time event) lte) +++ on-read-items ((on time ,~) lte) ++| %constants +++ default-volumes + ^~ + ^- (map event-type volume) + %- my + :~ [%post & &] + [%reply & |] + [%dm-reply & &] + [%post-mention & &] + [%reply-mention & &] + [%dm-invite & &] + [%dm-post & &] + [%dm-post-mention & &] + [%dm-reply-mention & &] + [%group-invite & &] + [%group-ask & &] + [%flag-post & &] + [%flag-reply & &] + [%group-kick & |] + [%group-join & |] + [%group-role & |] == -+$ read-action - $% [%thread id=time-id] :: mark a whole thread as read - [%post id=time-id] :: mark an individual post as read - [%all ~] :: mark _everything_ as read +++ old-volumes + ^~ + %- my + :~ [%soft (~(put by default-volumes) %post [& |])] + [%loud (~(run by default-volumes) |=([u=? *] [u &]))] + [%hush (~(run by default-volumes) |=([u=? *] [u |]))] == -+$ full-info [=stream =indices unreads=(map index unread-summary)] --- +-- \ No newline at end of file diff --git a/desk/sur/ui.hoon b/desk/sur/ui.hoon index a0f58dc99c..0233cd7331 100644 --- a/desk/sur/ui.hoon +++ b/desk/sur/ui.hoon @@ -1,28 +1,43 @@ -/- g=groups, d=channels, c=chat +/- g=groups, d=channels, c=chat, a=activity |% +$ init $: groups=groups-ui:g =gangs:g =channels:d - =unreads:d + =activity:a pins=(list whom) =chat profile=? == :: ++$ init-1 + $: groups=groups-ui:g + =gangs:g + =channels:d + =unreads:d + pins=(list whom) + chat=chat-0 + profile=? + == +:: +$ init-0 $: groups=groups-ui:g =gangs:g channels=channels-0:d =unreads:d pins=(list whom) - =chat + chat=chat-0 profile=? == :: +$ mixed-heads [chan=channel-heads:d chat=chat-heads:c] :: +$ chat + $: clubs=(map id:club:c crew:club:c) + dms=(set ship) + invited=(set ship) + == ++$ chat-0 $: clubs=(map id:club:c crew:club:c) dms=(set ship) =unreads:c diff --git a/packages/shared/src/api/initApi.ts b/packages/shared/src/api/initApi.ts index d9534e7498..23ba4164d6 100644 --- a/packages/shared/src/api/initApi.ts +++ b/packages/shared/src/api/initApi.ts @@ -28,17 +28,18 @@ export const getInitData = async () => { const channelsInit = toClientChannelsInit(response.channels); const groups = toClientGroups(response.groups, true); const unjoinedGroups = toClientGroupsFromGangs(response.gangs); - const channelUnreads = toClientUnreads(response.unreads, 'channel'); + // const channelUnreads = toClientUnreads(response.unreads, 'channel'); const dmChannels = toClientDms(response.chat.dms); const groupDmChannels = toClientGroupDms(response.chat.clubs); const invitedDms = toClientDms(response.chat.invited, true); - const talkUnreads = toClientUnreads(response.chat.unreads, 'dm'); + // const talkUnreads = toClientUnreads(response.chat.unreads, 'dm'); return { pins, groups, unjoinedGroups, - unreads: [...channelUnreads, ...talkUnreads], + // unreads: [...channelUnreads, ...talkUnreads], + unreads: [], channels: [...dmChannels, ...groupDmChannels, ...invitedDms], channelPerms: channelsInit, }; diff --git a/packages/shared/src/store/sync.test.ts b/packages/shared/src/store/sync.test.ts index 0696525044..681845dbb0 100644 --- a/packages/shared/src/store/sync.test.ts +++ b/packages/shared/src/store/sync.test.ts @@ -357,19 +357,20 @@ test('syncs init data', async () => { groupsInitData.chat.dms.length + Object.keys(groupsInitData.chat.clubs).length ); - const staleChannels = await db.getStaleChannels(); - expect(staleChannels.slice(0, 10).map((c) => [c.id])).toEqual([ - ['chat/~bolbex-fogdys/watercooler-4926'], - ['chat/~dabben-larbet/hosting-6173'], - ['chat/~bolbex-fogdys/tlon-general'], - ['chat/~bolbex-fogdys/marcom'], - ['heap/~bolbex-fogdys/design-1761'], - ['chat/~bitpyx-dildus/interface'], - ['chat/~bolbex-fogdys/ops'], - ['heap/~dabben-larbet/fanmail-3976'], - ['diary/~bolbex-fogdys/bulletins'], - ['chat/~nocsyx-lassul/bongtable'], - ]); + // TODO: fix when activity integrated + // const staleChannels = await db.getStaleChannels(); + // expect(staleChannels.slice(0, 10).map((c) => [c.id])).toEqual([ + // ['chat/~bolbex-fogdys/watercooler-4926'], + // ['chat/~dabben-larbet/hosting-6173'], + // ['chat/~bolbex-fogdys/tlon-general'], + // ['chat/~bolbex-fogdys/marcom'], + // ['heap/~bolbex-fogdys/design-1761'], + // ['chat/~bitpyx-dildus/interface'], + // ['chat/~bolbex-fogdys/ops'], + // ['heap/~dabben-larbet/fanmail-3976'], + // ['diary/~bolbex-fogdys/bulletins'], + // ['chat/~nocsyx-lassul/bongtable'], + // ]); }); test('syncs thread posts', async () => { diff --git a/packages/shared/src/store/sync.ts b/packages/shared/src/store/sync.ts index e8ed72c49a..f8e4d9709f 100644 --- a/packages/shared/src/store/sync.ts +++ b/packages/shared/src/store/sync.ts @@ -90,6 +90,7 @@ export const syncUnreads = async () => { }; const resetUnreads = async (unreads: db.Unread[]) => { + if (!unreads.length) return; await db.insertUnreads(unreads); await db.setJoinedGroupChannels({ channelIds: unreads diff --git a/packages/shared/src/urbit/activity.ts b/packages/shared/src/urbit/activity.ts new file mode 100644 index 0000000000..acb7722f2f --- /dev/null +++ b/packages/shared/src/urbit/activity.ts @@ -0,0 +1,473 @@ +import _ from 'lodash'; + +import { Story } from './channel'; +import { whomIsDm, whomIsFlag, whomIsMultiDm } from './utils'; + +export type Whom = { ship: string } | { club: string }; + +export type ExtendedEventType = + | 'post' + | 'post-mention' + | 'reply' + | 'reply-mention' + | 'dm-invite' + | 'dm-post' + | 'dm-post-mention' + | 'dm-reply' + | 'dm-reply-mention' + | 'group-ask' + | 'group-join' + | 'group-kick' + | 'group-invite' + | 'group-role' + | 'flag-post' + | 'flag-reply'; + +export type NotificationLevel = 'hush' | 'soft' | 'default' | 'medium' | 'loud'; + +export enum NotificationNames { + loud = 'Notify for all activity', + default = 'Posts, mentions and replies', + medium = 'Posts, mentions and replies ', + soft = 'Only mentions and replies', + hush = 'Do not notify for any activity', +} + +export interface VolumeLevel { + notify: NotificationLevel; + unreads: boolean; +} + +export type Volume = { + unreads: boolean; + notify: boolean; +}; + +export interface MessageKey { + id: string; + time: string; +} + +export interface DmInviteEvent { + 'dm-invite': Whom; +} + +export interface GroupKickEvent { + 'group-kick': { + ship: string; + group: string; + }; +} + +export interface GroupJoinEvent { + 'group-join': { + ship: string; + group: string; + }; +} + +export interface FlagEvent { + flag: { + key: MessageKey; + channel: string; + group: string; + }; +} + +export interface DmPostEvent { + 'dm-post': { + key: MessageKey; + whom: Whom; + content: Story[]; + mention: boolean; + }; +} + +export interface DmReplyEvent { + 'dm-reply': { + parent: MessageKey; + key: MessageKey; + whom: Whom; + content: Story[]; + mention: boolean; + }; +} + +export interface PostEvent { + post: { + key: MessageKey; + group: string; + channel: string; + content: Story[]; + mention: boolean; + }; +} + +export interface ReplyEvent { + reply: { + parent: MessageKey; + key: MessageKey; + group: string; + channel: string; + content: Story[]; + mention: boolean; + }; +} + +export type ActivityEvent = + | DmInviteEvent + | GroupKickEvent + | GroupJoinEvent + | FlagEvent + | DmPostEvent + | DmReplyEvent + | PostEvent + | ReplyEvent; + +export interface PostRead { + seen: boolean; + floor: string; +} + +export interface Reads { + floor: string; + posts: Record; +} + +export interface IndexData { + stream: Stream; + reads: Reads; +} + +export type Source = + | { dm: Whom } + | { base: null } + | { group: string } + | { channel: { nest: string; group: string } } + | { thread: { key: MessageKey; channel: string; group: string } } + | { 'dm-thread': { key: MessageKey; whom: Whom } }; + +export interface MessageKey { + id: string; + time: string; +} + +export interface UnreadPoint extends MessageKey { + count: number; + notify: boolean; +} + +export interface UnreadThread extends UnreadPoint { + 'parent-time': string; +} + +export interface ActivitySummary { + recency: number; + count: number; + notify: boolean; + unread: UnreadPoint | null; + children: string[]; +} + +export type Activity = Record; + +export type Indices = Record; + +export type Stream = Record; + +export type VolumeMap = Partial>; + +export type ReadAction = + | { event: ActivityEvent } + | { item: string } + | { all: null }; + +export interface ActivityReadAction { + source: Source; + action: ReadAction; +} + +export interface ActivityVolumeAction { + source: Source; + volume: VolumeMap | null; +} + +export type ActivityAction = + | { add: ActivityEvent } + | { del: Source } + | { read: ActivityReadAction } + | { adjust: ActivityVolumeAction }; + +export interface ActivityReadUpdate { + read: { + source: Source; + activity: ActivitySummary; + }; +} + +export interface ActivityVolumeUpdate { + adjust: { + source: Source; + volume: VolumeMap | null; + }; +} + +export interface ActivityAddUpdate { + add: { + time: string; + event: ActivityEvent; + }; +} + +export interface ActivityDeleteUpdate { + del: Source; +} + +export type ActivityUpdate = + | ActivityReadUpdate + | ActivityVolumeUpdate + | ActivityDeleteUpdate + | ActivityAddUpdate; + +export interface FullActivity { + indices: Indices; + activity: Activity; +} + +export type VolumeSettings = Record; + +export function sourceToString(source: Source, stripPrefix = false): string { + if ('base' in source) { + return stripPrefix ? '' : 'base'; + } + + if ('group' in source) { + return stripPrefix ? source.group : `group/${source.group}`; + } + + if ('channel' in source) { + return stripPrefix ? source.channel.nest : `channel/${source.channel.nest}`; + } + + if ('dm' in source) { + if ('ship' in source.dm) { + return stripPrefix ? source.dm.ship : `ship/${source.dm.ship}`; + } + + return stripPrefix ? source.dm.club : `club/${source.dm.club}`; + } + + if ('thread' in source) { + const key = `${source.thread.channel}/${source.thread.key.time}`; + return stripPrefix ? key : `thread/${key}`; + } + + if ('dm-thread' in source) { + const prefix = sourceToString({ dm: source['dm-thread'].whom }, true); + const key = `${prefix}/${source['dm-thread'].key.id}`; + return stripPrefix ? key : `dm-thread/${key}`; + } + + throw new Error('Invalid activity source'); +} + +const onEvents: ExtendedEventType[] = [ + 'dm-reply', + 'post-mention', + 'reply-mention', + 'dm-invite', + 'dm-post', + 'dm-post-mention', + 'dm-reply', + 'dm-reply-mention', + 'group-ask', + 'group-invite', + 'flag-post', + 'flag-reply', +]; + +const notifyOffEvents: ExtendedEventType[] = [ + 'reply', + 'group-join', + 'group-kick', + 'group-role', +]; + +const allEvents: ExtendedEventType[] = [ + 'post', + 'post-mention', + 'reply', + 'reply-mention', + 'dm-invite', + 'dm-post', + 'dm-post-mention', + 'dm-reply', + 'dm-reply-mention', + 'group-ask', + 'group-join', + 'group-kick', + 'group-invite', + 'group-role', + 'flag-post', + 'flag-reply', +]; + +export function getUnreadsFromVolumeMap(vmap: VolumeMap): boolean { + return _.some(vmap, (v) => !!v?.unreads); +} + +export function getLevelFromVolumeMap(vmap: VolumeMap): NotificationLevel { + const entries = Object.entries(vmap) as [ExtendedEventType, Volume][]; + if (_.every(entries, ([, v]) => v.notify)) { + return 'loud'; + } + + if (_.every(entries, ([, v]) => !v.notify)) { + return 'hush'; + } + + let isDefault = true; + entries.forEach(([k, v]) => { + if (onEvents.concat('post').includes(k) && !v.notify) { + isDefault = false; + } + + if (notifyOffEvents.includes(k) && v.notify) { + isDefault = false; + } + }); + + if (isDefault) { + return 'medium'; + } + + return 'soft'; +} + +export function getVolumeMap( + level: NotificationLevel, + unreads: boolean +): VolumeMap { + const emptyMap: VolumeMap = {}; + if (level === 'loud') { + return allEvents.reduce((acc, e) => { + acc[e] = { unreads, notify: true }; + return acc; + }, emptyMap); + } + + if (level === 'hush') { + return allEvents.reduce((acc, e) => { + acc[e] = { unreads, notify: false }; + return acc; + }, {} as VolumeMap); + } + + return allEvents.reduce((acc, e) => { + if (onEvents.includes(e)) { + acc[e] = { unreads, notify: true }; + } + + if (notifyOffEvents.includes(e)) { + acc[e] = { unreads, notify: false }; + } + + if (e === 'post') { + acc[e] = { unreads, notify: level === 'medium' || level === 'default' }; + } + + return acc; + }, emptyMap); +} + +export function getDefaultVolumeOption( + source: Source, + settings: VolumeSettings, + group?: string +): { label: string; volume: NotificationLevel } { + const def = 'Use default setting'; + if ('base' in source) { + return { + label: def, + volume: 'default', + }; + } + + const base: NotificationLevel = settings.base + ? getLevelFromVolumeMap(settings.base) + : 'default'; + if ('group' in source || 'dm' in source) { + return { + label: def, + volume: base, + }; + } + + if ('channel' in source && group) { + const groupVolume = settings[`group/${group}`]; + return { + label: groupVolume ? 'Use group default' : def, + volume: groupVolume ? getLevelFromVolumeMap(groupVolume) : base, + }; + } + + if ('thread' in source) { + const channelVolume = settings[`channel/${source.thread.channel}`]; + return channelVolume + ? { + label: 'Use channel default', + volume: getLevelFromVolumeMap(channelVolume), + } + : getDefaultVolumeOption( + { + channel: { + nest: source.thread.channel, + group: source.thread.group, + }, + }, + settings, + group + ); + } + + if ('dm-thread' in source) { + const index = sourceToString({ dm: source['dm-thread'].whom }); + const dmVolume = settings[index]; + return dmVolume + ? { label: 'Use DM default', volume: getLevelFromVolumeMap(dmVolume) } + : getDefaultVolumeOption({ dm: source['dm-thread'].whom }, settings); + } + + return { + label: def, + volume: 'default', + }; +} + +export function stripSourcePrefix(source: string) { + return source.replace(/^[-\w]*\//, ''); +} + +export function stripPrefixes(unreads: Activity) { + return _.mapKeys(unreads, (v, k) => stripSourcePrefix); +} + +export function onlyChats(unreads: Activity) { + return _.pickBy( + unreads, + (v, k) => k.startsWith('chat/') || whomIsDm(k) || whomIsMultiDm(k) + ); +} + +export function getKey(whom: string) { + return whomIsFlag(whom) + ? `channel/chat/${whom}` + : whomIsDm(whom) + ? `ship/${whom}` + : `club/${whom}`; +} + +export function getThreadKey(whom: string, id: string) { + const prefix = whomIsFlag(whom) ? 'thread/chat' : 'dm-thread'; + return `${prefix}/${whom}/${id}`; +} diff --git a/packages/shared/src/urbit/dms.ts b/packages/shared/src/urbit/dms.ts index d4908582ba..2b6aef899d 100644 --- a/packages/shared/src/urbit/dms.ts +++ b/packages/shared/src/urbit/dms.ts @@ -330,7 +330,6 @@ export interface ClubAction { export interface DMInit { clubs: Clubs; dms: string[]; - unreads: DMUnreads; invited: string[]; } diff --git a/packages/shared/src/urbit/ui.ts b/packages/shared/src/urbit/ui.ts index 99541e0798..ecd587647c 100644 --- a/packages/shared/src/urbit/ui.ts +++ b/packages/shared/src/urbit/ui.ts @@ -1,4 +1,5 @@ -import { Channels, Unreads } from './channel'; +import { Activity } from './activity'; +import { Channels } from './channel'; import { DMInit } from './dms'; import { Gangs, Groups } from './groups'; @@ -6,7 +7,7 @@ export interface GroupsInit { groups: Groups; gangs: Gangs; channels: Channels; - unreads: Unreads; + activity: Activity; pins: string[]; chat: DMInit; } diff --git a/packages/shared/src/urbit/utils.ts b/packages/shared/src/urbit/utils.ts index 6d382498b2..3ad2a08681 100644 --- a/packages/shared/src/urbit/utils.ts +++ b/packages/shared/src/urbit/utils.ts @@ -1,4 +1,4 @@ -import { formatUd, formatUv, unixToDa } from '@urbit/aura'; +import { formatUd, formatUv, isValidPatp, unixToDa } from '@urbit/aura'; import { useMemo } from 'react'; import { GroupJoinStatus, GroupPrivacy } from '../db/schema'; @@ -330,6 +330,20 @@ export function whomIsMultiDm(whom: string): boolean { return whom.startsWith(`0v`); } +// ship + term, term being a @tas: lower-case letters, numbers, and hyphens +export function whomIsFlag(whom: string): boolean { + return ( + /^~[a-z-]+\/[a-z]+[a-z0-9-]*$/.test(whom) && isValidPatp(whom.split('/')[0]) + ); +} + +export function whomIsNest(whom: string): boolean { + return ( + /^[a-z]+\/~[a-z-]+\/[a-z]+[a-z0-9-]*$/.test(whom) && + isValidPatp(whom.split('/')[1]) + ); +} + export function useIsDmOrMultiDm(whom: string) { return useMemo(() => whomIsDm(whom) || whomIsMultiDm(whom), [whom]); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bef7eab86..a7aeaa3a1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -972,7 +972,7 @@ importers: specifier: ^2.0.3 version: 2.0.3(@tiptap/core@2.0.3(@tiptap/pm@2.0.3))(@tiptap/pm@2.0.3(@tiptap/core@2.0.3)) '@urbit/api': - specifier: ^2.2.0 + specifier: 2.2.0 version: 2.2.0 any-ascii: specifier: ^0.3.1