mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75bba4f148 | |||
| 7945060a0d | |||
| 61e6e938dc | |||
| 0d0cbb0076 |
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user