diff --git a/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx b/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx index 7235a9d685..dc06c4fa69 100644 --- a/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx +++ b/apps/tlon-web/src/chat/ChatInput/ChatInput.tsx @@ -4,8 +4,8 @@ import { Cite, Memo, Nest, - PageTuple, PostEssay, + PostTuple, ReplyTuple, } from '@tloncorp/shared/dist/urbit/channel'; import { WritTuple } from '@tloncorp/shared/dist/urbit/dms'; @@ -52,9 +52,10 @@ import { VIDEO_REGEX, createStorageKey, pathToCite, + useIsDmOrMultiDm, useThreadParentId, } from '@/logic/utils'; -import { CacheId } from '@/state/channel/channel'; +import { CacheId, useMyLastMessage } from '@/state/channel/channel'; import { SendMessageVariables, SendReplyVariables, @@ -93,7 +94,7 @@ interface ChatInputProps { cacheId: CacheId; }) => void; dropZoneId: string; - replyingWrit?: PageTuple | WritTuple | ReplyTuple; + replyingWrit?: PostTuple | WritTuple | ReplyTuple; isScrolling: boolean; } @@ -158,6 +159,7 @@ export default function ChatInput({ const threadParentId = useThreadParentId(whom); const [uploadError, setUploadError] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + const isEditing = searchParams.get('edit'); const [replyCite, setReplyCite] = useState(); const groupFlag = useGroupFlag(); const { privacy } = useGroupPrivacy(groupFlag); @@ -178,6 +180,15 @@ export default function ChatInput({ const shipIsBlocked = useIsShipBlocked(whom); const shipHasBlockedUs = useShipHasBlockedUs(whom); const { mutate: unblockShip } = useUnblockShipMutation(); + const isDmOrMultiDM = useIsDmOrMultiDm(whom); + const myLastMessage = useMyLastMessage( + `${!isDmOrMultiDM ? 'chat/' : ''}${whom}` + ); + const lastMessageId = myLastMessage ? myLastMessage.seal.id : ''; + console.log({ + myLastMessage, + lastMessageId, + }); const handleUnblockClick = useCallback(() => { unblockShip({ @@ -396,6 +407,25 @@ export default function ChatInput({ [onSubmit] ), onUpdate: onUpdate.current, + onUpArrow: useCallback( + ({ editor }: HandlerParams) => { + if (lastMessageId && !isEditing) { + setSearchParams( + { + edit: lastMessageId, + }, + { replace: true } + ); + console.log('should blur', { + editor, + }); + editor.commands.blur(); + return true; + } + return false; + }, + [lastMessageId, setSearchParams, isEditing] + ), }); useEffect(() => { @@ -403,15 +433,16 @@ export default function ChatInput({ (autoFocus || replyCite) && !isMobile && messageEditor && - !messageEditor.isDestroyed + !messageEditor.isDestroyed && + !isEditing ) { // end brings the cursor to the end of the content messageEditor?.commands.focus('end'); } - }, [autoFocus, replyCite, isMobile, messageEditor]); + }, [autoFocus, replyCite, isMobile, messageEditor, isEditing]); useEffect(() => { - if (messageEditor && !messageEditor.isDestroyed) { + if (messageEditor && !messageEditor.isDestroyed && !isEditing) { messageEditor?.commands.setContent(draft); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx b/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx index 165a1bff00..e37333234f 100644 --- a/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx +++ b/apps/tlon-web/src/chat/ChatMessage/ChatMessage.tsx @@ -1,6 +1,13 @@ /* eslint-disable react/no-unused-prop-types */ // eslint-disable-next-line import/no-cycle -import { Post, Story, Unread } from '@tloncorp/shared/dist/urbit/channel'; +import { EditorView } from '@tiptap/pm/view'; +import { Editor } from '@tiptap/react'; +import { + Post, + Story, + Unread, + constructStory, +} from '@tloncorp/shared/dist/urbit/channel'; import { DMUnread } from '@tloncorp/shared/dist/urbit/dms'; import { daToUnix } from '@urbit/api'; import { BigInteger } from 'big-integer'; @@ -15,7 +22,8 @@ import React, { useState, } from 'react'; import { useInView } from 'react-intersection-observer'; -import { NavLink, useParams } from 'react-router-dom'; +import { NavLink, useParams, useSearchParams } from 'react-router-dom'; +import { useEventListener } from 'usehooks-ts'; import ChatContent from '@/chat/ChatContent/ChatContent'; import Author from '@/chat/ChatMessage/Author'; @@ -23,12 +31,15 @@ import ChatMessageOptions from '@/chat/ChatMessage/ChatMessageOptions'; import DateDivider from '@/chat/ChatMessage/DateDivider'; import ChatReactions from '@/chat/ChatReactions/ChatReactions'; import Avatar from '@/components/Avatar'; +import MessageEditor, { useMessageEditor } from '@/components/MessageEditor'; import UnreadIndicator from '@/components/Sidebar/UnreadIndicator'; import DoubleCaretRightIcon from '@/components/icons/DoubleCaretRightIcon'; +import { JSONToInlines, diaryMixedToJSON } from '@/logic/tiptap'; import useLongPress from '@/logic/useLongPress'; import { useIsMobile } from '@/logic/useMedia'; import { useIsDmOrMultiDm, whomIsDm, whomIsMultiDm } from '@/logic/utils'; import { + useEditPostMutation, useMarkReadMutation, usePostToggler, useTrackedPostStatus, @@ -49,6 +60,11 @@ import { useChatStore, } from '../useChatStore'; +EditorView.prototype.updateState = function updateState(state) { + if (!(this as any).docView) return; // This prevents the matchesNode error on hot reloads + (this as any).updateStateInner(state, this.state.plugins != state.plugins); //eslint-disable-line +}; + export interface ChatMessageProps { whom: string; time: BigInteger; @@ -136,6 +152,8 @@ const ChatMessage = React.memo< }: ChatMessageProps, ref ) => { + const [searchParms, setSearchParams] = useSearchParams(); + const isEditing = searchParms.get('edit') === writ.seal.id; const { seal, essay } = writ; const container = useRef(null); const { idShip, idTime } = useParams<{ @@ -158,6 +176,7 @@ const ChatMessage = React.memo< const { open: pickerOpen } = useChatDialog(whom, seal.id, 'picker'); const { mutate: markChatRead } = useMarkReadMutation(); const { mutate: markDmRead } = useMarkDmReadMutation(); + const { mutate: editPost } = useEditPostMutation(); const { isHidden: isMessageHidden } = useMessageToggler(seal.id); const { isHidden: isPostHidden } = usePostToggler(seal.id); const isHidden = useMemo( @@ -310,6 +329,58 @@ const ChatMessage = React.memo< isThreadOp, ]); + const onSubmit = useCallback( + async (editor: Editor) => { + // const now = Date.now(); + const editorJson = editor.getJSON(); + const inlineContent = JSONToInlines(editorJson); + const content = constructStory(inlineContent); + + if (content.length === 0) { + return; + } + + editPost({ + nest: `chat/${whom}`, + time: seal.id, + essay: { + ...essay, + author: window.our, + content, + }, + }); + + setSearchParams({}, { replace: true }); + }, + [editPost, whom, seal.id, essay, setSearchParams] + ); + + const messageEditor = useMessageEditor({ + whom: writ.seal.id, + content: diaryMixedToJSON(essay.content), + uploadKey: 'chat-editor-should-not-be-used-for-uploads', + allowMentions: true, + onEnter: useCallback( + ({ editor }) => { + onSubmit(editor); + return true; + }, + [onSubmit] + ), + }); + + useEventListener('keydown', (e) => { + if (e.key === 'Escape' && isEditing) { + setSearchParams({}, { replace: true }); + } + }); + + useEffect(() => { + if (messageEditor && !messageEditor.isDestroyed && isEditing) { + messageEditor.commands.focus('end'); + } + }, [isEditing, messageEditor]); + if (!writ) { return null; } @@ -320,6 +391,7 @@ const ChatMessage = React.memo< className={cn('flex flex-col break-words', { 'pt-3': newAuthor, 'pb-2': isLast, + 'bg-gray-50 px-3 rounded': isEditing, })} onMouseEnter={onOver} onMouseLeave={onOut.current} @@ -341,7 +413,7 @@ const ChatMessage = React.memo< ) : null}
- {isDelivered && ( + {isDelivered && !isEditing && (
-
- {isHidden ? ( - - ) : essay.content ? ( - - ) : null} - {Object.keys(seal.reacts).length > 0 && ( - <> - + +
+ ESC to cancel edit   +   + ENTER to save edit +
+
+ ) : ( +
+ {isHidden ? ( + - - - )} - {replyCount > 0 && !hideReplies ? ( - - cn( - 'default-focus group -ml-2 whitespace-nowrap rounded p-2 text-sm font-semibold text-gray-800', - isActive - ? 'is-active bg-gray-50 [&>div>div>.reply-avatar]:outline-gray-50' - : '', - isLinked - ? '[&>div>div>.reply-avatar]:outline-blue-100 dark:[&>div>div>.reply-avatar]:outline-blue-900' - : '' - ) - } - > -
-
- {replyAuthors.map((ship, i) => ( -
- -
- ))} -
+ ) : null} + {Object.keys(seal.reacts).length > 0 && ( + <> + + + + )} + {replyCount > 0 && !hideReplies ? ( + + cn( + 'default-focus group -ml-2 whitespace-nowrap rounded p-2 text-sm font-semibold text-gray-800', + isActive + ? 'is-active bg-gray-50 [&>div>div>.reply-avatar]:outline-gray-50' + : '', + isLinked + ? '[&>div>div>.reply-avatar]:outline-blue-100 dark:[&>div>div>.reply-avatar]:outline-blue-900' + : '' + ) + } + > +
+
+ {replyAuthors.map((ship, i) => ( +
+ +
+ ))} +
- - {replyCount} {replyCount > 1 ? 'replies' : 'reply'}{' '} - - {unreadDisplay === 'thread' ? ( - - ) : null} - - - Last reply  + + {replyCount} {replyCount > 1 ? 'replies' : 'reply'}{' '} - - {lastReplyTime && - (isToday(lastReplyTime) - ? `${formatDistanceToNow(lastReplyTime)} ago` - : formatRelative(lastReplyTime, new Date()))} + {unreadDisplay === 'thread' ? ( + + ) : null} + + + Last reply  + + + {lastReplyTime && + (isToday(lastReplyTime) + ? `${formatDistanceToNow(lastReplyTime)} ago` + : formatRelative(lastReplyTime, new Date()))} + - -
-
- ) : null} -
+
+ + ) : null} +
+ )} +
{!isDelivered && ( { + setSearchParams({ edit: seal.id }, { replace: true }); + }, [seal, setSearchParams]); + const startThread = () => { navigate(`message/${seal.id}`); }; @@ -244,6 +249,7 @@ function ChatMessageOptions(props: { const showReplyAction = !hideReply; const showCopyAction = !!groupFlag; const showDeleteAction = isAdmin || window.our === essay.author; + const showEditAction = window.our === essay.author; const reactionsCount = Object.keys(seal.reacts).length; const actions: Action[] = []; @@ -386,6 +392,19 @@ function ChatMessageOptions(props: { }); } + if (showEditAction) { + actions.push({ + key: 'edit', + content: ( +
+ + Edit +
+ ), + onClick: edit, + }); + } + // Ensure options menu is visible even if the top of the message has scrolled // off the page. useLayoutEffect(() => { @@ -512,6 +531,14 @@ function ChatMessageOptions(props: { action={() => setDeleteOpen(true)} /> )} + {showEditAction && ( + } + label="Edit" + showTooltip + action={edit} + /> + )}
)} diff --git a/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx b/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx index d8a840fc6e..38a7de9e4b 100644 --- a/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx +++ b/apps/tlon-web/src/chat/ChatScroller/ChatScroller.tsx @@ -1,5 +1,5 @@ import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual'; -import { PageTuple, ReplyTuple } from '@tloncorp/shared/dist/urbit/channel'; +import { PostTuple, ReplyTuple } from '@tloncorp/shared/dist/urbit/channel'; import { WritTuple } from '@tloncorp/shared/dist/urbit/dms'; import { BigInteger } from 'big-integer'; import React, { @@ -168,7 +168,7 @@ const loaderPadding = { export interface ChatScrollerProps { whom: string; - messages: PageTuple[] | WritTuple[] | ReplyTuple[]; + messages: PostTuple[] | WritTuple[] | ReplyTuple[]; onAtTop?: () => void; onAtBottom?: () => void; isLoadingOlder: boolean; diff --git a/apps/tlon-web/src/components/MessageEditor.tsx b/apps/tlon-web/src/components/MessageEditor.tsx index c6bfc2cc4b..f4e23cbd02 100644 --- a/apps/tlon-web/src/components/MessageEditor.tsx +++ b/apps/tlon-web/src/components/MessageEditor.tsx @@ -33,6 +33,11 @@ import { useFileStore } from '@/state/storage'; import getMentionPopup from './Mention/MentionPopup'; +EditorView.prototype.updateState = function updateState(state) { + if (!(this as any).docView) return; // This prevents the matchesNode error on hot reloads + (this as any).updateStateInner(state, this.state.plugins != state.plugins); //eslint-disable-line +}; + export interface HandlerParams { editor: Editor; } @@ -46,6 +51,7 @@ interface useMessageEditorParams { allowMentions?: boolean; onEnter: ({ editor }: HandlerParams) => boolean; onUpdate?: ({ editor }: HandlerParams) => void; + onUpArrow?: ({ editor }: HandlerParams) => boolean; } /** @@ -65,6 +71,7 @@ export function useMessageEditor({ allowMentions = false, onEnter, onUpdate, + onUpArrow, }: useMessageEditorParams) { const calm = useCalm(); const chatBlocks = useChatBlocks(whom); @@ -109,7 +116,7 @@ export function useMessageEditor({ const keyMapExt = useMemo( () => Shortcuts({ - Enter: ({ editor }) => onEnter({ editor } as any), + Enter: ({ editor }) => onEnter({ editor } as HandlerParams), 'Shift-Enter': ({ editor }) => editor.commands.first(({ commands }) => [ () => commands.newlineInCode(), @@ -117,8 +124,11 @@ export function useMessageEditor({ () => commands.liftEmptyBlock(), () => commands.splitBlock(), ]), + ArrowUp: onUpArrow + ? ({ editor }) => onUpArrow({ editor } as HandlerParams) + : () => false, }), - [onEnter] + [onEnter, onUpArrow] ); const extensions = [ diff --git a/apps/tlon-web/src/diary/DiaryChannel.tsx b/apps/tlon-web/src/diary/DiaryChannel.tsx index b8303160d7..5847b2de18 100644 --- a/apps/tlon-web/src/diary/DiaryChannel.tsx +++ b/apps/tlon-web/src/diary/DiaryChannel.tsx @@ -1,5 +1,5 @@ import * as Toast from '@radix-ui/react-toast'; -import { PageTuple } from '@tloncorp/shared/dist/urbit/channel'; +import { PostTuple } from '@tloncorp/shared/dist/urbit/channel'; import { ViewProps } from '@tloncorp/shared/dist/urbit/groups'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Helmet } from 'react-helmet'; @@ -138,7 +138,7 @@ function DiaryChannel({ title }: ViewProps) { return b.compare(a); }); - const itemContent = (i: number, [time, outline]: PageTuple) => ( + const itemContent = (i: number, [time, outline]: PostTuple) => (
{lastArrangedNote === time.toString() && ( diff --git a/apps/tlon-web/src/diary/DiaryList/DiaryGridView.tsx b/apps/tlon-web/src/diary/DiaryList/DiaryGridView.tsx index 34c84bb7ec..df7e732e1d 100644 --- a/apps/tlon-web/src/diary/DiaryList/DiaryGridView.tsx +++ b/apps/tlon-web/src/diary/DiaryList/DiaryGridView.tsx @@ -1,4 +1,4 @@ -import { PageTuple, Post } from '@tloncorp/shared/dist/urbit/channel'; +import { Post, PostTuple } from '@tloncorp/shared/dist/urbit/channel'; import { RenderComponentProps, useInfiniteLoader, @@ -12,11 +12,11 @@ import DiaryGridItem from '@/diary/DiaryList/DiaryGridItem'; import { useIsMobile } from '@/logic/useMedia'; interface DiaryGridProps { - outlines: PageTuple[]; + outlines: PostTuple[]; loadOlderNotes: (atBottom: boolean) => void; } -const masonryItem = ({ data }: RenderComponentProps) => ( +const masonryItem = ({ data }: RenderComponentProps) => ( ); diff --git a/apps/tlon-web/src/heap/HeapChannel.tsx b/apps/tlon-web/src/heap/HeapChannel.tsx index 44956ecb83..0c3729d846 100644 --- a/apps/tlon-web/src/heap/HeapChannel.tsx +++ b/apps/tlon-web/src/heap/HeapChannel.tsx @@ -1,5 +1,5 @@ import * as Toast from '@radix-ui/react-toast'; -import { PageTuple, Post } from '@tloncorp/shared/dist/urbit/channel'; +import { Post, PostTuple } from '@tloncorp/shared/dist/urbit/channel'; import { ViewProps } from '@tloncorp/shared/dist/urbit/groups'; import bigInt from 'big-integer'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -113,7 +113,7 @@ function HeapChannel({ title }: ViewProps) { [hasNextPage, fetchNextPage] ); - const computeItemKey = (_i: number, [time, _curio]: PageTuple) => + const computeItemKey = (_i: number, [time, _curio]: PostTuple) => time.toString(); const thresholds = { diff --git a/apps/tlon-web/src/state/channel/channel.ts b/apps/tlon-web/src/state/channel/channel.ts index a4d7b25e6c..a327f86d69 100644 --- a/apps/tlon-web/src/state/channel/channel.ts +++ b/apps/tlon-web/src/state/channel/channel.ts @@ -13,13 +13,13 @@ import { HiddenPosts, Memo, Nest, - PageTuple, PagedPosts, Perm, Post, PostAction, PostDataResponse, PostEssay, + PostTuple, Posts, Reply, ReplyTuple, @@ -29,12 +29,18 @@ import { UnreadUpdate, Unreads, newChatMap, + newPostTupleArray, } from '@tloncorp/shared/dist/urbit/channel'; +import { + PagedWrits, + Writ, + newWritTupleArray, +} from '@tloncorp/shared/dist/urbit/dms'; import { Flag } from '@tloncorp/shared/dist/urbit/hark'; import { decToUd, udToDec, unixToDa } from '@urbit/api'; import { Poke } from '@urbit/http-api'; import bigInt from 'big-integer'; -import _ from 'lodash'; +import _, { last } from 'lodash'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import create from 'zustand'; @@ -49,10 +55,18 @@ import { isNativeApp } from '@/logic/native'; import useReactQueryScry from '@/logic/useReactQueryScry'; import useReactQuerySubscribeOnce from '@/logic/useReactQuerySubscribeOnce'; import useReactQuerySubscription from '@/logic/useReactQuerySubscription'; -import { checkNest, log, nestToFlag, whomIsFlag } from '@/logic/utils'; +import { + checkNest, + log, + nestToFlag, + useIsDmOrMultiDm, + whomIsFlag, +} from '@/logic/utils'; import queryClient from '@/queryClient'; -import { channelKey } from './keys'; +// eslint-disable-next-line import/no-cycle +import ChatQueryKeys from '../chat/keys'; +import { channelKey, infinitePostsKey } from './keys'; import shouldAddPostToCache from './util'; const POST_PAGE_SIZE = isNativeApp() @@ -679,32 +693,7 @@ export function useInfinitePosts(nest: Nest, initialTime?: string) { retry: false, }); - // we stringify the data here so that we can use it in useMemo's dependency array. - // this is because the data object is a reference and react will not - // do a deep comparison on it. - const stringifiedData = data ? JSON.stringify(data) : JSON.stringify({}); - - const posts: PageTuple[] = useMemo(() => { - if (data === undefined || data.pages.length === 0) { - return []; - } - - return _.uniqBy( - data.pages - .map((page) => { - const pagePosts = Object.entries(page.posts).map( - ([k, v]) => [bigInt(udToDec(k)), v] as PageTuple - ); - - return pagePosts; - }) - .flat(), - ([k]) => k.toString() - ).sort(([a], [b]) => a.compare(b)); - // we disable exhaustive deps here because we add stringifiedData - // to the dependency array to force a re-render when the data changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stringifiedData, data]); + const posts = newPostTupleArray(data); return { data, @@ -2445,7 +2434,7 @@ export function useChannelSearch(nest: string, query: string) { .flat() .map((scItem: ChannelScanItem) => 'post' in scItem - ? ([bigInt(scItem.post.seal.id), scItem.post] as PageTuple) + ? ([bigInt(scItem.post.seal.id), scItem.post] as PostTuple) : ([ bigInt(scItem.reply.reply.seal.id), scItem.reply.reply, @@ -2525,3 +2514,66 @@ export function usePostToggler(postId: string) { isHidden, }; } + +export function useMyLastMessage(whom: string): Post | Writ | null { + const isDmOrMultiDm = useIsDmOrMultiDm(whom); + + const lastMessage = (pages: PagedPosts[] | PagedWrits[]) => { + if (!pages || pages.length === 0) { + return null; + } + + if ('writs' in pages[0]) { + // @ts-expect-error we already have a type guard + const writs = newWritTupleArray({ pages }); + const myWrits = writs.filter( + ([_id, msg]) => msg?.essay.author === window.our + ); + const lastWrit = last(myWrits); + if (!lastWrit) { + return null; + } + + return lastWrit[1]; + } + + if ('posts' in pages[0]) { + // @ts-expect-error we already have a type guard + const posts = newPostTupleArray({ pages }); + const myPosts = posts.filter( + ([_id, msg]) => msg?.essay.author === window.our + ); + const lastPost = last(myPosts); + if (!lastPost) { + return null; + } + + return lastPost[1]; + } + return null; + }; + + if (!isDmOrMultiDm) { + const data = queryClient.getQueryData<{ pages: PagedPosts[] }>( + infinitePostsKey(whom) + ); + if (data) { + const { pages } = data; + return lastMessage(pages); + } + + return null; + } + + const data = queryClient.getQueryData<{ pages: PagedWrits[] }>( + ChatQueryKeys.infiniteDmsKey(whom) + ); + + if (data) { + const { pages } = data; + + return lastMessage(pages); + } + + return null; +} diff --git a/apps/tlon-web/src/state/chat/chat.ts b/apps/tlon-web/src/state/chat/chat.ts index e713545b27..0ad305d155 100644 --- a/apps/tlon-web/src/state/chat/chat.ts +++ b/apps/tlon-web/src/state/chat/chat.ts @@ -31,6 +31,7 @@ import { WritSeal, WritTuple, Writs, + newWritTupleArray, } from '@tloncorp/shared/dist/urbit/dms'; import { GroupMeta } from '@tloncorp/shared/dist/urbit/groups'; import { decToUd, udToDec } from '@urbit/api'; @@ -53,6 +54,7 @@ import useReactQuerySubscription from '@/logic/useReactQuerySubscription'; import { whomIsDm } from '@/logic/utils'; import queryClient from '@/queryClient'; +// eslint-disable-next-line import/no-cycle import { CacheId, PostStatus, TrackedPost } from '../channel/channel'; import ChatKeys from './keys'; import emptyMultiDm, { @@ -1345,22 +1347,7 @@ export function useInfiniteDMs(whom: string, initialTime?: string) { retry: false, }); - const writs: WritTuple[] = useMemo( - () => - _.uniqBy( - data?.pages - ?.map((page) => { - const writPages = Object.entries(page.writs).map( - ([k, v]) => [bigInt(udToDec(k)), v] as WritTuple - ); - return writPages; - }) - .flat() || [], - ([k]) => k.toString() - ).sort(([a], [b]) => a.compare(b)), - - [data] - ); + const writs = newWritTupleArray(data); return { data, diff --git a/desk/app/chat.hoon b/desk/app/chat.hoon index ffe2d19408..cb63dea6c7 100644 --- a/desk/app/chat.hoon +++ b/desk/app/chat.hoon @@ -1549,6 +1549,9 @@ =? cor (want-hark %to-us) (emit (pass-hark new-yarn)) (di-give-writs-diff diff) + :: + %edit + =/ entry=(unit [=time =writ:c]) (get:di-pact p.diff) :: %reply =* delt delta.q.diff diff --git a/desk/sur/chat.hoon b/desk/sur/chat.hoon index 17b699c048..c7d7c504f5 100644 --- a/desk/sur/chat.hoon +++ b/desk/sur/chat.hoon @@ -91,6 +91,7 @@ :: time and meta are units because we won't have it when we send, :: but we need it upon receipt $% [%add =memo:d =kind time=(unit time)] + [%edit =memo:d] [%del ~] [%reply =id meta=(unit reply-meta) =delta:replies] [%add-react =ship =react] diff --git a/packages/shared/src/urbit/channel.ts b/packages/shared/src/urbit/channel.ts index 43094fd913..1ce499c881 100644 --- a/packages/shared/src/urbit/channel.ts +++ b/packages/shared/src/urbit/channel.ts @@ -1,4 +1,5 @@ -import { BigInteger } from 'big-integer'; +import { udToDec } from '@urbit/api'; +import bigInt, { BigInteger } from 'big-integer'; import _ from 'lodash'; import BTree from 'sorted-btree'; @@ -195,7 +196,7 @@ export interface Posts { [time: string]: Post | null; } -export type PageTuple = [BigInteger, Post | null]; +export type PostTuple = [BigInteger, Post | null]; export type ReplyTuple = [BigInteger, Reply | null]; @@ -602,7 +603,32 @@ export function newReplyMap( ); } -export function newPostMap(entries?: PageTuple[], reverse = false): PageMap { +export function newPostTupleArray( + data: + | { + pages: PagedPosts[]; + } + | undefined +): PostTuple[] { + if (data === undefined || data.pages.length === 0) { + return []; + } + + return _.uniqBy( + data.pages + .map((page) => { + const pagePosts = Object.entries(page.posts).map( + ([k, v]) => [bigInt(udToDec(k)), v] as PostTuple + ); + + return pagePosts; + }) + .flat(), + ([k]) => k.toString() + ).sort(([a], [b]) => a.compare(b)); +} + +export function newPostMap(entries?: PostTuple[], reverse = false): PageMap { return new BTree(entries, (a, b) => reverse ? b.compare(a) : a.compare(b) ); diff --git a/packages/shared/src/urbit/dms.ts b/packages/shared/src/urbit/dms.ts index d19bc1ad1a..d4bb1fae9f 100644 --- a/packages/shared/src/urbit/dms.ts +++ b/packages/shared/src/urbit/dms.ts @@ -1,4 +1,6 @@ -import { BigInteger } from 'big-integer'; +import { udToDec } from '@urbit/api'; +import bigInt, { BigInteger } from 'big-integer'; +import _ from 'lodash'; import BTree from 'sorted-btree'; import { @@ -178,6 +180,30 @@ export function newWritMap(entries?: WritTuple[], reverse = false): WritMap { ); } +export function newWritTupleArray( + data: + | { + pages: PagedWrits[]; + } + | undefined +): WritTuple[] { + if (data === undefined || data.pages.length === 0) { + return []; + } + + return _.uniqBy( + data?.pages + ?.map((page) => { + const writPages = Object.entries(page.writs).map( + ([k, v]) => [bigInt(udToDec(k)), v] as WritTuple + ); + return writPages; + }) + .flat() || [], + ([k]) => k.toString() + ).sort(([a], [b]) => a.compare(b)); +} + export interface Pact { writs: WritMap; index: {