diff --git a/frontend/apps/site/pages/api/content-image/[entityType]/[entityId]/[versionId]/media.png.tsx b/frontend/apps/site/pages/api/content-image/[entityType]/[entityId]/[versionId]/media.png.tsx index e8f2d09bc..d2029562f 100644 --- a/frontend/apps/site/pages/api/content-image/[entityType]/[entityId]/[versionId]/media.png.tsx +++ b/frontend/apps/site/pages/api/content-image/[entityType]/[entityId]/[versionId]/media.png.tsx @@ -5,6 +5,7 @@ import { HMBlockNode, HMGroup, HMPublication, + clipContentBlocks, createHmId, getCIDFromIPFSUrl, toHMInlineContent, @@ -268,42 +269,6 @@ function GroupCard({ ) } -// type HMBlockNode = { -// block: HMBlock -// children?: Array -// } - -// HMBlockNodes are recursive values. we want the output to have the same shape, but limit the total number of blocks -// the first blocks will be included up until the totalBlock value is reached -function clipContent( - content: HMBlockNode[] | undefined, - totalBlocks: number, -): HMBlockNode[] | null { - if (!content) return null - const output: HMBlockNode[] = [] - let blocksRemaining: number = totalBlocks - function walk(currentNode: HMBlockNode, outputNode: HMBlockNode[]): void { - if (blocksRemaining <= 0) { - return - } - let newNode: HMBlockNode = { - block: currentNode.block, - children: currentNode.children ? [] : undefined, - } - outputNode.push(newNode) - blocksRemaining-- - if (currentNode.children && newNode.children) { - for (let child of currentNode.children) { - walk(child, newNode.children) - } - } - } - for (let root of content) { - walk(root, output) - } - return output -} - function PublicationCard({ publication, editors, @@ -311,7 +276,7 @@ function PublicationCard({ publication: HMPublication editors: {account: HMAccount | null}[] }) { - const clippedContent = clipContent( + const clippedContent = clipContentBlocks( publication.document?.children, 8, // render a maximum of 8 blocks in the OG image ) diff --git a/frontend/packages/app/models/accounts.ts b/frontend/packages/app/models/accounts.ts index 6a36c3f77..93c72515d 100644 --- a/frontend/packages/app/models/accounts.ts +++ b/frontend/packages/app/models/accounts.ts @@ -84,6 +84,7 @@ export function useSetTrusted( return undefined }, onSuccess: (result, input, ctx) => { + invalidate([queryKeys.FEED, true]) invalidate([queryKeys.GET_ACCOUNT, input.accountId]) invalidate([queryKeys.GET_ALL_ACCOUNTS]) invalidate([queryKeys.GET_PUBLICATION_LIST, 'trusted']) @@ -118,6 +119,7 @@ export function useSetProfile( }, ...opts, // careful to put this above onSuccess so that it overrides opts.onSuccess onSuccess: (accountId, ...rest) => { + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_ACCOUNT, accountId]) invalidate([queryKeys.GET_ALL_ACCOUNTS]) opts?.onSuccess?.(accountId, ...rest) diff --git a/frontend/packages/app/models/changes.ts b/frontend/packages/app/models/changes.ts index 5d094cb9e..09e8f1d97 100644 --- a/frontend/packages/app/models/changes.ts +++ b/frontend/packages/app/models/changes.ts @@ -95,17 +95,63 @@ export function useChange(changeId?: string) { enabled: !!changeId, }) } +export type IPLDRef = { + '/': string +} +export type IPLDBytes = { + '/': { + bytes: string + } +} +export type IPLDNode = IPLDRef | IPLDBytes + +export type ChangeBlob = { + '@type': 'Change' + // action: 'Update', // seems to appear on group changes but not account changes + delegation: IPLDRef + deps: IPLDRef[] + entity: string // entity id like hm://d/123 + hlcTime: number + patch: Partial + sig: IPLDBytes + signer: IPLDRef +} + +export type ProfileSchema = { + alias: string + bio: string + avatar: IPLDRef +} +export enum GroupRole { + Owner = 1, + Editor = 2, +} +export type GroupSchema = { + title: string + description: string + members: Record< + string, // accountId + GroupRole + > + content: Record< + string, // pathName + string // hm://d/123?v=123 + > +} -export function useChangeData(changeId?: string) { +// todo, add KeyDelegationData CommentData and any other JSON blobs +export type ChangeData = ChangeBlob | ChangeBlob // todo: add DocumentSchema +export type BlobData = ChangeData + +export function useBlobData(cid?: string) { return useQuery({ queryFn: async () => { - const res = await fetch( - `http://localhost:${HTTP_PORT}/debug/cid/${changeId}`, - ) + const res = await fetch(`http://localhost:${HTTP_PORT}/debug/cid/${cid}`) const data = await res.json() - return data + console.log('blob data', data) + return data as BlobData }, - queryKey: [queryKeys.CHANGE_DATA, changeId], - enabled: !!changeId, + queryKey: [queryKeys.BLOB_DATA, cid], + enabled: !!cid, }) } diff --git a/frontend/packages/app/models/comments.ts b/frontend/packages/app/models/comments.ts index d59c6ed76..d88e77429 100644 --- a/frontend/packages/app/models/comments.ts +++ b/frontend/packages/app/models/comments.ts @@ -335,6 +335,7 @@ export function useCommentEditor(opts: {onDiscard?: () => void} = {}) { targetDocId && invalidate([queryKeys.PUBLICATION_COMMENTS, targetDocId.eid]) invalidate(['trpc.comments.getCommentDrafts']) + invalidate([queryKeys.FEED]) if (route.key !== 'comment-draft') throw new Error('not in comment-draft route') replace({ diff --git a/frontend/packages/app/models/documents.ts b/frontend/packages/app/models/documents.ts index 323ccae76..143c7e351 100644 --- a/frontend/packages/app/models/documents.ts +++ b/frontend/packages/app/models/documents.ts @@ -18,6 +18,7 @@ import { GRPCClient, GroupVariant, HMBlock, + HMPublication, ListPublicationsResponse, Publication, fromHMBlock, @@ -164,7 +165,7 @@ export function usePublication({ id, version, ...options -}: UseQueryOptions & { +}: UseQueryOptions & { id?: string version?: string }) { @@ -179,7 +180,7 @@ export function queryPublication( grpcClient: GRPCClient, documentId?: string, versionId?: string, -): UseQueryOptions | FetchQueryOptions { +): UseQueryOptions | FetchQueryOptions { return { queryKey: [queryKeys.GET_PUBLICATION, documentId, versionId], enabled: !!documentId, @@ -187,11 +188,15 @@ export function queryPublication( // default is 5. the backend waits ~1s for discovery, so we retry for a little while in case document is on its way. retry: 10, // about 15 seconds total right now - queryFn: () => - grpcClient.publications.getPublication({ + queryFn: async () => { + const pub = await grpcClient.publications.getPublication({ documentId, version: versionId, - }), + }) + const hmPub = hmPublication(pub) + if (!hmPub) throw new Error('Failed to produce HMPublication') + return hmPub + }, } } @@ -337,6 +342,7 @@ export function usePublishDraft( const documentId = result.pub.document?.id const {groupVariant} = result opts?.onSuccess?.(result, variables, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_PUBLICATION_LIST]) invalidate([queryKeys.PUBLICATION_CITATIONS]) invalidate([queryKeys.GET_DRAFT_LIST]) diff --git a/frontend/packages/app/models/groups.ts b/frontend/packages/app/models/groups.ts index 4cd326682..9c8d46414 100644 --- a/frontend/packages/app/models/groups.ts +++ b/frontend/packages/app/models/groups.ts @@ -140,6 +140,7 @@ export function useCreateGroup( }, onSuccess: (result, input, context) => { opts?.onSuccess?.(result, input, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_GROUPS]) }, }) @@ -162,6 +163,7 @@ export function useUpdateGroup( }, onSuccess: (result, input, context) => { opts?.onSuccess?.(result, input, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_GROUPS]) invalidate([queryKeys.GET_GROUP, input.id]) invalidate([queryKeys.GET_GROUPS_FOR_ACCOUNT]) @@ -188,6 +190,7 @@ export function usePublishGroupToSite( }, onSuccess: (result, input, context) => { opts?.onSuccess?.(result, input, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_GROUPS]) invalidate([queryKeys.GET_GROUP, input.groupId]) }, @@ -230,6 +233,7 @@ export function usePublishDocToGroup( }, onSuccess: (result, input, context) => { opts?.onSuccess?.(result, input, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_GROUP_CONTENT, input.groupId]) invalidate([queryKeys.ENTITY_TIMELINE, input.groupId]) invalidate([queryKeys.GET_GROUPS_FOR_DOCUMENT, input.docId]) @@ -259,6 +263,7 @@ export function useRemoveDocFromGroup( }, onSuccess: (result, input, context) => { opts?.onSuccess?.(result, input, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_GROUP_CONTENT, input.groupId]) invalidate([queryKeys.ENTITY_TIMELINE, input.groupId]) invalidate([queryKeys.GET_GROUPS_FOR_DOCUMENT]) @@ -298,6 +303,7 @@ export function useRenameGroupDoc( onSuccess: (result, input, context) => { const docId = unpackDocId(result) opts?.onSuccess?.(result, input, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_GROUP_CONTENT, input.groupId]) invalidate([queryKeys.GET_GROUPS_FOR_DOCUMENT, docId?.docId]) }, @@ -570,6 +576,7 @@ export function useAddGroupMember( }, onSuccess: (result, input, context) => { opts?.onSuccess?.(result, input, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_GROUP_MEMBERS, input.groupId]) }, }) @@ -597,6 +604,7 @@ export function useRemoveGroupMember( }, onSuccess: (result, input, context) => { opts?.onSuccess?.(result, input, context) + invalidate([queryKeys.FEED]) invalidate([queryKeys.GET_GROUP_MEMBERS, input.groupId]) }, }) diff --git a/frontend/packages/app/models/query-keys.ts b/frontend/packages/app/models/query-keys.ts index bef56b7d9..9950ab551 100644 --- a/frontend/packages/app/models/query-keys.ts +++ b/frontend/packages/app/models/query-keys.ts @@ -56,10 +56,12 @@ export const queryKeys = { // changes CHANGE: 'CHANGE', //, changeId: string - CHANGE_DATA: 'CHANGE_DATA', //, changeId: string ALL_ENTITY_CHANGES: 'ALL_ENTITY_CHANGES', //, entityId: string DOCUMENT_TEXT_CONTENT: 'DOCUMENT_TEXT_CONTENT', + // cid + BLOB_DATA: 'BLOB_DATA', //, cid: string + LIGHTNING_ACCOUNT_CHECK: 'LIGHTNING_ACCOUNT_CHECK', //, accountId: string } as const diff --git a/frontend/packages/app/pages/feed.tsx b/frontend/packages/app/pages/feed.tsx index c538d8b4d..68a7c95d5 100644 --- a/frontend/packages/app/pages/feed.tsx +++ b/frontend/packages/app/pages/feed.tsx @@ -4,11 +4,14 @@ import { ActivityEvent, BlocksContent, Group, - Publication, + HMComment, + HMPublication, PublicationContent, UnpackedHypermediaId, + clipContentBlocks, formattedDateLong, hmId, + pluralS, unpackHmId, } from '@mintter/shared' import { @@ -26,12 +29,12 @@ import { styled, toast, } from '@mintter/ui' -import {Verified} from '@tamagui/lucide-icons' -import {ReactNode} from 'react' +import {ArrowRight, Verified} from '@tamagui/lucide-icons' +import {PropsWithChildren, ReactNode} from 'react' import Footer from '../components/footer' import {MainWrapperNoScroll} from '../components/main-wrapper' import {useAccount} from '../models/accounts' -import {useChangeData} from '../models/changes' +import {GroupSchema, ProfileSchema, useBlobData} from '../models/changes' import {useComment} from '../models/comments' import {usePublication} from '../models/documents' import {useFeed} from '../models/feed' @@ -61,22 +64,40 @@ export default function FeedPage() { const FeedItemInnerContainer = styled(YStack, { gap: '$2', backgroundColor: '$color1', - padding: '$3', + paddingTop: '$3', borderRadius: '$2', overflow: 'hidden', + f: 1, + justifyContent: 'flex-start', +}) + +const FeedItemFooter = styled(XStack, { + gap: '$2', + jc: 'center', + backgroundColor: '$color1', + borderTopWidth: 1, + borderColor: '$borderColor', + padding: '$2', }) function FeedItemContainer({ children, linkId, + maxContentHeight, + footer, + header, }: { children: ReactNode linkId?: UnpackedHypermediaId + maxContentHeight?: number + footer?: ReactNode + header?: ReactNode }) { const navigate = useNavigate('push') return ( - + { @@ -90,7 +111,17 @@ function FeedItemContainer({ : undefined } > - {children} + {header} + + {children} + + {footer} ) @@ -121,7 +152,8 @@ function EntityLink({ return ( { + onPress={(e) => { + e.stopPropagation() const route = appRouteOfId(id) if (route) { navigate(route) @@ -149,7 +181,7 @@ function FeedItemHeader({ const navigate = useNavigate('push') const account = useAccount(author) return ( - + - + ) } +function FeedItemCommentContent({comment}: {comment: HMComment}) { + return ( + + + + ) +} + +function HMLinkButton({ + to, + children, +}: PropsWithChildren<{to: UnpackedHypermediaId}>) { + const navigate = useNavigate('push') + return ( + + ) +} + function DocChangeFeedItem({id, eventTime, cid, author}: ChangeFeedItemProps) { const pub = usePublication({id: id.qid, version: cid}) const linkId = hmId('d', id.eid, {version: cid}) return ( - - - updated{' '} - - {pub.data?.document?.title || 'Untitled Document'} - - - } - /> + + updated{' '} + + {pub.data?.document?.title || 'Untitled Document'} + + + } + /> + } + footer={ + + Open Document + + } + > {pub.data && } ) @@ -234,27 +320,39 @@ function GroupContentChangeFeedItem({ /> ) return ( - - - updated{' '} - {linkId ? ( - - {pub.data?.document?.title || 'Untitled Document'} + + updated{' '} + {linkId ? ( + + {pub.data?.document?.title || 'Untitled Document'} + + ) : ( + 'entry' + )}{' '} + in{' '} + + {group.title || 'Untitled Group'} - ) : ( - 'entry' - )}{' '} - in{' '} - - {group.title || 'Untitled Group'} - - - } - /> + + } + /> + } + footer={ + + {linkId && ( + Open Group Document + )} + + } + > {pub.data && } ) @@ -263,12 +361,9 @@ function GroupContentChangeFeedItem({ function GroupChangeFeedItem(props: ChangeFeedItemProps) { const {id, eventTime, cid, author} = props const group = useGroup(id.qid, cid) - const groupChange = useChangeData(cid) + const groupChange = useBlobData(cid) if (groupChange.isInitialLoading) return - if (groupChange.data?.action !== 'Update') - return ( - - ) + // @ts-expect-error const patchEntries = Object.entries(groupChange.data?.patch) if (patchEntries.length === 0) return ( @@ -291,39 +386,137 @@ function GroupChangeFeedItem(props: ChangeFeedItemProps) { /> ) } + // @ts-expect-error + const updates = getPatchedGroupEntries(groupChange.data?.patch || {}) const linkId = hmId('g', id.eid, {version: cid}) return ( - - - updated{' '} - - {group.data?.title || 'Untitled Group'} - - - } - /> - {JSON.stringify(nonContentPatchEntries, null, 2)} + + updated{' '} + + {group.data?.title || 'Untitled Group'} + + + } + /> + } + > + ) } +function UpdatesList({ + updates, +}: { + updates: {labelKey: string; content: ReactNode}[] +}) { + return ( + + {updates.map((entry) => { + return ( + + {entry.labelKey} + {typeof entry.content === 'string' ? ( + {entry.content} + ) : ( + entry.content + )} + + ) + })} + + ) +} + +function getPatchedAccountEntries( + patch: Partial, +): {labelKey: string; content: ReactNode}[] { + const entries: {labelKey: string; content: ReactNode}[] = [] + if (patch.alias) { + entries.push({labelKey: 'Alias', content: patch.alias}) + } + if (patch.bio) { + entries.push({labelKey: 'Bio', content: patch.bio}) + } + if (patch.avatar) { + entries.push({ + labelKey: 'Avatar', + content: ( + + ), + }) + } + return entries +} + +function AccountEntityLink({id}: {id: string}) { + const account = useAccount(id) + return ( + {account.data?.profile?.alias} + ) +} + +function getPatchedGroupEntries( + patch: Partial, +): {labelKey: string; content: ReactNode}[] { + const entries: {labelKey: string; content: ReactNode}[] = [] + if (patch.title) { + entries.push({labelKey: 'Title', content: patch.title}) + } + if (patch.description) { + entries.push({labelKey: 'Description', content: patch.description}) + } + if (patch.members) { + const memberEntries = Object.entries(patch.members) + entries.push({ + labelKey: `Added ${pluralS(memberEntries.length, 'Editor')}`, + content: memberEntries + .map(([accountId, groupRole], index) => { + return [ + , + index === memberEntries.length - 1 ? '' : ', ', + ] + }) + .flat(), + }) + } + return entries +} + function AccountChangeFeedItem({ id, eventTime, cid, author, }: ChangeFeedItemProps) { + const accountChange = useBlobData(cid) + if (accountChange.isInitialLoading) return + // @ts-expect-error + const updates = getPatchedAccountEntries(accountChange.data?.patch || {}) return ( - - + + } + > + ) } @@ -337,30 +530,34 @@ function CommentFeedItem({id, eventTime, cid, author}: CommentFeedItemProps) { version: targetDocId?.version || undefined, }) return ( - - - commented on{' '} - {targetDocId ? ( - - {targetDoc.data?.document?.title} - - ) : ( - 'a document' - )} - - } - /> - {comment.data && ( - <> - - - - - )} + + commented on{' '} + {targetDocId ? ( + + {targetDoc.data?.document?.title} + + ) : ( + 'a document' + )} + + } + /> + } + footer={ + + Open Comment + + } + > + {comment.data && } ) } diff --git a/frontend/packages/app/pages/publication-content-provider.tsx b/frontend/packages/app/pages/publication-content-provider.tsx index c0c021958..b724147a0 100644 --- a/frontend/packages/app/pages/publication-content-provider.tsx +++ b/frontend/packages/app/pages/publication-content-provider.tsx @@ -47,11 +47,15 @@ export function AppPublicationContentProvider({ e.stopPropagation() openUrl(href) }} - onCopyBlock={(blockId: string) => { - if (blockId && reference) { - reference.onCopy(blockId) - } - }} + onCopyBlock={ + reference + ? (blockId: string) => { + if (blockId && reference) { + reference.onCopy(blockId) + } + } + : null + } ipfsBlobPrefix={`${API_FILE_URL}/`} saveCidAsFile={saveCidAsFile} {...overrides} diff --git a/frontend/packages/shared/src/content.ts b/frontend/packages/shared/src/content.ts new file mode 100644 index 000000000..b70568d14 --- /dev/null +++ b/frontend/packages/shared/src/content.ts @@ -0,0 +1,32 @@ +import {HMBlockNode} from './hm-documents' + +// HMBlockNodes are recursive values. we want the output to have the same shape, but limit the total number of blocks +// the first blocks will be included up until the totalBlock value is reached +export function clipContentBlocks( + content: HMBlockNode[] | undefined, + totalBlocks: number, +): HMBlockNode[] | null { + if (!content) return null + const output: HMBlockNode[] = [] + let blocksRemaining: number = totalBlocks + function walk(currentNode: HMBlockNode, outputNode: HMBlockNode[]): void { + if (blocksRemaining <= 0) { + return + } + let newNode: HMBlockNode = { + block: currentNode.block, + children: currentNode.children ? [] : undefined, + } + outputNode.push(newNode) + blocksRemaining-- + if (currentNode.children && newNode.children) { + for (let child of currentNode.children) { + walk(child, newNode.children) + } + } + } + for (let root of content) { + walk(root, output) + } + return output +} diff --git a/frontend/packages/shared/src/index.ts b/frontend/packages/shared/src/index.ts index 21958a7eb..78a253c5c 100644 --- a/frontend/packages/shared/src/index.ts +++ b/frontend/packages/shared/src/index.ts @@ -1,5 +1,6 @@ export * from './client' export * from './constants' +export * from './content' export * from './grpc-client' export * from './hm-documents' export * from './publication-content' diff --git a/frontend/packages/shared/src/publication-content.tsx b/frontend/packages/shared/src/publication-content.tsx index e47ee1137..f635fcff1 100644 --- a/frontend/packages/shared/src/publication-content.tsx +++ b/frontend/packages/shared/src/publication-content.tsx @@ -1,4 +1,4 @@ -import {PartialMessage, Timestamp} from '@bufbuild/protobuf' +import {Timestamp} from '@bufbuild/protobuf' import { API_HTTP_URL, Block, @@ -10,7 +10,7 @@ import { HMInlineContent, HMPublication, MttLink, - Publication, + clipContentBlocks, formatBytes, formattedDate, getCIDFromIPFSUrl, @@ -211,9 +211,11 @@ function debugStyles(debug: boolean = false, color: ColorProp = '$color7') { export function PublicationContent({ publication, + maxBlockCount, ...props }: XStackProps & { - publication: Publication | HMPublication + maxBlockCount?: number + publication: HMPublication }) { const {layoutUnit} = usePublicationContentContext() const allBlocks = publication.document?.children || [] @@ -223,7 +225,10 @@ export function PublicationContent({ (!allBlocks[0]?.children || allBlocks[0]?.children?.length == 0) && allBlocks[0]?.block?.text && allBlocks[0]?.block?.text === publication.document?.title - const displayBlocks = hideTopBlock ? allBlocks.slice(1) : allBlocks + const displayableBlocks = hideTopBlock ? allBlocks.slice(1) : allBlocks + const displayBlocks = maxBlockCount + ? clipContentBlocks(displayableBlocks, maxBlockCount) + : displayableBlocks return ( []) - | HMBlockNode[] - | undefined -}) { +export function BlocksContent({blocks}: {blocks?: HMBlockNode[] | null}) { if (!blocks) return null return ( diff --git a/frontend/packages/ui/src/container.tsx b/frontend/packages/ui/src/container.tsx index d3fee59f8..a147b5129 100644 --- a/frontend/packages/ui/src/container.tsx +++ b/frontend/packages/ui/src/container.tsx @@ -1,4 +1,4 @@ -import {ComponentProps, ReactNode} from 'react' +import {ComponentProps} from 'react' import {XStack, YStack, styled} from 'tamagui' const variants = { @@ -18,7 +18,7 @@ const variants = { export function PageContainer({ children, ...props -}: {children: ReactNode} & ComponentProps) { +}: ComponentProps) { return (