Compare commits

...

4 Commits

Author SHA1 Message Date
Tom Moor 75bba4f148 wip: Refactor of activeDocumentId 2026-01-10 14:00:23 -05:00
Tom Moor 7945060a0d refactor 2026-01-10 12:50:18 -05:00
Tom Moor 61e6e938dc refactor 2026-01-10 11:46:43 -05:00
Tom Moor 0d0cbb0076 Add backlinks support for publicly shared documents
Include backlinks in the documents.info API response for publicly shared documents, filtering to only show backlinks that exist within the shared tree.

Changes:
- Add findSourceDocumentIdsInSharedTree method to Relationship model to find backlinks within allowed document IDs
- Export getAllIdsInSharedTree helper from shareLoader for reuse
- Update presentDocument to accept and include backlinkIds in response
- Modify documents.info endpoint to fetch and include backlinks for public shares
- Add backlinkIds property to Document model and update backlinks getter to use it when available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 11:34:16 -05:00
11 changed files with 241 additions and 89 deletions
+11
View File
@@ -4,6 +4,7 @@ import React, { createContext, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import type Model from "~/models/base/Model";
import type { ActionContext as ActionContextType } from "~/types";
export const ActionContext = createContext<ActionContextType | undefined>(
@@ -49,8 +50,18 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
isMenu: false,
isCommandBar: false,
isButton: false,
// Legacy (backward compatibility)
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
// New API
getActiveModels: <T extends Model>(modelClass: typeof Model): T[] =>
stores.ui.getActiveModels<T>(modelClass),
isModelActive: (model: Model): boolean =>
stores.ui.isModelActive(model),
activeModels: stores.ui.activeModels,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
+15 -1
View File
@@ -229,6 +229,13 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
isCollectionDeleted: boolean;
/**
* Array of backlink document IDs for publicly shared documents.
* Only populated when viewing through a share link.
*/
@observable
backlinkIds?: string[];
/**
* Returns the notifications associated with this document.
*/
@@ -347,11 +354,18 @@ export default class Document extends ArchivableModel implements Searchable {
/**
* Returns the documents that link to this document.
* For publicly shared documents, uses the backlinkIds provided by the server.
* For authenticated users, uses the store's backlink data.
*
* @returns documents that link to this document
* @returns documents that link to this document.
*/
@computed
get backlinks(): Document[] {
if (this.backlinkIds) {
return this.backlinkIds
.map((id) => this.store.get(id))
.filter(Boolean) as Document[];
}
return this.store.getBacklinkedDocuments(this.id);
}
+2 -7
View File
@@ -49,7 +49,6 @@ import Contents from "./Contents";
import Editor from "./Editor";
import Header from "./Header";
import Notices from "./Notices";
import PublicReferences from "./PublicReferences";
import References from "./References";
import RevisionViewer from "./RevisionViewer";
@@ -606,15 +605,11 @@ class DocumentScene extends React.Component<Props> {
canComment={abilities.comment}
autoFocus={document.createdAt === document.updatedAt}
>
{shareId ? (
<ReferencesWrapper>
<PublicReferences documentId={document.id} />
</ReferencesWrapper>
) : !revision ? (
{!revision && (
<ReferencesWrapper>
<References document={document} />
</ReferencesWrapper>
) : null}
)}
</Editor>
</>
)}
@@ -1,61 +0,0 @@
import { observer } from "mobx-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { NavigationNode } from "@shared/types";
import Subheading from "~/components/Subheading";
import ReferenceListItem from "./ReferenceListItem";
import useShare from "@shared/hooks/useShare";
type Props = {
documentId: string;
};
function PublicReferences(props: Props) {
const { t } = useTranslation();
const { sharedTree } = useShare();
const { documentId } = props;
// The sharedTree is the entire document tree starting at the shared document
// we must filter down the tree to only the part with the document we're
// currently viewing
const children = useMemo(() => {
let result: NavigationNode[];
function findChildren(node?: NavigationNode) {
if (!node) {
return;
}
if (node.id === documentId) {
result = node.children;
} else {
node.children.forEach((node) => {
if (result) {
return;
}
findChildren(node);
});
}
return result;
}
return findChildren(sharedTree) || [];
}, [documentId, sharedTree]);
if (!children.length) {
return null;
}
return (
<>
<Subheading>{t("Documents")}</Subheading>
{children.map((node) => (
<ReferenceListItem key={node.id} document={node} />
))}
</>
);
}
export default observer(PublicReferences);
+41 -2
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useEffect, useRef, Fragment } from "react";
import { useEffect, useRef, Fragment, useMemo } from "react";
import { Trans } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
@@ -12,23 +12,62 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import ReferenceListItem from "./ReferenceListItem";
import useShare from "@shared/hooks/useShare";
import type { NavigationNode } from "@shared/types";
type Props = {
document: Document;
};
/**
* Hook to get the children of a document, filtering from the shared tree if available.
*
* @param document - the document to get children for.
* @param sharedTree - the shared tree to filter from, if available.
* @returns the children of the document.
*/
function useChildren(
document: Document,
sharedTree: NavigationNode | undefined
): NavigationNode[] {
return useMemo(() => {
if (!sharedTree) {
return document.children;
}
function findChildren(node: NavigationNode): NavigationNode[] | undefined {
if (node.id === document.id) {
return node.children;
}
for (const child of node.children) {
const result = findChildren(child);
if (result) {
return result;
}
}
return undefined;
}
return findChildren(sharedTree) || [];
}, [document.id, document.children, sharedTree]);
}
function References({ document }: Props) {
const { documents } = useStores();
const user = useCurrentUser();
const location = useLocation();
const locationSidebarContext = useLocationSidebarContext();
const { sharedTree } = useShare();
useEffect(() => {
void documents.fetchBacklinks(document.id);
}, [documents, document.id]);
const children = useChildren(document, sharedTree);
const backlinks = document.backlinks;
const children = document.children;
const showBacklinks = !!backlinks.length;
const showChildDocuments = !!children.length;
const shouldFade = useRef(!showBacklinks && !showChildDocuments);
+110 -13
View File
@@ -2,7 +2,9 @@ import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
import type Document from "~/models/Document";
import type Model from "~/models/base/Model";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import { startViewTransition } from "~/utils/viewTransition";
import type RootStore from "./RootStore";
@@ -48,10 +50,7 @@ class UiStore {
systemTheme: SystemTheme;
@observable
activeDocumentId: string | undefined;
@observable
activeCollectionId?: string | null;
activeModels = new Set<Model>();
@observable
observingUserId: string | undefined;
@@ -140,6 +139,84 @@ class UiStore {
});
}
/**
* Add a model instance to the active set.
*
* @param model the model instance to add.
*/
@action
addActiveModel = (model: Model): void => {
this.activeModels.add(model);
};
/**
* Remove a model instance from the active set.
*
* @param model the model instance to remove.
*/
@action
removeActiveModel = (model: Model): void => {
this.activeModels.delete(model);
};
/**
* Get all active models of a specific type.
*
* @param modelClass the model class to filter by.
* @returns array of active models of the specified type.
*/
getActiveModels<T extends Model>(modelClass: typeof Model): T[] {
return Array.from(this.activeModels).filter(
(model) => model.constructor === modelClass
) as T[];
}
/**
* Check if a model instance is in the active set.
*
* @param model the model instance to check.
* @returns true if the model is active.
*/
isModelActive(model: Model): boolean {
return this.activeModels.has(model);
}
/**
* Clear all active models, or only models of a specific type.
*
* @param modelClass optional model class to filter by.
*/
@action
clearActiveModels(modelClass?: typeof Model): void {
if (modelClass) {
const modelsToRemove = this.getActiveModels(modelClass);
modelsToRemove.forEach((model) => this.activeModels.delete(model));
} else {
this.activeModels.clear();
}
}
/**
* Get the most recently added model of a specific type (primary).
*
* @param modelClass the model class to filter by.
* @returns the most recently added model of the specified type.
*/
getPrimaryActiveModel<T extends Model>(modelClass: typeof Model): T | undefined {
const models = this.getActiveModels<T>(modelClass);
return models[models.length - 1];
}
@computed
get activeDocumentId(): string | undefined {
return this.getPrimaryActiveModel<Document>(Document)?.id;
}
@computed
get activeCollectionId(): string | undefined {
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
}
@action
setTheme = (theme: Theme) => {
startViewTransition(() => {
@@ -152,17 +229,28 @@ class UiStore {
@action
setActiveDocument = (document: Document | string): void => {
let model: Document | undefined;
if (typeof document === "string") {
this.activeDocumentId = document;
this.observingUserId = undefined;
model = this.rootStore.documents.get(document);
} else {
model = document;
}
if (!model) {
return;
}
this.activeDocumentId = document.id;
this.clearActiveModels(Document);
this.addActiveModel(model);
this.observingUserId = undefined;
if (document.isActive) {
this.activeCollectionId = document.collectionId;
if (model.isActive && model.collectionId) {
const collection = this.rootStore.collections.get(model.collectionId);
if (collection) {
this.clearActiveModels(Collection);
this.addActiveModel(collection);
}
}
};
@@ -182,7 +270,16 @@ class UiStore {
@action
setActiveCollection = (collectionId: string | undefined): void => {
this.activeCollectionId = collectionId;
if (collectionId === undefined || collectionId === null) {
this.clearActiveModels(Collection);
return;
}
const model = this.rootStore.collections.get(collectionId);
if (model) {
this.clearActiveModels(Collection);
this.addActiveModel(model);
}
};
@action
@@ -192,12 +289,12 @@ class UiStore {
@action
clearActiveDocument = (): void => {
this.activeDocumentId = undefined;
this.clearActiveModels(Document);
this.observingUserId = undefined;
// Unset when navigating away from a document (e.g. to another document, home, settings, etc.)
// Next document's onMount will set the right activeCollectionId.
this.activeCollectionId = undefined;
this.clearActiveModels(Collection);
};
@action
+9
View File
@@ -8,6 +8,7 @@ import type {
} from "@shared/types";
import type RootStore from "~/stores/RootStore";
import type { SidebarContextType } from "./components/Sidebar/components/SidebarContext";
import type Model from "./models/base/Model";
import type Document from "./models/Document";
import type FileOperation from "./models/FileOperation";
import type Pin from "./models/Pin";
@@ -96,8 +97,16 @@ export type ActionContext = {
isCommandBar: boolean;
isButton: boolean;
sidebarContext?: SidebarContextType;
// Legacy (backward compatibility) - returns primary active model's ID
activeCollectionId?: string | undefined;
activeDocumentId: string | undefined;
// New API - work directly with Model instances
getActiveModels: <T extends Model>(modelClass: typeof Model) => T[];
isModelActive: (model: Model) => boolean;
activeModels: ReadonlySet<Model>;
currentUserId: string | undefined;
currentTeamId: string | undefined;
location: Location;
+9 -1
View File
@@ -236,7 +236,15 @@ export async function loadShareWithParent({
return { share, parentShare };
}
function getAllIdsInSharedTree(sharedTree: NavigationNode | null): string[] {
/**
* Recursively extracts all document IDs from a shared tree navigation node.
*
* @param sharedTree The navigation node representing the shared tree.
* @returns Array of all document IDs in the tree.
*/
export function getAllIdsInSharedTree(
sharedTree: NavigationNode | null
): string[] {
if (!sharedTree) {
return [];
}
+26 -3
View File
@@ -51,10 +51,10 @@ class Relationship extends IdModel<
type: RelationshipType;
/**
* Find all backlinks for a document that the user has access to
* Find all backlinks for a document that the user has access to.
*
* @param documentId The document ID to find backlinks for
* @param user The user to check access for
* @param documentId The document ID to find backlinks for.
* @param user The user to check access for.
* @deprecated
*/
public static async findSourceDocumentIdsForUser(
@@ -81,6 +81,29 @@ class Relationship extends IdModel<
return documents.map((doc) => doc.id);
}
/**
* Find all backlinks for a document that are within a shared tree.
*
* @param documentId The document ID to find backlinks for.
* @param allowedDocumentIds Array of document IDs that are accessible in the shared tree.
* @returns Array of document IDs that link to the target document and are within the shared tree.
*/
public static async findSourceDocumentIdsInSharedTree(
documentId: string,
allowedDocumentIds: string[]
) {
const relationships = await this.findAll({
attributes: ["reverseDocumentId"],
where: {
documentId,
type: RelationshipType.Backlink,
reverseDocumentId: allowedDocumentIds,
},
});
return relationships.map((relationship) => relationship.reverseDocumentId);
}
}
export default Relationship;
+3
View File
@@ -16,6 +16,8 @@ type Options = {
includeData?: boolean;
/** Include the updatedAt timestamp for public documents. */
includeUpdatedAt?: boolean;
/** Array of backlink document IDs to include in the response. */
backlinkIds?: string[];
};
async function presentDocument(
@@ -75,6 +77,7 @@ async function presentDocument(
parentDocumentId: undefined,
lastViewedAt: undefined,
isCollectionDeleted: undefined,
backlinkIds: options?.backlinkIds,
};
if (!!document.views && document.views.length > 0) {
+15 -1
View File
@@ -78,7 +78,10 @@ import { getTeamFromContext } from "@server/utils/passport";
import { assertPresent } from "@server/validation";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
import { loadPublicShare } from "@server/commands/shareLoader";
import {
loadPublicShare,
getAllIdsInSharedTree,
} from "@server/commands/shareLoader";
const router = new Router();
@@ -583,10 +586,21 @@ router.post(
isPublic = cannot(user, "read", document);
// Get backlinks that are within the shared tree
let backlinkIds: string[] | undefined;
if (result.sharedTree) {
const allowedDocumentIds = getAllIdsInSharedTree(result.sharedTree);
backlinkIds = await Relationship.findSourceDocumentIdsInSharedTree(
document.id,
allowedDocumentIds
);
}
serializedDocument = await presentDocument(ctx, document, {
isPublic,
shareId,
includeUpdatedAt: result.share.showLastUpdated,
backlinkIds,
});
} else {
if (!user) {