Merge branch 'tom/socket-refactor'
This commit is contained in:
@@ -25,7 +25,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
|
||||
@@ -49,8 +49,8 @@ function DocumentListItem(
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const currentTeam = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
const {
|
||||
@@ -70,7 +70,7 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = usePolicy(currentTeam.id);
|
||||
const can = usePolicy(team);
|
||||
const canCollection = usePolicy(document.collectionId);
|
||||
|
||||
return (
|
||||
@@ -96,7 +96,7 @@ function DocumentListItem(
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy.id !== currentUser.id && (
|
||||
{document.isBadgedNew && document.createdBy.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
|
||||
@@ -33,7 +33,7 @@ type Props = {
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(document.id);
|
||||
const can = usePolicy(document);
|
||||
const opts = {
|
||||
userName: event.actor.name,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ function AppSidebar() {
|
||||
const { documents } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user.isViewer) {
|
||||
|
||||
@@ -44,7 +44,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const canUpdate = usePolicy(collection.id).update;
|
||||
const canUpdate = usePolicy(collection).update;
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const inStarredSection = useStarredContext();
|
||||
|
||||
@@ -25,7 +25,7 @@ function CollectionLinkChildren({
|
||||
expanded,
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
const { showToast } = useToasts();
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents } = useStores();
|
||||
|
||||
@@ -39,7 +39,7 @@ function DraggableCollectionLink({
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
collection.id === ui.activeCollectionId && !locationStateStarred
|
||||
);
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Drop to reorder collection
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
import invariant from "invariant";
|
||||
import { find } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import withStores from "~/components/withStores";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
|
||||
|
||||
type SocketWithAuthentication = Socket & {
|
||||
authenticated?: boolean;
|
||||
};
|
||||
|
||||
export const SocketContext = React.createContext<SocketWithAuthentication | null>(
|
||||
null
|
||||
);
|
||||
|
||||
type Props = RootStore;
|
||||
|
||||
@observer
|
||||
class SocketProvider extends React.Component<Props> {
|
||||
@observable
|
||||
socket: SocketWithAuthentication | null;
|
||||
|
||||
componentDidMount() {
|
||||
this.createConnection();
|
||||
document.addEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
document.removeEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
checkConnection = () => {
|
||||
if (this.socket?.disconnected && getPageVisible()) {
|
||||
// null-ifying this reference is important, do not remove. Without it
|
||||
// references to old sockets are potentially held in context
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.createConnection();
|
||||
}
|
||||
};
|
||||
|
||||
createConnection = () => {
|
||||
this.socket = io(window.location.origin, {
|
||||
path: "/realtime",
|
||||
transports: ["websocket"],
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
});
|
||||
invariant(this.socket, "Socket should be defined");
|
||||
|
||||
this.socket.authenticated = false;
|
||||
const {
|
||||
auth,
|
||||
toasts,
|
||||
documents,
|
||||
collections,
|
||||
groups,
|
||||
pins,
|
||||
stars,
|
||||
memberships,
|
||||
policies,
|
||||
presence,
|
||||
views,
|
||||
fileOperations,
|
||||
} = this.props;
|
||||
if (!auth.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
// immediately send current users token to the websocket backend where it
|
||||
// is verified, if all goes well an 'authenticated' message will be
|
||||
// received in response
|
||||
this.socket?.emit("authentication", {
|
||||
token: auth.token,
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", () => {
|
||||
// when the socket is disconnected we need to clear all presence state as
|
||||
// it's no longer reliable.
|
||||
presence.clear();
|
||||
});
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.io.on("reconnect_attempt", () => {
|
||||
if (this.socket) {
|
||||
this.socket.io.opts.transports = auth?.team?.domain
|
||||
? ["websocket"]
|
||||
: ["websocket", "polling"];
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", () => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("unauthorized", (err: Error) => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
}
|
||||
toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
this.socket.on("entities", async (event: any) => {
|
||||
if (event.documentIds) {
|
||||
for (const documentDescriptor of event.documentIds) {
|
||||
const documentId = documentDescriptor.id;
|
||||
let document = documents.get(documentId) || {};
|
||||
|
||||
if (event.event === "documents.delete") {
|
||||
const document = documents.get(documentId);
|
||||
|
||||
if (document) {
|
||||
document.deletedAt = documentDescriptor.updatedAt;
|
||||
}
|
||||
|
||||
policies.remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
|
||||
const { title, updatedAt } = document;
|
||||
|
||||
if (updatedAt === documentDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// otherwise, grab the latest version of the document
|
||||
try {
|
||||
document = await documents.fetch(documentId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.remove(documentId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if the title changed then we need to update the collection also
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
|
||||
if (title !== document.title) {
|
||||
if (!event.collectionIds) {
|
||||
event.collectionIds = [];
|
||||
}
|
||||
|
||||
const existing = find(event.collectionIds, {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
|
||||
id: document.collectionId,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
event.collectionIds.push({
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
|
||||
id: document.collectionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.collectionIds) {
|
||||
for (const collectionDescriptor of event.collectionIds) {
|
||||
const collectionId = collectionDescriptor.id;
|
||||
const collection = collections.get(collectionId);
|
||||
|
||||
if (event.event === "collections.delete") {
|
||||
if (collection) {
|
||||
collection.deletedAt = collectionDescriptor.updatedAt;
|
||||
}
|
||||
|
||||
const deletedDocuments = documents.inCollection(collectionId);
|
||||
deletedDocuments.forEach((doc) => {
|
||||
doc.deletedAt = collectionDescriptor.updatedAt;
|
||||
policies.remove(doc.id);
|
||||
});
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
|
||||
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await collections.fetch(collectionId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.groupIds) {
|
||||
for (const groupDescriptor of event.groupIds) {
|
||||
const groupId = groupDescriptor.id;
|
||||
const group = groups.get(groupId) || {};
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type '{}'.
|
||||
const { updatedAt } = group;
|
||||
|
||||
if (updatedAt === groupDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await groups.fetch(groupId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
groups.remove(groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.teamIds) {
|
||||
await auth.fetch();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("pins.create", (event: any) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.update", (event: any) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.delete", (event: any) => {
|
||||
pins.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("stars.create", (event: any) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.update", (event: any) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.delete", (event: any) => {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("documents.permanent_delete", (event: any) => {
|
||||
documents.remove(event.documentId);
|
||||
});
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on("collections.add_user", (event: any) => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.fetch(event.collectionId, {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Document policies might need updating as the permission changes
|
||||
documents.inCollection(event.collectionId).forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
});
|
||||
});
|
||||
|
||||
// received when a user is removed from having access to a collection
|
||||
// to keep state in sync we must update our UI if the user is us,
|
||||
// or otherwise just remove any membership state we have for that user.
|
||||
this.socket.on("collections.remove_user", (event: any) => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.remove(event.collectionId);
|
||||
memberships.removeCollectionMemberships(event.collectionId);
|
||||
documents.removeCollectionDocuments(event.collectionId);
|
||||
} else {
|
||||
memberships.remove(`${event.userId}-${event.collectionId}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("collections.update_index", (event: any) => {
|
||||
const collection = collections.get(event.collectionId);
|
||||
|
||||
if (collection) {
|
||||
collection.updateIndex(event.index);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("fileOperations.create", async (event: any) => {
|
||||
const user = auth.user;
|
||||
if (user) {
|
||||
fileOperations.add({ ...event, user });
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("fileOperations.update", async (event: any) => {
|
||||
const user = auth.user;
|
||||
if (user) {
|
||||
fileOperations.add({ ...event, user });
|
||||
}
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to join a specific room. Forward that to the ws server.
|
||||
this.socket.on("join", (event: any) => {
|
||||
this.socket?.emit("join", event);
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to leave a specific room. Forward that to the ws server.
|
||||
this.socket.on("leave", (event: any) => {
|
||||
this.socket?.emit("leave", event);
|
||||
});
|
||||
|
||||
// received whenever we join a document room, the payload includes
|
||||
// userIds that are present/viewing and those that are editing.
|
||||
this.socket.on("document.presence", (event: any) => {
|
||||
presence.init(event.documentId, event.userIds, event.editingIds);
|
||||
});
|
||||
|
||||
// received whenever a new user joins a document room, aka they
|
||||
// navigate to / start viewing a document
|
||||
this.socket.on("user.join", (event: any) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
views.touch(event.documentId, event.userId);
|
||||
});
|
||||
|
||||
// received whenever a new user leaves a document room, aka they
|
||||
// navigate away / stop viewing a document
|
||||
this.socket.on("user.leave", (event: any) => {
|
||||
presence.leave(event.documentId, event.userId);
|
||||
views.touch(event.documentId, event.userId);
|
||||
});
|
||||
|
||||
// received when another client in a document room wants to change
|
||||
// or update it's presence. Currently the only property is whether
|
||||
// the client is in editing state or not.
|
||||
this.socket.on("user.presence", (event: any) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SocketContext.Provider value={this.socket}>
|
||||
{this.props.children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStores(SocketProvider);
|
||||
432
app/components/WebsocketProvider.tsx
Normal file
432
app/components/WebsocketProvider.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import invariant from "invariant";
|
||||
import { find } from "lodash";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
import Pin from "~/models/Pin";
|
||||
import Star from "~/models/Star";
|
||||
import Team from "~/models/Team";
|
||||
import withStores from "~/components/withStores";
|
||||
import {
|
||||
PartialWithId,
|
||||
WebsocketCollectionUpdateIndexEvent,
|
||||
WebsocketCollectionUserEvent,
|
||||
WebsocketEntitiesEvent,
|
||||
WebsocketEntityDeletedEvent,
|
||||
} from "~/types";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
|
||||
|
||||
type SocketWithAuthentication = Socket & {
|
||||
authenticated?: boolean;
|
||||
};
|
||||
|
||||
export const WebsocketContext = React.createContext<SocketWithAuthentication | null>(
|
||||
null
|
||||
);
|
||||
|
||||
type Props = RootStore;
|
||||
|
||||
@observer
|
||||
class WebsocketProvider extends React.Component<Props> {
|
||||
@observable
|
||||
socket: SocketWithAuthentication | null;
|
||||
|
||||
componentDidMount() {
|
||||
this.createConnection();
|
||||
document.addEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
document.removeEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
checkConnection = () => {
|
||||
if (this.socket?.disconnected && getPageVisible()) {
|
||||
// null-ifying this reference is important, do not remove. Without it
|
||||
// references to old sockets are potentially held in context
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.createConnection();
|
||||
}
|
||||
};
|
||||
|
||||
createConnection = () => {
|
||||
this.socket = io(window.location.origin, {
|
||||
path: "/realtime",
|
||||
transports: ["websocket"],
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
});
|
||||
invariant(this.socket, "Socket should be defined");
|
||||
|
||||
this.socket.authenticated = false;
|
||||
const {
|
||||
auth,
|
||||
toasts,
|
||||
documents,
|
||||
collections,
|
||||
groups,
|
||||
pins,
|
||||
stars,
|
||||
memberships,
|
||||
policies,
|
||||
presence,
|
||||
views,
|
||||
fileOperations,
|
||||
} = this.props;
|
||||
if (!auth.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
// immediately send current users token to the websocket backend where it
|
||||
// is verified, if all goes well an 'authenticated' message will be
|
||||
// received in response
|
||||
this.socket?.emit("authentication", {
|
||||
token: auth.token,
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", () => {
|
||||
// when the socket is disconnected we need to clear all presence state as
|
||||
// it's no longer reliable.
|
||||
presence.clear();
|
||||
});
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.io.on("reconnect_attempt", () => {
|
||||
if (this.socket) {
|
||||
this.socket.io.opts.transports = auth?.team?.domain
|
||||
? ["websocket"]
|
||||
: ["websocket", "polling"];
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", () => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("unauthorized", (err: Error) => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
}
|
||||
toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"entities",
|
||||
action(async (event: WebsocketEntitiesEvent) => {
|
||||
if (event.documentIds) {
|
||||
for (const documentDescriptor of event.documentIds) {
|
||||
const documentId = documentDescriptor.id;
|
||||
let document = documents.get(documentId);
|
||||
const previousTitle = document?.title;
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
if (document?.updatedAt === documentDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// otherwise, grab the latest version of the document
|
||||
try {
|
||||
document = await documents.fetch(documentId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.remove(documentId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if the title changed then we need to update the collection also
|
||||
if (document && previousTitle !== document.title) {
|
||||
if (!event.collectionIds) {
|
||||
event.collectionIds = [];
|
||||
}
|
||||
|
||||
const existing = find(event.collectionIds, {
|
||||
id: document.collectionId,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
event.collectionIds.push({
|
||||
id: document.collectionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.collectionIds) {
|
||||
for (const collectionDescriptor of event.collectionIds) {
|
||||
const collectionId = collectionDescriptor.id;
|
||||
const collection = collections.get(collectionId);
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await collections.fetch(collectionId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.update",
|
||||
action(
|
||||
(event: PartialWithId<Document> & { title: string; url: string }) => {
|
||||
documents.add(event);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.updateDocument(event);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.archive",
|
||||
action((event: PartialWithId<Document>) => {
|
||||
documents.add(event);
|
||||
policies.remove(event.id);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(event.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.delete",
|
||||
action((event: PartialWithId<Document>) => {
|
||||
documents.add(event);
|
||||
policies.remove(event.id);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(event.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.permanent_delete",
|
||||
(event: WebsocketEntityDeletedEvent) => {
|
||||
documents.remove(event.modelId);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||
groups.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
|
||||
groups.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("groups.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
groups.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
|
||||
collections.add(event);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"collections.delete",
|
||||
action((event: WebsocketEntityDeletedEvent) => {
|
||||
const collectionId = event.modelId;
|
||||
const deletedAt = new Date().toISOString();
|
||||
|
||||
const deletedDocuments = documents.inCollection(collectionId);
|
||||
deletedDocuments.forEach((doc) => {
|
||||
doc.deletedAt = deletedAt;
|
||||
policies.remove(doc.id);
|
||||
});
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
|
||||
auth.updateTeam(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
pins.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on(
|
||||
"collections.add_user",
|
||||
action((event: WebsocketCollectionUserEvent) => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.fetch(event.collectionId, {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Document policies might need updating as the permission changes
|
||||
documents.inCollection(event.collectionId).forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// received when a user is removed from having access to a collection
|
||||
// to keep state in sync we must update our UI if the user is us,
|
||||
// or otherwise just remove any membership state we have for that user.
|
||||
this.socket.on(
|
||||
"collections.remove_user",
|
||||
action((event: WebsocketCollectionUserEvent) => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.remove(event.collectionId);
|
||||
memberships.removeCollectionMemberships(event.collectionId);
|
||||
documents.removeCollectionDocuments(event.collectionId);
|
||||
} else {
|
||||
memberships.remove(`${event.userId}-${event.collectionId}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"collections.update_index",
|
||||
action((event: WebsocketCollectionUpdateIndexEvent) => {
|
||||
const collection = collections.get(event.collectionId);
|
||||
|
||||
if (collection) {
|
||||
collection.updateIndex(event.index);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"fileOperations.create",
|
||||
(event: PartialWithId<FileOperation>) => {
|
||||
fileOperations.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"fileOperations.update",
|
||||
(event: PartialWithId<FileOperation>) => {
|
||||
fileOperations.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to join a specific room. Forward that to the ws server.
|
||||
this.socket.on("join", (event: any) => {
|
||||
this.socket?.emit("join", event);
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to leave a specific room. Forward that to the ws server.
|
||||
this.socket.on("leave", (event: any) => {
|
||||
this.socket?.emit("leave", event);
|
||||
});
|
||||
|
||||
// received whenever we join a document room, the payload includes
|
||||
// userIds that are present/viewing and those that are editing.
|
||||
this.socket.on("document.presence", (event: any) => {
|
||||
presence.init(event.documentId, event.userIds, event.editingIds);
|
||||
});
|
||||
|
||||
// received whenever a new user joins a document room, aka they
|
||||
// navigate to / start viewing a document
|
||||
this.socket.on("user.join", (event: any) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
views.touch(event.documentId, event.userId);
|
||||
});
|
||||
|
||||
// received whenever a new user leaves a document room, aka they
|
||||
// navigate away / stop viewing a document
|
||||
this.socket.on("user.leave", (event: any) => {
|
||||
presence.leave(event.documentId, event.userId);
|
||||
views.touch(event.documentId, event.userId);
|
||||
});
|
||||
|
||||
// received when another client in a document room wants to change
|
||||
// or update it's presence. Currently the only property is whether
|
||||
// the client is in editing state or not.
|
||||
this.socket.on("user.presence", (event: any) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<WebsocketContext.Provider value={this.socket}>
|
||||
{this.props.children}
|
||||
</WebsocketContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStores(WebsocketProvider);
|
||||
@@ -69,7 +69,7 @@ type ConfigType = {
|
||||
|
||||
const useAuthorizedSettingsConfig = () => {
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const config: ConfigType = React.useMemo(
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
import * as React from "react";
|
||||
import BaseModel from "~/models/BaseModel";
|
||||
import useStores from "./useStores";
|
||||
|
||||
/**
|
||||
* Quick access to retrieve the abilities of a policy for a given entity
|
||||
* Retrieve the abilities of a policy for a given entity, if the policy is not
|
||||
* located in the store, it will be fetched from the server.
|
||||
*
|
||||
* @param entityId The entity id
|
||||
* @returns The available abilities
|
||||
* @param entity The model or model id
|
||||
* @returns The policy for the model
|
||||
*/
|
||||
export default function usePolicy(entityId: string) {
|
||||
export default function usePolicy(entity: string | BaseModel | undefined) {
|
||||
const { policies } = useStores();
|
||||
const triggered = React.useRef(false);
|
||||
const entityId = entity
|
||||
? typeof entity === "string"
|
||||
? entity
|
||||
: entity.id
|
||||
: "";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (entity && typeof entity !== "string") {
|
||||
// The policy for this model is missing and we haven't tried to fetch it
|
||||
// yet, go ahead and do that now. The force flag is needed otherwise the
|
||||
// network request will be skipped due to the model existing in the store
|
||||
if (!policies.get(entity.id) && !triggered.current) {
|
||||
triggered.current = true;
|
||||
void entity.store.fetch(entity.id, { force: true });
|
||||
}
|
||||
}
|
||||
}, [policies, entity]);
|
||||
|
||||
return policies.abilities(entityId);
|
||||
}
|
||||
|
||||
@@ -187,8 +187,8 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const alphabeticalSort = collection.sort.field === "title";
|
||||
const can = usePolicy(collection.id);
|
||||
const canUserInTeam = usePolicy(team.id);
|
||||
const can = usePolicy(collection);
|
||||
const canUserInTeam = usePolicy(team);
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
|
||||
@@ -125,7 +125,7 @@ function DocumentMenu({
|
||||
}, [menu]);
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const can = usePolicy(document.id);
|
||||
const can = usePolicy(document);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
|
||||
@@ -24,7 +24,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
});
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
|
||||
const can = usePolicy(group.id);
|
||||
const can = usePolicy(group);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,7 +27,7 @@ function NewDocumentMenu() {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ function NewTemplateMenu() {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
|
||||
@@ -101,8 +101,14 @@ export default class Collection extends ParanoidModel {
|
||||
return sortNavigationNodes(this.documents, this.sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the document identified by the given id in the collection in memory.
|
||||
* Does not update the document in the database.
|
||||
*
|
||||
* @param document The document properties stored in the collection
|
||||
*/
|
||||
@action
|
||||
updateDocument(document: Document) {
|
||||
updateDocument(document: Pick<Document, "id" | "title" | "url">) {
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === document.id) {
|
||||
@@ -116,6 +122,27 @@ export default class Collection extends ParanoidModel {
|
||||
travelNodes(this.documents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the document identified by the given id from the collection in
|
||||
* memory. Does not remove the document from the database.
|
||||
*
|
||||
* @param documentId The id of the document to remove.
|
||||
*/
|
||||
@action
|
||||
removeDocument(documentId: string) {
|
||||
this.documents = this.documents.filter(function f(node): boolean {
|
||||
if (node.id === documentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
node.children = node.children.filter(f);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
updateIndex(index: string) {
|
||||
this.index = index;
|
||||
|
||||
@@ -11,7 +11,7 @@ import Layout from "~/components/AuthenticatedLayout";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import SocketProvider from "~/components/SocketProvider";
|
||||
import WebsocketProvider from "~/components/WebsocketProvider";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||
@@ -64,10 +64,10 @@ const RedirectDocument = ({
|
||||
|
||||
function AuthenticatedRoutes() {
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<SocketProvider>
|
||||
<WebsocketProvider>
|
||||
<Layout>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
@@ -116,7 +116,7 @@ function AuthenticatedRoutes() {
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
</WebsocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
|
||||
function Actions({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
|
||||
function EmptyCollection({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
const collectionName = collection ? collection.name : "";
|
||||
|
||||
const [
|
||||
|
||||
@@ -58,7 +58,7 @@ function DataLoader({ match, children }: Props) {
|
||||
: undefined;
|
||||
const isEditRoute = match.path === matchDocumentEdit;
|
||||
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
|
||||
const can = usePolicy(document ? document.id : "");
|
||||
const can = usePolicy(document?.id);
|
||||
const location = useLocation<LocationState>();
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -100,7 +100,7 @@ function DocumentHeader({
|
||||
}, [onSave]);
|
||||
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const can = usePolicy(document.id);
|
||||
const can = usePolicy(document);
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
const toc = (
|
||||
|
||||
@@ -45,7 +45,7 @@ function SharePopover({
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const can = usePolicy(share ? share.id : "");
|
||||
const documentAbilities = usePolicy(document.id);
|
||||
const documentAbilities = usePolicy(document);
|
||||
const canPublish =
|
||||
can.update &&
|
||||
!document.isTemplate &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
|
||||
import { SocketContext } from "~/components/SocketProvider";
|
||||
import { WebsocketContext } from "~/components/WebsocketProvider";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
@@ -8,9 +8,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export default class SocketPresence extends React.Component<Props> {
|
||||
static contextType = SocketContext;
|
||||
static contextType = WebsocketContext;
|
||||
|
||||
previousContext: typeof SocketContext;
|
||||
previousContext: typeof WebsocketContext;
|
||||
|
||||
editingInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ function GroupMembers({ group }: Props) {
|
||||
const { users, groupMemberships } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group.id);
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleAddModal = (state: boolean) => {
|
||||
setAddModalOpen(state);
|
||||
|
||||
@@ -30,7 +30,7 @@ function Home() {
|
||||
pins.fetchPage();
|
||||
}, [pins]);
|
||||
|
||||
const canManageTeam = usePolicy(team.id).manage;
|
||||
const canManageTeam = usePolicy(team).manage;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -56,7 +56,7 @@ function Invite({ onSubmit }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const predictedDomain = user.email.split("@")[1];
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ function Groups() {
|
||||
const { t } = useTranslation();
|
||||
const { groups } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const [
|
||||
newGroupModalOpen,
|
||||
handleNewGroupModalOpen,
|
||||
|
||||
@@ -40,7 +40,7 @@ function Members() {
|
||||
const [data, setData] = React.useState<User[]>([]);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
const [userIds, setUserIds] = React.useState<string[]>([]);
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const query = params.get("query") || "";
|
||||
const filter = params.get("filter") || "";
|
||||
const sort = params.get("sort") || "name";
|
||||
|
||||
@@ -21,7 +21,7 @@ function Shares() {
|
||||
const { t } = useTranslation();
|
||||
const { shares, auth } = useStores();
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState<Share[]>([]);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
|
||||
@@ -23,7 +23,7 @@ function Tokens() {
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -23,7 +23,7 @@ function Webhooks() {
|
||||
const { t } = useTranslation();
|
||||
const { webhookSubscriptions } = useStores();
|
||||
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -31,8 +31,6 @@ const WEBHOOK_EVENTS = {
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.restore",
|
||||
"documents.star",
|
||||
"documents.unstar",
|
||||
"documents.move",
|
||||
"documents.update",
|
||||
"documents.update.delayed",
|
||||
|
||||
@@ -21,7 +21,7 @@ function Templates(props: RouteComponentProps<{ sort: string }>) {
|
||||
const team = useCurrentTeam();
|
||||
const { fetchTemplates, templates, templatesAlphabetical } = documents;
|
||||
const { sort } = props.match.params;
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -5,12 +5,10 @@ import { Class } from "utility-types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import BaseModel from "~/models/BaseModel";
|
||||
import Policy from "~/models/Policy";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { PaginationParams, PartialWithId } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
|
||||
type PartialWithId<T> = Partial<T> & { id: string };
|
||||
|
||||
export enum RPCAction {
|
||||
Info = "info",
|
||||
List = "list",
|
||||
|
||||
38
app/types.ts
38
app/types.ts
@@ -1,7 +1,12 @@
|
||||
import { Location, LocationDescriptor } from "history";
|
||||
import { TFunction } from "react-i18next";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
import Document from "./models/Document";
|
||||
import FileOperation from "./models/FileOperation";
|
||||
import Pin from "./models/Pin";
|
||||
import Star from "./models/Star";
|
||||
|
||||
export type PartialWithId<T> = Partial<T> & { id: string };
|
||||
|
||||
export type MenuItemButton = {
|
||||
type: "button";
|
||||
@@ -178,3 +183,34 @@ export type ToastOptions = {
|
||||
onClick: React.MouseEventHandler<HTMLSpanElement>;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebsocketEntityDeletedEvent = {
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
export type WebsocketEntitiesEvent = {
|
||||
documentIds: { id: string; updatedAt?: string }[];
|
||||
collectionIds: { id: string; updatedAt?: string }[];
|
||||
groupIds: { id: string; updatedAt?: string }[];
|
||||
teamIds: string[];
|
||||
event: string;
|
||||
};
|
||||
|
||||
export type WebsocketCollectionUserEvent = {
|
||||
collectionId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type WebsocketCollectionUpdateIndexEvent = {
|
||||
collectionId: string;
|
||||
index: string;
|
||||
};
|
||||
|
||||
export type WebsocketEvent =
|
||||
| PartialWithId<Pin>
|
||||
| PartialWithId<Star>
|
||||
| PartialWithId<FileOperation>
|
||||
| WebsocketCollectionUserEvent
|
||||
| WebsocketCollectionUpdateIndexEvent
|
||||
| WebsocketEntityDeletedEvent
|
||||
| WebsocketEntitiesEvent;
|
||||
|
||||
@@ -10,11 +10,16 @@ import {
|
||||
GroupUser,
|
||||
Pin,
|
||||
Star,
|
||||
Team,
|
||||
} from "@server/models";
|
||||
import {
|
||||
presentCollection,
|
||||
presentDocument,
|
||||
presentFileOperation,
|
||||
presentGroup,
|
||||
presentPin,
|
||||
presentStar,
|
||||
presentTeam,
|
||||
} from "@server/presenters";
|
||||
import { Event } from "../../types";
|
||||
|
||||
@@ -23,7 +28,6 @@ export default class WebsocketsProcessor {
|
||||
switch (event.name) {
|
||||
case "documents.publish":
|
||||
case "documents.restore":
|
||||
case "documents.archive":
|
||||
case "documents.unarchive": {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
paranoid: false,
|
||||
@@ -51,52 +55,16 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.delete": {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
paranoid: false,
|
||||
});
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.publishedAt) {
|
||||
return socketio.to(`user-${document.createdById}`).emit("entities", {
|
||||
event: event.name,
|
||||
documentIds: [
|
||||
{
|
||||
id: document.id,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return socketio
|
||||
.to(`collection-${document.collectionId}`)
|
||||
.emit("entities", {
|
||||
event: event.name,
|
||||
documentIds: [
|
||||
{
|
||||
id: document.id,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
],
|
||||
collectionIds: [
|
||||
{
|
||||
id: document.collectionId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.permanent_delete": {
|
||||
return socketio
|
||||
.to(`collection-${event.collectionId}`)
|
||||
.emit(event.name, {
|
||||
documentId: event.documentId,
|
||||
modelId: event.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.archive":
|
||||
case "documents.delete":
|
||||
case "documents.update": {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
paranoid: false,
|
||||
@@ -107,15 +75,9 @@ export default class WebsocketsProcessor {
|
||||
const channel = document.publishedAt
|
||||
? `collection-${document.collectionId}`
|
||||
: `user-${event.actorId}`;
|
||||
return socketio.to(channel).emit("entities", {
|
||||
event: event.name,
|
||||
documentIds: [
|
||||
{
|
||||
id: document.id,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const data = await presentDocument(document);
|
||||
return socketio.to(channel).emit(event.name, data);
|
||||
}
|
||||
|
||||
case "documents.create": {
|
||||
@@ -139,13 +101,6 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.star":
|
||||
case "documents.unstar": {
|
||||
return socketio.to(`user-${event.actorId}`).emit(event.name, {
|
||||
documentId: event.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.move": {
|
||||
const documents = await Document.findAll({
|
||||
where: {
|
||||
@@ -188,22 +143,15 @@ export default class WebsocketsProcessor {
|
||||
.to(
|
||||
collection.permission
|
||||
? `team-${collection.teamId}`
|
||||
: `collection-${collection.id}`
|
||||
: `user-${collection.createdById}`
|
||||
)
|
||||
.emit("entities", {
|
||||
event: event.name,
|
||||
collectionIds: [
|
||||
{
|
||||
id: collection.id,
|
||||
updatedAt: collection.updatedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
.emit(event.name, presentCollection(collection));
|
||||
|
||||
return socketio
|
||||
.to(
|
||||
collection.permission
|
||||
? `team-${collection.teamId}`
|
||||
: `collection-${collection.id}`
|
||||
: `user-${collection.createdById}`
|
||||
)
|
||||
.emit("join", {
|
||||
event: event.name,
|
||||
@@ -211,8 +159,7 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "collections.update":
|
||||
case "collections.delete": {
|
||||
case "collections.update": {
|
||||
const collection = await Collection.findByPk(event.collectionId, {
|
||||
paranoid: false,
|
||||
});
|
||||
@@ -230,6 +177,14 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "collections.delete": {
|
||||
return socketio
|
||||
.to(`collection-${event.collectionId}`)
|
||||
.emit(event.name, {
|
||||
modelId: event.collectionId,
|
||||
});
|
||||
}
|
||||
|
||||
case "collections.move": {
|
||||
return socketio
|
||||
.to(`collection-${event.collectionId}`)
|
||||
@@ -366,8 +321,9 @@ export default class WebsocketsProcessor {
|
||||
if (!fileOperation) {
|
||||
return;
|
||||
}
|
||||
const data = await presentFileOperation(fileOperation);
|
||||
return socketio.to(`user-${event.actorId}`).emit(event.name, data);
|
||||
return socketio
|
||||
.to(`user-${event.actorId}`)
|
||||
.emit(event.name, presentFileOperation(fileOperation));
|
||||
}
|
||||
|
||||
case "pins.create":
|
||||
@@ -422,15 +378,9 @@ export default class WebsocketsProcessor {
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
return socketio.to(`team-${group.teamId}`).emit("entities", {
|
||||
event: event.name,
|
||||
groupIds: [
|
||||
{
|
||||
id: group.id,
|
||||
updatedAt: group.updatedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
return socketio
|
||||
.to(`team-${group.teamId}`)
|
||||
.emit(event.name, presentGroup(group));
|
||||
}
|
||||
|
||||
case "groups.add_user": {
|
||||
@@ -518,25 +468,14 @@ export default class WebsocketsProcessor {
|
||||
}
|
||||
|
||||
case "groups.delete": {
|
||||
const group = await Group.findByPk(event.modelId, {
|
||||
paranoid: false,
|
||||
socketio.to(`team-${event.teamId}`).emit(event.name, {
|
||||
modelId: event.modelId,
|
||||
});
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
socketio.to(`team-${group.teamId}`).emit("entities", {
|
||||
event: event.name,
|
||||
groupIds: [
|
||||
{
|
||||
id: group.id,
|
||||
updatedAt: group.updatedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
// we the users and collection relations that were just severed as a result of the group deletion
|
||||
// since there are cascading deletes, we approximate this by looking for the recently deleted
|
||||
// items in the GroupUser and CollectionGroup tables
|
||||
// we get users and collection relations that were just severed as a
|
||||
// result of the group deletion since there are cascading deletes, we
|
||||
// approximate this by looking for the recently deleted items in the
|
||||
// GroupUser and CollectionGroup tables
|
||||
const groupUsers = await GroupUser.findAll({
|
||||
paranoid: false,
|
||||
where: {
|
||||
@@ -594,14 +533,13 @@ export default class WebsocketsProcessor {
|
||||
}
|
||||
|
||||
case "teams.update": {
|
||||
return socketio.to(`team-${event.teamId}`).emit("entities", {
|
||||
event: event.name,
|
||||
teamIds: [
|
||||
{
|
||||
id: event.teamId,
|
||||
},
|
||||
],
|
||||
});
|
||||
const team = await Team.scope("withDomains").findByPk(event.teamId);
|
||||
if (!team) {
|
||||
return;
|
||||
}
|
||||
return socketio
|
||||
.to(`team-${event.teamId}`)
|
||||
.emit(event.name, presentTeam(team));
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -122,8 +122,6 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
case "documents.archive":
|
||||
case "documents.unarchive":
|
||||
case "documents.restore":
|
||||
case "documents.star":
|
||||
case "documents.unstar":
|
||||
case "documents.move":
|
||||
case "documents.update":
|
||||
case "documents.title_change":
|
||||
|
||||
@@ -44,24 +44,6 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.star should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.unstar should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.update should fail if document lastRevision does not match 1`] = `
|
||||
Object {
|
||||
"error": "invalid_request",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Document,
|
||||
View,
|
||||
Star,
|
||||
Revision,
|
||||
Backlink,
|
||||
CollectionUser,
|
||||
@@ -1819,79 +1818,6 @@ describe("#documents.restore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#documents.star", () => {
|
||||
it("should star the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
const res = await server.post("/api/documents.star", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const stars = await Star.findAll();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(stars.length).toEqual(1);
|
||||
expect(stars[0].documentId).toEqual(document.id);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.star");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { document } = await seed();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.star", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#documents.unstar", () => {
|
||||
it("should unstar the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
await Star.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/documents.unstar", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const stars = await Star.findAll();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(stars.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.star");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { document } = await seed();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.unstar", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#documents.import", () => {
|
||||
it("should error if no file is passed", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
Event,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Star,
|
||||
User,
|
||||
View,
|
||||
} from "@server/models";
|
||||
@@ -731,75 +730,6 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// Deprecated – use stars.create instead
|
||||
router.post("documents.star", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
const { user } = ctx.state;
|
||||
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
await Star.findOrCreate({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await Event.create({
|
||||
name: "documents.star",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
// Deprecated – use stars.delete instead
|
||||
router.post("documents.unstar", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
const { user } = ctx.state;
|
||||
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
await Star.destroy({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
await Event.create({
|
||||
name: "documents.unstar",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.templatize", auth({ member: true }), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
|
||||
@@ -6,6 +6,8 @@ import IO from "socket.io";
|
||||
import { createAdapter } from "socket.io-redis";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Metrics from "@server/logging/metrics";
|
||||
import * as Tracing from "@server/logging/tracing";
|
||||
import { APM } from "@server/logging/tracing";
|
||||
import { Document, Collection, View, User } from "@server/models";
|
||||
import { can } from "@server/policies";
|
||||
import { getUserForJWT } from "@server/utils/jwt";
|
||||
@@ -135,14 +137,23 @@ export default function init(
|
||||
|
||||
// Handle events from event queue that should be sent to the clients down ws
|
||||
const websockets = new WebsocketsProcessor();
|
||||
websocketQueue.process(async function websocketEventsProcessor(job) {
|
||||
websocketQueue.process(
|
||||
APM.traceFunction({
|
||||
serviceName: "websockets",
|
||||
spanName: "process",
|
||||
isRoot: true,
|
||||
})(async function (job) {
|
||||
const event = job.data;
|
||||
|
||||
Tracing.setResource(`Processor.WebsocketsProcessor`);
|
||||
|
||||
websockets.perform(event, io).catch((error) => {
|
||||
Logger.error("Error processing websocket event", error, {
|
||||
event,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function authenticated(io: IO.Server, socket: SocketWithAuth) {
|
||||
|
||||
@@ -95,9 +95,7 @@ export type DocumentEvent = BaseEvent &
|
||||
| "documents.permanent_delete"
|
||||
| "documents.archive"
|
||||
| "documents.unarchive"
|
||||
| "documents.restore"
|
||||
| "documents.star"
|
||||
| "documents.unstar";
|
||||
| "documents.restore";
|
||||
documentId: string;
|
||||
collectionId: string;
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user