Hosted onbitcointuesdaymadrid.hyper.mediavia theHypermedia Protocol

    Overview

      When a user publishes a new document (first publish only), automatically add an Embed block (Card view) linking to the child document at the end of its immediate parent document.

    Requirements

      Link Type: Embed block with Card view

      Parent always exists: If we have <account_id>/informes/foo, we must have <account_id>/informes

      Root level: Home document always exists for <account_id>/foo

      Nested levels: Only add link to immediate parent

      Link position: Absolute end of document (after all content)

      First publish only: Don't add link when editing existing documents

      Skip conditions:

        Link to child already exists in parent

        Parent has Query block that includes itself (self-referential)

      Duplicates: Skip adding if link already exists

      User consent: Add "X" button in publish popover to opt-out per parent

      Draft handling: If parent has draft, add to draft instead of publishing

    Phase 1: Core Logic - Determine if Auto-Link is Needed

      File: frontend/apps/desktop/src/models/documents.ts

      Create a new function shouldAutoLinkToParent():

      async function shouldAutoLinkToParent(
        childId: UnpackedHypermediaId,
        parentDocument: HMDocument | null
      ): Promise<boolean>
      

      Logic:

        Check if this is a first publish (editId is null)

        Check if child has a parent (path length > 0, or if path is empty, parent is home document)

        Check parent document content for:

          Existing embed/link to the child → skip

          Query block that includes the child (self-referential query where space/path is empty or matches parent) → skip

        Return true if none of the skip conditions apply

    Phase 2: Check for Existing Parent Draft

      File: frontend/apps/desktop/src/app-drafts.ts

      Add a new tRPC procedure drafts.findByEdit:

      findByEdit: t.procedure
        .input(z.object({
          editUid: z.string(),
          editPath: z.array(z.string()),
        }))
        .query(({input}) => {
          return draftIndex?.find(d =>
            d.editUid === input.editUid &&
            pathMatches(d.editPath || [], input.editPath)
          ) || null
        })
      

    Phase 3: Add Link to Parent Draft (if draft exists)

      File: frontend/apps/desktop/src/models/documents.ts

      Create function addLinkToParentDraft():

      async function addLinkToParentDraft(
        parentDraftId: string,
        childId: UnpackedHypermediaId
      ): Promise<void>
      

      Logic:

        Fetch draft via client.drafts.get.query(parentDraftId)

        Create new embed block in editor format:

          {
            id: nanoid(10),
            type: 'embed',
            props: {
              url: packHmId(childId),
              view: 'Card',
              defaultOpen: 'false'
            },
            content: [],
            children: []
          }
          

        Append block to end of draft.content

        Write back via client.drafts.write.mutate({...draft, content: updatedContent})

        Cache invalidation happens automatically

      Add TODO comment:

      // TODO: If user discards this draft later, the auto-link will be lost.
      // Discuss with team whether we should warn user or handle this differently.
      

    Phase 4: Add Link to Parent Document (if no draft exists)

      File: frontend/apps/desktop/src/models/documents.ts

      Create function publishLinkToParentDocument():

      async function publishLinkToParentDocument(
        parentId: UnpackedHypermediaId,
        parentDocument: HMDocument,
        childId: UnpackedHypermediaId,
        signingKeyName: string
      ): Promise<HMDocument>
      

      Logic:

        Find the last root-level block in parent document

        Generate new block ID

        Create DocumentChange operations in correct order:

          MoveBlock to position the new block at the end (after last block)

          ReplaceBlock to create the embed block

        Call grpcClient.documents.createDocumentChange() with:

          signingKeyName

          account: parentId.uid

          path: parentId.path

          baseVersion: parentDocument.version

          changes: [moveBlock, replaceBlock]

      Document changes order:

      const changes = [
        {
          moveBlock: {
            blockId: generatedBlockId,
            parent: '', // root level
            leftSibling: lastBlockId, // after the last existing block
          }
        },
        {
          replaceBlock: {
            block: {
              id: generatedBlockId,
              type: 'Embed',
              link: packHmId(childId),
              attributes: { view: 'Card' }
            }
          }
        }
      ]
      

    Phase 5: Integrate into Publish Workflow

      File: frontend/apps/desktop/src/components/publish-draft-button.tsx

      New state:

      const [parentPublishInfo, setParentPublishInfo] = useState<{
        parentId: UnpackedHypermediaId
        hasDraft: boolean
        draftId?: string
        willAddLink: boolean
        optedOut: boolean
      } | null>(null)
      

      On publish flow modification:

        Before publishing child, compute parentPublishInfo:

          Get parent ID from child's destination path

          Check if parent has existing draft

          Check if should auto-link (using shouldAutoLinkToParent)

        After child publishes successfully:

          if (parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut) {
            if (parentPublishInfo.hasDraft && parentPublishInfo.draftId) {
              await addLinkToParentDraft(parentPublishInfo.draftId, childResultId)
            } else {
              const parentDoc = await publishLinkToParentDocument(
                parentPublishInfo.parentId,
                parentDocument,
                childResultId,
                signingAccountId
              )
              // Push parent along with child
              pushResource(hmId(parentDoc.account, {
                path: entityQueryPathToHmIdPath(parentDoc.path),
                version: parentDoc.version
              }))
            }
          }
          

    Phase 6: UI Changes - Publish Popover

      File: frontend/apps/desktop/src/components/publish-draft-button.tsx

      Modify "You are publishing" section (parent first, then child):

      {/* You are publishing section */}
      <div className="flex flex-col gap-1">
        <p className="text-sm font-medium">You are publishing</p>
      
        {/* Parent document first (if auto-linking) */}
        {parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut && (
          <PublishItem
            url={parentUrl}
            icon={<Document size={12} />}
            label={parentPublishInfo.hasDraft ? "(adding link to draft)" : "(adding link)"}
            onRemove={() => setParentPublishInfo(prev =>
              prev ? {...prev, optedOut: true} : null
            )}
          />
        )}
      
        {/* Child document (current) - always shown */}
        {documentUrl && (
          <PublishItem url={documentUrl} icon={<Document size={12} />} />
        )}
      </div>
      

      New component PublishItem:

      function PublishItem({
        url,
        icon,
        label,
        onRemove
      }: {
        url: string
        icon: React.ReactNode
        label?: string
        onRemove?: () => void
      }) {
        return (
          <div className="flex items-center gap-1 group">
            <span className="shrink-0">{icon}</span>
            <span className="text-xs truncate" style={{direction: 'rtl', textAlign: 'left'}}>
              {url}
            </span>
            {label && <span className="text-xs text-muted-foreground">{label}</span>}
            {onRemove && (
              <Button
                size="iconSm"
                variant="ghost"
                className="opacity-0 group-hover:opacity-100 ml-auto"
                onClick={onRemove}
              >
                <X size={12} />
              </Button>
            )}
          </div>
        )
      }
      

    Phase 7: Push Workflow Updates

      File: frontend/apps/desktop/src/components/publish-draft-button.tsx

      In the onSuccess callback of usePublishResource:

      onSuccess: async (resultDoc, input) => {
        if (pushOnPublish.data === 'never') return
      
        const [setPushStatus, pushStatus] = writeableStateStream<PushResourceStatus | null>(null)
        const childResultId = hmId(resultDoc.account, {
          path: entityQueryPathToHmIdPath(resultDoc.path),
          version: resultDoc.version,
        })
      
        // Handle parent auto-link
        let parentResultDoc: HMDocument | null = null
        if (parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut) {
          if (parentPublishInfo.hasDraft && parentPublishInfo.draftId) {
            // Add to draft - no push needed for parent
            await addLinkToParentDraft(parentPublishInfo.draftId, childResultId)
          } else {
            // Publish to parent - will need to push both
            parentResultDoc = await publishLinkToParentDocument(...)
          }
        }
      
        // Push child
        const childPushPromise = pushResource(childResultId, undefined, setPushStatus)
      
        // Push parent if we published changes to it
        if (parentResultDoc) {
          const parentResultId = hmId(parentResultDoc.account, {
            path: entityQueryPathToHmIdPath(parentResultDoc.path),
            version: parentResultDoc.version,
          })
          pushResource(parentResultId) // Fire and forget, or chain with Promise.all
        }
      
        toast.promise(childPushPromise, {...})
      }
      

    Phase 8: Helper Functions

      File: frontend/apps/desktop/src/models/documents.ts

      Check for existing link to child:

      function documentContainsLinkToChild(
        document: HMDocument,
        childId: UnpackedHypermediaId
      ): boolean {
        const childUrl = packHmId(childId)
        // Recursively search all blocks for embed/link to childUrl
        function searchBlocks(nodes: HMBlockNode[]): boolean {
          for (const node of nodes) {
            if (node.block.type === 'Embed' && node.block.link === childUrl) return true
            // Also check inline annotations for links
            if (node.block.annotations) {
              for (const ann of node.block.annotations) {
                if ((ann.type === 'Link' || ann.type === 'Embed') && ann.link === childUrl) return true
              }
            }
            if (node.children && searchBlocks(node.children)) return true
          }
          return false
        }
        return searchBlocks(document.content || [])
      }
      

      Check for self-referential Query block:

      function documentHasSelfQuery(
        document: HMDocument,
        documentId: UnpackedHypermediaId
      ): boolean {
        function searchBlocks(nodes: HMBlockNode[]): boolean {
          for (const node of nodes) {
            if (node.block.type === 'Query') {
              const query = node.block.attributes?.query
              if (query?.includes) {
                for (const inc of query.includes) {
                  // Self-referential if space is empty or matches document
                  const isSpaceMatch = !inc.space || inc.space === documentId.uid
                  const isPathMatch = !inc.path || inc.path === hmIdPathToEntityQueryPath(documentId.path)
                  if (isSpaceMatch && isPathMatch) return true
                }
              }
            }
            if (node.children && searchBlocks(node.children)) return true
          }
          return false
        }
        return searchBlocks(document.content || [])
      }
      

    File Changes Summary

      | File | Changes | |------|---------| | frontend/apps/desktop/src/app-drafts.ts | Add drafts.findByEdit procedure | | frontend/apps/desktop/src/models/documents.ts | Add helper functions: shouldAutoLinkToParent, addLinkToParentDraft, publishLinkToParentDocument, documentContainsLinkToChild, documentHasSelfQuery | | frontend/apps/desktop/src/components/publish-draft-button.tsx | Add parent tracking state, modify UI to show parent in "You are publishing" section with opt-out X button, update publish flow to handle parent auto-link |

    Edge Cases Handled

      ✅ Parent has existing draft → add link to draft, don't publish parent

      ✅ Parent has no draft → publish new version of parent with link

      ✅ Link already exists → skip adding

      ✅ Parent has Query block to itself → skip adding

      ✅ User opts out via X button → skip auto-link

      ✅ First publish only → edits don't trigger auto-link

      ✅ Push workflow → push both child and parent (if parent was published)

      ✅ Draft discard edge case → TODO note added