mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8ba94551 | |||
| 00bab31cff | |||
| 3ef2b7cf42 | |||
| 18743da2fc | |||
| fe1307d7e7 | |||
| a226889143 | |||
| 347f033802 | |||
| f5c659f902 | |||
| 722d10e7de | |||
| ce001547b5 | |||
| 8d05e2b095 | |||
| 19e40cf814 | |||
| 2bb9b50637 | |||
| 4885612661 | |||
| e2dd6221f8 | |||
| 7f513a6950 | |||
| 6440d78b6f | |||
| 7e05fc1017 | |||
| 2bc47cfcef | |||
| e8e46a438c | |||
| 3156f62e94 | |||
| 9274f56ef6 | |||
| 4bb9ac40c7 | |||
| 36772f1444 | |||
| e503225f04 | |||
| 762140e493 |
@@ -2,7 +2,6 @@ import { transparentize } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { getTextColor } from "@shared/utils/color";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 10;
|
||||
@@ -33,7 +32,7 @@ export const Title = styled(Text).attrs({ as: "h2", size: "large" })`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
export const Info = styled(StyledText).attrs(() => ({
|
||||
@@ -60,15 +59,27 @@ export const Thumbnail = styled.img`
|
||||
export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
color?: string;
|
||||
}>`
|
||||
background-color: ${(props) =>
|
||||
props.color ?? props.theme.backgroundSecondary};
|
||||
color: ${(props) =>
|
||||
props.color ? getTextColor(props.color) : props.theme.text};
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
width: fit-content;
|
||||
border-radius: 2em;
|
||||
padding: 0 8px;
|
||||
padding: 1px 8px 1px 20px;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) =>
|
||||
props.color || props.theme.backgroundSecondary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardContent = styled.div`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import {
|
||||
IntegrationService,
|
||||
@@ -40,13 +42,18 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
<CardContent>
|
||||
<Flex gap={2} column>
|
||||
<Title>
|
||||
<IssueStatusIcon service={service} state={state} />
|
||||
<StyledIssueStatusIcon
|
||||
service={service}
|
||||
state={state}
|
||||
size={18}
|
||||
/>
|
||||
<span>
|
||||
{title} <Text type="tertiary">{id}</Text>
|
||||
<Backticks content={title} />
|
||||
<Text type="tertiary">{id}</Text>
|
||||
</span>
|
||||
</Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<Avatar src={author.avatarUrl} />
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={author.avatarUrl} size={18} />
|
||||
<Info>
|
||||
<Trans>
|
||||
{{ authorName }} created{" "}
|
||||
@@ -71,4 +78,8 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIssueStatusIcon = styled(IssueStatusIcon)`
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewIssue;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
@@ -31,13 +33,14 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
<CardContent>
|
||||
<Flex gap={2} column>
|
||||
<Title>
|
||||
<PullRequestIcon status={state.name} color={state.color} />
|
||||
<StyledPullRequestIcon size={18} state={state} />
|
||||
<span>
|
||||
{title} <Text type="tertiary">{id}</Text>
|
||||
<Backticks content={title} />
|
||||
<Text type="tertiary">{id}</Text>
|
||||
</span>
|
||||
</Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<Avatar src={author.avatarUrl} />
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={author.avatarUrl} size={18} />
|
||||
<Info>
|
||||
<Trans>
|
||||
{{ authorName }} opened{" "}
|
||||
@@ -55,4 +58,8 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
}
|
||||
);
|
||||
|
||||
const StyledPullRequestIcon = styled(PullRequestIcon)`
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewPullRequest;
|
||||
|
||||
@@ -195,21 +195,27 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
}
|
||||
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchResults();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const prevFetch = usePrevious(fetch);
|
||||
const prevOptions = usePrevious(options);
|
||||
|
||||
// Equivalent to componentDidUpdate
|
||||
// Initial fetch on mount
|
||||
React.useEffect(() => {
|
||||
if (fetch) {
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch]);
|
||||
|
||||
// Handle updates to fetch or options
|
||||
React.useEffect(() => {
|
||||
if (!prevFetch || !prevOptions) {
|
||||
return; // Skip on initial mount since it's handled by the above effect
|
||||
}
|
||||
|
||||
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
|
||||
reset();
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch, options, reset, prevFetch, prevOptions, fetchResults]);
|
||||
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
|
||||
|
||||
// Computed property equivalent
|
||||
const itemsToRender = React.useMemo(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -29,9 +30,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
|
||||
let mentionType: MentionType | undefined;
|
||||
const url = pastedText ? new URL(pastedText) : undefined;
|
||||
|
||||
if (url) {
|
||||
if (pastedText && isUrl(pastedText)) {
|
||||
const url = new URL(pastedText);
|
||||
const integration = integrations.find((intg: Integration) =>
|
||||
isURLMentionable({ url, integration: intg })
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+B`,
|
||||
icon: <BoldIcon />,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "em",
|
||||
@@ -75,7 +75,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+I`,
|
||||
icon: <ItalicIcon />,
|
||||
active: isMarkActive(schema.marks.em),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
@@ -83,7 +83,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+D`,
|
||||
icon: <StrikethroughIcon />,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
|
||||
@@ -331,6 +331,16 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the documents that link to this document.
|
||||
*
|
||||
* @returns documents that link to this document
|
||||
*/
|
||||
@computed
|
||||
get backlinks(): Document[] {
|
||||
return this.store.getBacklinkedDocuments(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns users that have been individually given access to the document.
|
||||
*
|
||||
|
||||
@@ -66,7 +66,6 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, ui } = useStores();
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const currentPath = location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
@@ -120,21 +119,16 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if ((!can || !collection) && !error && !isFetching) {
|
||||
try {
|
||||
setError(undefined);
|
||||
setFetching(true);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
try {
|
||||
setError(undefined);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchData();
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
}, []);
|
||||
|
||||
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function References({ document }: Props) {
|
||||
const { collections, documents } = useStores();
|
||||
const { documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
@@ -27,10 +27,8 @@ function References({ document }: Props) {
|
||||
void documents.fetchBacklinks(document.id);
|
||||
}, [documents, document.id]);
|
||||
|
||||
const backlinks = documents.getBacklinkedDocuments(document.id);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const backlinks = document.backlinks;
|
||||
const collection = document.collection;
|
||||
const children = collection
|
||||
? collection.getChildrenForDocument(document.id)
|
||||
: [];
|
||||
|
||||
@@ -186,6 +186,13 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
});
|
||||
|
||||
get(id: string): Collection | undefined {
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((collection) => id.endsWith(collection.urlId))
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get archived(): Collection[] {
|
||||
return orderBy(this.orderedData, "archivedAt", "desc").filter(
|
||||
|
||||
@@ -300,8 +300,8 @@ export default class DocumentsStore extends Store<Document> {
|
||||
const documentIds = this.backlinks.get(documentId) || [];
|
||||
return orderBy(
|
||||
compact(documentIds.map((id) => this.data.get(id))),
|
||||
"updatedAt",
|
||||
"desc"
|
||||
"title",
|
||||
"asc"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+10
-11
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.787.0",
|
||||
"@aws-sdk/lib-storage": "3.787.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.787.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.787.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.787.0",
|
||||
"@aws-sdk/client-s3": "3.797.0",
|
||||
"@aws-sdk/lib-storage": "3.797.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.797.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.797.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.796.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
@@ -174,7 +174,7 @@
|
||||
"passport-oauth2": "^1.8.0",
|
||||
"passport-slack-oauth2": "^1.2.0",
|
||||
"patch-package": "^7.0.2",
|
||||
"pg": "^8.14.1",
|
||||
"pg": "^8.15.6",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pluralize": "^8.0.0",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
@@ -209,7 +209,7 @@
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-medium-image-zoom": "5.2.13",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.2.2",
|
||||
"react-portal": "^4.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-waypoint": "^10.3.0",
|
||||
@@ -249,7 +249,7 @@
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^5.4.18",
|
||||
"vite": "^6.3.4",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -356,12 +356,12 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^13.3.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"nodemon": "^3.1.10",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.3",
|
||||
"rollup-plugin-webpack-stats": "^2.0.5",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
@@ -375,7 +375,6 @@
|
||||
"node-fetch": "^2.7.0",
|
||||
"js-yaml": "^3.14.1",
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.83.0",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import Router from "koa-router";
|
||||
import find from "lodash/find";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { IntegrationAuthentication, Integration, Team } from "@server/models";
|
||||
import { IntegrationAuthentication, Integration } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { GitHubUtils } from "../../shared/GitHubUtils";
|
||||
import { GitHub } from "../github";
|
||||
@@ -17,10 +16,17 @@ const router = new Router();
|
||||
|
||||
router.get(
|
||||
"github.callback",
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
auth({ optional: true }),
|
||||
validate(T.GitHubCallbackSchema),
|
||||
apexAuthRedirect<T.GitHubCallbackReq>({
|
||||
getTeamId: (ctx) => ctx.input.query.state,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
GitHubUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => GitHubUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.GitHubCallbackReq>) => {
|
||||
const {
|
||||
@@ -43,33 +49,6 @@ router.get(
|
||||
return;
|
||||
}
|
||||
|
||||
// this code block accounts for the root domain being unable to
|
||||
// access authentication for subdomains. We must forward to the appropriate
|
||||
// subdomain to complete the oauth flow
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
GitHubUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
const client = await GitHub.authenticateAsUser(code!, teamId);
|
||||
const installationsByUser = await client.requestAppInstallations();
|
||||
const installation = find(
|
||||
|
||||
@@ -279,7 +279,8 @@ export class GitHub {
|
||||
},
|
||||
state: {
|
||||
name: prState,
|
||||
color: GitHubUtils.getColorForStatus(prState),
|
||||
color: GitHubUtils.getColorForStatus(prState, !!pr.draft),
|
||||
draft: pr.draft,
|
||||
},
|
||||
createdAt: pr.created_at,
|
||||
transformed_unfurl: true,
|
||||
|
||||
@@ -45,10 +45,10 @@ export class GitHubUtils {
|
||||
return `${this.url}?install_request=true`;
|
||||
}
|
||||
|
||||
public static getColorForStatus(status: string) {
|
||||
public static getColorForStatus(status: string, isDraftPR: boolean = false) {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "#238636";
|
||||
return isDraftPR ? "#848d97" : "#238636";
|
||||
case "done":
|
||||
return "#a371f7";
|
||||
case "closed":
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { IntegrationAuthentication, Integration, Team } from "@server/models";
|
||||
import { IntegrationAuthentication, Integration } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { Linear } from "../linear";
|
||||
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
|
||||
import * as T from "./schema";
|
||||
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
|
||||
|
||||
@@ -19,49 +19,21 @@ router.get(
|
||||
optional: true,
|
||||
}),
|
||||
validate(T.LinearCallbackSchema),
|
||||
apexAuthRedirect<T.LinearCallbackReq>({
|
||||
getTeamId: (ctx) => LinearUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
LinearUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => LinearUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.LinearCallbackReq>) => {
|
||||
const { code, state, error } = ctx.input.query;
|
||||
const { code, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let parsedState;
|
||||
try {
|
||||
parsedState = LinearUtils.parseState(state);
|
||||
} catch {
|
||||
ctx.redirect(LinearUtils.errorUrl("invalid_state"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = parsedState;
|
||||
|
||||
// this code block accounts for the root domain being unable to
|
||||
// access authentication for subdomains. We must forward to the appropriate
|
||||
// subdomain to complete the oauth flow
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
LinearUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(LinearUtils.errorUrl(error));
|
||||
@@ -82,7 +54,9 @@ router.get(
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.create<Integration<IntegrationType.Embed>>(
|
||||
const integration = await Integration.create<
|
||||
Integration<IntegrationType.Embed>
|
||||
>(
|
||||
{
|
||||
service: IntegrationService.Linear,
|
||||
type: IntegrationType.Embed,
|
||||
@@ -103,6 +77,15 @@ router.get(
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
transaction.afterCommit(async () => {
|
||||
if (workspace.logoUrl) {
|
||||
await new UploadLinearWorkspaceLogoTask().schedule({
|
||||
integrationId: integration.id,
|
||||
logoUrl: workspace.logoUrl,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.redirect(LinearUtils.successUrl());
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import config from "../plugin.json";
|
||||
import router from "./api/linear";
|
||||
import env from "./env";
|
||||
import { Linear } from "./linear";
|
||||
import UploadLinearWorkspaceLogoTask from "./tasks/UploadLinearWorkspaceLogoTask";
|
||||
import { uninstall } from "./uninstall";
|
||||
|
||||
const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET;
|
||||
@@ -15,6 +16,10 @@ if (enabled) {
|
||||
type: Hook.API,
|
||||
value: router,
|
||||
},
|
||||
{
|
||||
type: Hook.Task,
|
||||
value: UploadLinearWorkspaceLogoTask,
|
||||
},
|
||||
{
|
||||
type: Hook.UnfurlProvider,
|
||||
value: { unfurl: Linear.unfurl, cacheExpiry: Minute.seconds },
|
||||
|
||||
@@ -172,9 +172,16 @@ export class Linear {
|
||||
);
|
||||
|
||||
const idx = states.findIndex((s) => s.name === state.name);
|
||||
return idx !== -1
|
||||
? (idx + 1) / (states.length + 1) // add 1 to states for the "done" state.
|
||||
: defaultCompletionPercentage;
|
||||
|
||||
if (idx === -1) {
|
||||
return defaultCompletionPercentage;
|
||||
} else if (states.length === 1) {
|
||||
return 0.5;
|
||||
} else if (states.length === 2) {
|
||||
return idx === 0 ? 0.5 : 0.75;
|
||||
} else {
|
||||
return (idx + 1) / (states.length + 1); // add 1 to states for the "done" state.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { Integration } from "@server/models";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
import BaseTask, { TaskPriority } from "@server/queues/tasks/BaseTask";
|
||||
import FileStorage from "@server/storage/files";
|
||||
|
||||
type Props = {
|
||||
/** The integrationId to operate on */
|
||||
integrationId: string;
|
||||
/** The original logoUrl from Linear */
|
||||
logoUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A task that uploads the provided logoUrl to storage and updates the
|
||||
* Linear integration record with the new url.
|
||||
*/
|
||||
export default class UploadLinearWorkspaceLogoTask extends BaseTask<Props> {
|
||||
public async perform(props: Props) {
|
||||
const integration = await Integration.scope("withAuthentication").findByPk<
|
||||
Integration<IntegrationType.Embed>
|
||||
>(props.integrationId);
|
||||
if (!integration || integration.service !== IntegrationService.Linear) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await FileStorage.storeFromUrl(
|
||||
props.logoUrl,
|
||||
`${Buckets.avatars}/${integration.teamId}/${uuidv4()}`,
|
||||
"public-read",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${integration.authentication.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res?.url) {
|
||||
integration.settings.linear!.workspace.logoUrl = res.url;
|
||||
integration.changed("settings", true);
|
||||
await integration.save();
|
||||
}
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
attempts: 3,
|
||||
priority: TaskPriority.Normal,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export class LinearUtils {
|
||||
scope: this.oauthScopes,
|
||||
response_type: "code",
|
||||
prompt: "consent",
|
||||
actor: "application",
|
||||
actor: "app",
|
||||
};
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Integration, IntegrationAuthentication, Team } from "@server/models";
|
||||
import { Integration, IntegrationAuthentication } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { NotionClient } from "../notion";
|
||||
import * as T from "./schema";
|
||||
@@ -17,49 +16,21 @@ router.get(
|
||||
"notion.callback",
|
||||
auth({ optional: true }),
|
||||
validate(T.NotionCallbackSchema),
|
||||
apexAuthRedirect<T.NotionCallbackReq>({
|
||||
getTeamId: (ctx) => NotionUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
NotionUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => NotionUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.NotionCallbackReq>) => {
|
||||
const { code, state, error } = ctx.input.query;
|
||||
const { code, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let parsedState;
|
||||
try {
|
||||
parsedState = NotionUtils.parseState(state);
|
||||
} catch {
|
||||
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = parsedState;
|
||||
|
||||
// This code block accounts for the root domain being unable to access authentication for subdomains.
|
||||
// We must forward to the appropriate subdomain to complete the oauth flow.
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
NotionUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(NotionUtils.errorUrl(error));
|
||||
|
||||
@@ -66,6 +66,6 @@ export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.
|
||||
protected async scheduleTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
): Promise<void> {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
protected async scheduleNextTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
) {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@ import Router from "koa-router";
|
||||
import { Profile } from "passport";
|
||||
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import {
|
||||
IntegrationAuthentication,
|
||||
Integration,
|
||||
Team,
|
||||
User,
|
||||
Collection,
|
||||
} from "@server/models";
|
||||
@@ -126,6 +125,15 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
"slack.post",
|
||||
auth({ optional: true }),
|
||||
validate(T.SlackPostSchema),
|
||||
apexAuthRedirect<T.SlackPostReq>({
|
||||
getTeamId: (ctx) => SlackUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
SlackUtils.connectUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => SlackUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
async (ctx: APIContext<T.SlackPostReq>) => {
|
||||
const { code, error, state } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -144,31 +152,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
throw ValidationError("Invalid state");
|
||||
}
|
||||
|
||||
const { teamId, collectionId, type } = parsedState;
|
||||
|
||||
// This code block accounts for the root domain being unable to access authentication for
|
||||
// subdomains. We must forward to the appropriate subdomain to complete the OAuth flow.
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
SlackUtils.connectUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
return ctx.redirect(SlackUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(SlackUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
const { collectionId, type } = parsedState;
|
||||
|
||||
switch (type) {
|
||||
case IntegrationType.Post: {
|
||||
|
||||
@@ -29,8 +29,12 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalled();
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
@@ -53,7 +57,9 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("it schedules a delivery for the event for each subscription", async () => {
|
||||
@@ -79,13 +85,21 @@ describe("WebhookProcessor", () => {
|
||||
|
||||
await processor.perform(event);
|
||||
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(2);
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalled();
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||
expect(
|
||||
jest.mocked(DeliverWebhookTask.prototype.schedule)
|
||||
).toHaveBeenCalledWith({
|
||||
event,
|
||||
subscriptionId: subscriptionTwo.id,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,10 @@ export default class WebhookProcessor extends BaseProcessor {
|
||||
|
||||
await Promise.all(
|
||||
applicableSubscriptions.map((subscription) =>
|
||||
DeliverWebhookTask.schedule({ event, subscriptionId: subscription.id })
|
||||
new DeliverWebhookTask().schedule({
|
||||
event,
|
||||
subscriptionId: subscription.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type Props = {
|
||||
/** Position of moved document within document structure */
|
||||
index?: number;
|
||||
/** The IP address of the user moving the document */
|
||||
ip: string;
|
||||
ip: string | null;
|
||||
/** The database transaction to run within */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
@@ -4,9 +4,11 @@ import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
||||
import { buildAttachment, buildDocument } from "@server/test/factories";
|
||||
import documentPermanentDeleter from "./documentPermanentDeleter";
|
||||
|
||||
jest.mock("@server/queues/tasks/DeleteAttachmentTask", () => ({
|
||||
schedule: jest.fn(),
|
||||
}));
|
||||
jest.mock("@server/queues/tasks/DeleteAttachmentTask");
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("documentPermanentDeleter", () => {
|
||||
it("should destroy documents", async () => {
|
||||
@@ -60,7 +62,9 @@ describe("documentPermanentDeleter", () => {
|
||||
await document.save();
|
||||
const countDeletedDoc = await documentPermanentDeleter([document]);
|
||||
expect(countDeletedDoc).toEqual(1);
|
||||
expect(DeleteAttachmentTask.schedule).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(DeleteAttachmentTask.prototype.schedule)
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
await Document.unscoped().count({
|
||||
where: {
|
||||
|
||||
@@ -67,7 +67,7 @@ export default async function documentPermanentDeleter(documents: Document[]) {
|
||||
"commands",
|
||||
`Attachment ${attachmentId} scheduled for deletion`
|
||||
);
|
||||
await DeleteAttachmentTask.schedule({
|
||||
await new DeleteAttachmentTask().schedule({
|
||||
attachmentId,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
|
||||
@@ -56,5 +56,5 @@ export default async function userSuspender({
|
||||
}
|
||||
);
|
||||
|
||||
await CleanupDemotedUserTask.schedule({ userId: user.id });
|
||||
await new CleanupDemotedUserTask().schedule({ userId: user.id });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Next } from "koa";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
/**
|
||||
* An authentication middleware that should be used on routes that return from external auth flows
|
||||
* to the apex domain. In these cases the user will be redirected to the correct subdomain where
|
||||
* they are authenticated.
|
||||
*
|
||||
* @param options Options for the middleware
|
||||
* @returns Koa middleware function
|
||||
*/
|
||||
export default function apexAuthRedirect<T>({
|
||||
getTeamId,
|
||||
getRedirectPath,
|
||||
getErrorPath,
|
||||
}: {
|
||||
/** Get the team ID for the current request */
|
||||
getTeamId: (ctx: APIContext<T>) => string | null | undefined;
|
||||
/** Get the redirect URL for the given team ID */
|
||||
getRedirectPath: (ctx: APIContext<T>, team: Team) => string;
|
||||
/** Get the error URL for the current request */
|
||||
getErrorPath: (ctx: APIContext<T>) => string;
|
||||
}) {
|
||||
return async function apexAuthRedirectMiddleware(
|
||||
ctx: APIContext<T>,
|
||||
next: Next
|
||||
) {
|
||||
const { user } = ctx.state.auth;
|
||||
if (user) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const teamId = getTeamId(ctx);
|
||||
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
attributes: ["id", "subdomain"],
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(getRedirectPath(ctx, team));
|
||||
} catch (err) {
|
||||
return ctx.redirect(getErrorPath(ctx));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(getErrorPath(ctx));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -408,7 +408,7 @@ class Team extends ParanoidModel<
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
await DeleteAttachmentTask.schedule({
|
||||
await new DeleteAttachmentTask().schedule({
|
||||
attachmentId: attachment.id,
|
||||
teamId: model.id,
|
||||
});
|
||||
|
||||
@@ -717,7 +717,7 @@ class User extends ParanoidModel<
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
await DeleteAttachmentTask.schedule({
|
||||
await new DeleteAttachmentTask().schedule({
|
||||
attachmentId: attachment.id,
|
||||
teamId: model.teamId,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { DocumentPermission, NotificationEventType } from "@shared/types";
|
||||
import { UserMembership } from "@server/models";
|
||||
import {
|
||||
buildComment,
|
||||
buildDocument,
|
||||
buildDraftDocument,
|
||||
buildSubscription,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
@@ -54,6 +56,78 @@ describe("NotificationHelper", () => {
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
|
||||
});
|
||||
|
||||
it("should only return users who have notification enabled for comment creation and are subscribed to the document in case of new thread in draft", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
|
||||
// create a draft
|
||||
const document = await buildDraftDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
collectionId: null,
|
||||
});
|
||||
|
||||
// add a bunch of users as direct members
|
||||
const user = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
const user2 = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
const user3 = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
await UserMembership.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission: DocumentPermission.Read,
|
||||
createdById: user.id,
|
||||
});
|
||||
await UserMembership.create({
|
||||
documentId: document.id,
|
||||
userId: user2.id,
|
||||
permission: DocumentPermission.Read,
|
||||
createdById: user.id,
|
||||
});
|
||||
await UserMembership.create({
|
||||
documentId: document.id,
|
||||
userId: user3.id,
|
||||
permission: DocumentPermission.Read,
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
// Add a subscription for only one of those users
|
||||
await Promise.all([
|
||||
buildSubscription({
|
||||
userId: user.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: user2.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: user3.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const comment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: documentAuthor.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getCommentNotificationRecipients(
|
||||
document,
|
||||
comment,
|
||||
comment.createdById
|
||||
);
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(user3.id);
|
||||
});
|
||||
|
||||
it("should only return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
|
||||
@@ -193,10 +193,16 @@ export default class NotificationHelper {
|
||||
[Op.ne]: actorId,
|
||||
},
|
||||
event: SubscriptionType.Document,
|
||||
[Op.or]: [
|
||||
{ collectionId: document.collectionId },
|
||||
{ documentId: document.id },
|
||||
],
|
||||
...(document.collectionId
|
||||
? {
|
||||
[Op.or]: [
|
||||
{ collectionId: document.collectionId },
|
||||
{ documentId: document.id },
|
||||
],
|
||||
}
|
||||
: {
|
||||
documentId: document.id,
|
||||
}),
|
||||
},
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ export default class AvatarProcessor extends BaseProcessor {
|
||||
});
|
||||
|
||||
if (user.avatarUrl) {
|
||||
await UploadUserAvatarTask.schedule({
|
||||
await new UploadUserAvatarTask().schedule({
|
||||
userId: event.userId,
|
||||
avatarUrl: user.avatarUrl,
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export default class AvatarProcessor extends BaseProcessor {
|
||||
});
|
||||
|
||||
if (team.avatarUrl) {
|
||||
await UploadTeamAvatarTask.schedule({
|
||||
await new UploadTeamAvatarTask().schedule({
|
||||
teamId: event.teamId,
|
||||
avatarUrl: team.avatarUrl,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export default class CollectionsProcessor extends BaseProcessor {
|
||||
];
|
||||
|
||||
async perform(event: CollectionEvent) {
|
||||
await DetachDraftsFromCollectionTask.schedule({
|
||||
await new DetachDraftsFromCollectionTask().schedule({
|
||||
collectionId: event.collectionId,
|
||||
actorId: event.actorId,
|
||||
ip: event.ip,
|
||||
|
||||
@@ -27,7 +27,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
async perform(event: ReceivedEvent) {
|
||||
switch (event.name) {
|
||||
case "collections.remove_user": {
|
||||
await CollectionSubscriptionRemoveUserTask.schedule(event);
|
||||
await new CollectionSubscriptionRemoveUserTask().schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
return this.handleRemoveGroupFromCollection(event);
|
||||
|
||||
case "documents.remove_user": {
|
||||
await DocumentSubscriptionRemoveUserTask.schedule(event);
|
||||
await new DocumentSubscriptionRemoveUserTask().schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,11 +57,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map((groupUser) =>
|
||||
CollectionSubscriptionRemoveUserTask.schedule({
|
||||
new CollectionSubscriptionRemoveUserTask().schedule({
|
||||
...event,
|
||||
name: "collections.remove_user",
|
||||
userId: groupUser.userId,
|
||||
})
|
||||
} as CollectionUserEvent)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -79,11 +79,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map((groupUser) =>
|
||||
DocumentSubscriptionRemoveUserTask.schedule({
|
||||
new DocumentSubscriptionRemoveUserTask().schedule({
|
||||
...event,
|
||||
name: "documents.remove_user",
|
||||
userId: groupUser.userId,
|
||||
})
|
||||
} as DocumentUserEvent)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,12 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
|
||||
if (fileOperation.type === FileOperationType.Import) {
|
||||
switch (fileOperation.format) {
|
||||
case FileOperationFormat.MarkdownZip:
|
||||
await ImportMarkdownZipTask.schedule({
|
||||
await new ImportMarkdownZipTask().schedule({
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
case FileOperationFormat.JSON:
|
||||
await ImportJSONTask.schedule({
|
||||
await new ImportJSONTask().schedule({
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
@@ -36,17 +36,17 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
|
||||
if (fileOperation.type === FileOperationType.Export) {
|
||||
switch (fileOperation.format) {
|
||||
case FileOperationFormat.HTMLZip:
|
||||
await ExportHTMLZipTask.schedule({
|
||||
await new ExportHTMLZipTask().schedule({
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
case FileOperationFormat.MarkdownZip:
|
||||
await ExportMarkdownZipTask.schedule({
|
||||
await new ExportMarkdownZipTask().schedule({
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
case FileOperationFormat.JSON:
|
||||
await ExportJSONTask.schedule({
|
||||
await new ExportJSONTask().schedule({
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -20,7 +20,7 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
}
|
||||
|
||||
// Store the available issue sources in the integration record.
|
||||
await CacheIssueSourcesTask.schedule({
|
||||
await new CacheIssueSourcesTask().schedule({
|
||||
integrationId: integration.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -62,25 +62,25 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await DocumentPublishedNotificationsTask.schedule(event);
|
||||
await new DocumentPublishedNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async documentAddUser(event: DocumentUserEvent) {
|
||||
if (!event.data.isNew || event.userId === event.actorId) {
|
||||
return;
|
||||
}
|
||||
await DocumentAddUserNotificationsTask.schedule(event);
|
||||
await new DocumentAddUserNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async documentAddGroup(event: DocumentGroupEvent) {
|
||||
if (!event.data.isNew) {
|
||||
return;
|
||||
}
|
||||
await DocumentAddGroupNotificationsTask.schedule(event);
|
||||
await new DocumentAddGroupNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async revisionCreated(event: RevisionEvent) {
|
||||
await RevisionCreatedNotificationsTask.schedule(event);
|
||||
await new RevisionCreatedNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async collectionCreated(event: CollectionEvent) {
|
||||
@@ -93,7 +93,7 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await CollectionCreatedNotificationsTask.schedule(event);
|
||||
await new CollectionCreatedNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async collectionAddUser(event: CollectionUserEvent) {
|
||||
@@ -101,14 +101,14 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await CollectionAddUserNotificationsTask.schedule(event);
|
||||
await new CollectionAddUserNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async commentCreated(event: CommentEvent) {
|
||||
await CommentCreatedNotificationsTask.schedule(event);
|
||||
await new CommentCreatedNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async commentUpdated(event: CommentEvent) {
|
||||
await CommentUpdatedNotificationsTask.schedule(event);
|
||||
await new CommentUpdatedNotificationsTask().schedule(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class RevisionsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await DocumentUpdateTextTask.schedule(event);
|
||||
await new DocumentUpdateTextTask().schedule(event);
|
||||
|
||||
const user = await User.findByPk(event.actorId, {
|
||||
paranoid: false,
|
||||
|
||||
@@ -6,6 +6,6 @@ export default class UserDemotedProcessor extends BaseProcessor {
|
||||
static applicableEvents: TEvent["name"][] = ["users.demote"];
|
||||
|
||||
async perform(event: UserEvent) {
|
||||
await CleanupDemotedUserTask.schedule({ userId: event.userId });
|
||||
await new CleanupDemotedUserTask().schedule({ userId: event.userId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +325,9 @@ export default abstract class APIImportTask<
|
||||
([url, attachment]) => ({ attachmentId: attachment.id, url })
|
||||
);
|
||||
// publish task after attachments are persisted in DB.
|
||||
const job = await UploadAttachmentsForImportTask.schedule(uploadItems);
|
||||
const job = await new UploadAttachmentsForImportTask().schedule(
|
||||
uploadItems
|
||||
);
|
||||
await job.finished();
|
||||
} catch (err) {
|
||||
// upload attachments failure is not critical enough to fail the whole import.
|
||||
|
||||
@@ -21,7 +21,7 @@ export default abstract class BaseTask<T extends Record<string, any>> {
|
||||
static cron: TaskSchedule | undefined;
|
||||
|
||||
/**
|
||||
* Schedule this task type to be processed asyncronously by a worker.
|
||||
* Schedule this task type to be processed asynchronously by a worker.
|
||||
*
|
||||
* @param props Properties to be used by the task
|
||||
* @returns A promise that resolves once the job is placed on the task queue
|
||||
@@ -39,6 +39,23 @@ export default abstract class BaseTask<T extends Record<string, any>> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule this task type to be processed asynchronously by a worker.
|
||||
*
|
||||
* @param props Properties to be used by the task
|
||||
* @param options Job options such as priority and retry strategy, as defined by Bull.
|
||||
* @returns A promise that resolves once the job is placed on the task queue
|
||||
*/
|
||||
public schedule(props: T, options?: JobOptions): Promise<Job> {
|
||||
return taskQueue.add(
|
||||
{
|
||||
name: this.constructor.name,
|
||||
props,
|
||||
},
|
||||
{ ...options, ...this.options }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the task.
|
||||
*
|
||||
|
||||
@@ -29,7 +29,7 @@ export default class CleanupDeletedTeamsTask extends BaseTask<Props> {
|
||||
});
|
||||
|
||||
for (const team of teams) {
|
||||
await CleanupDeletedTeamTask.schedule({
|
||||
await new CleanupDeletedTeamTask().schedule({
|
||||
teamId: team.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import BaseTask from "./BaseTask";
|
||||
type Props = {
|
||||
collectionId: string;
|
||||
actorId: string;
|
||||
ip: string;
|
||||
ip: string | null;
|
||||
};
|
||||
|
||||
export default class DetachDraftsFromCollectionTask extends BaseTask<Props> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Op } from "sequelize";
|
||||
import { GroupUser } from "@server/models";
|
||||
import { DocumentGroupEvent } from "@server/types";
|
||||
import { DocumentGroupEvent, DocumentUserEvent } from "@server/types";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask";
|
||||
|
||||
@@ -19,11 +19,12 @@ export default class DocumentAddGroupNotificationsTask extends BaseTask<Document
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map(async (groupUser) => {
|
||||
await DocumentAddUserNotificationsTask.schedule({
|
||||
await new DocumentAddUserNotificationsTask().schedule({
|
||||
...event,
|
||||
name: "documents.add_user",
|
||||
modelId: event.data.membershipId,
|
||||
userId: groupUser.userId,
|
||||
});
|
||||
} as DocumentUserEvent);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
sourceMetadata: Pick<Required<SourceMetadata>, "fileName" | "mimeType">;
|
||||
publish?: boolean;
|
||||
collectionId?: string;
|
||||
parentDocumentId?: string;
|
||||
parentDocumentId?: string | null;
|
||||
ip: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export default class UpdateTeamsAttachmentsSizeTask extends BaseTask<Props> {
|
||||
const teamIds = rows.map((row) => row.teamId);
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
await UpdateTeamAttachmentsSizeTask.schedule({ teamId });
|
||||
await new UpdateTeamAttachmentsSizeTask().schedule({ teamId });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -166,7 +166,7 @@ router.post(
|
||||
)
|
||||
);
|
||||
|
||||
const job = await UploadAttachmentFromUrlTask.schedule({
|
||||
const job = await new UploadAttachmentFromUrlTask().schedule({
|
||||
attachmentId: attachment.id,
|
||||
url,
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
|
||||
// If the user did not _just_ sign in then we need to check if they continue
|
||||
// to have access to the workspace they are signed into.
|
||||
if (user.lastSignedInAt && user.lastSignedInAt < subHours(new Date(), 1)) {
|
||||
await ValidateSSOAccessTask.schedule({ userId: user.id });
|
||||
await new ValidateSSOAccessTask().schedule({ userId: user.id });
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -29,7 +29,8 @@ const cronHandler = async (ctx: APIContext<T.CronSchemaReq>) => {
|
||||
for (const name in tasks) {
|
||||
const TaskClass = tasks[name];
|
||||
if (TaskClass.cron === period) {
|
||||
await TaskClass.schedule({ limit });
|
||||
// @ts-expect-error We won't instantiate an abstract class
|
||||
await new TaskClass().schedule({ limit });
|
||||
|
||||
// Backwards compatibility for installations that have not set up
|
||||
// cron jobs periods other than daily.
|
||||
@@ -38,13 +39,15 @@ const cronHandler = async (ctx: APIContext<T.CronSchemaReq>) => {
|
||||
!receivedPeriods.has(TaskSchedule.Minute) &&
|
||||
(period === TaskSchedule.Hour || period === TaskSchedule.Day)
|
||||
) {
|
||||
await TaskClass.schedule({ limit });
|
||||
// @ts-expect-error We won't instantiate an abstract class
|
||||
await new TaskClass().schedule({ limit });
|
||||
} else if (
|
||||
TaskClass.cron === TaskSchedule.Hour &&
|
||||
!receivedPeriods.has(TaskSchedule.Hour) &&
|
||||
period === TaskSchedule.Day
|
||||
) {
|
||||
await TaskClass.schedule({ limit });
|
||||
// @ts-expect-error We won't instantiate an abstract class
|
||||
await new TaskClass().schedule({ limit });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1539,7 +1539,7 @@ router.post(
|
||||
acl,
|
||||
});
|
||||
|
||||
const job = await DocumentImportTask.schedule({
|
||||
const job = await new DocumentImportTask().schedule({
|
||||
key,
|
||||
sourceMetadata: {
|
||||
fileName,
|
||||
@@ -1549,6 +1549,7 @@ router.post(
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
publish,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
const response: DocumentImportTaskResponse = await job.finished();
|
||||
if ("error" in response) {
|
||||
@@ -2062,7 +2063,7 @@ router.post(
|
||||
});
|
||||
|
||||
if (documents.length) {
|
||||
await EmptyTrashTask.schedule({
|
||||
await new EmptyTrashTask().schedule({
|
||||
documentIds: documents.map((doc) => doc.id),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ export default function init() {
|
||||
for (const name in tasks) {
|
||||
const TaskClass = tasks[name];
|
||||
if (TaskClass.cron === schedule) {
|
||||
await TaskClass.schedule({ limit: 10000 });
|
||||
// @ts-expect-error We won't instantiate an abstract class
|
||||
await new TaskClass().schedule({ limit: 10000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import cookie from "cookie";
|
||||
import Koa from "koa";
|
||||
import IO from "socket.io";
|
||||
import { createAdapter } from "socket.io-redis";
|
||||
import env from "@server/env";
|
||||
import { AuthenticationError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Metrics from "@server/logging/Metrics";
|
||||
@@ -38,7 +39,8 @@ export default function init(
|
||||
pingInterval: 15000,
|
||||
pingTimeout: 30000,
|
||||
cors: {
|
||||
origin: "*",
|
||||
// Included for completeness, though CORS does not apply to websocket transport.
|
||||
origin: env.isCloudHosted ? "*" : env.URL,
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
});
|
||||
@@ -60,6 +62,16 @@ export default function init(
|
||||
"upgrade",
|
||||
function (req: IncomingMessage, socket: Duplex, head: Buffer) {
|
||||
if (req.url?.startsWith(path) && ioHandleUpgrade) {
|
||||
// For on-premise deployments, ensure the websocket origin matches the deployed URL.
|
||||
// In cloud-hosted we support any origin for custom domains.
|
||||
if (
|
||||
!env.isCloudHosted &&
|
||||
(!req.headers.origin || !env.URL.startsWith(req.headers.origin))
|
||||
) {
|
||||
socket.end(`HTTP/1.1 400 Bad Request\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
ioHandleUpgrade(req, socket, head);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Blob } from "buffer";
|
||||
import { Readable } from "stream";
|
||||
import { PresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import omit from "lodash/omit";
|
||||
import FileHelper from "@shared/editor/lib/FileHelper";
|
||||
import { isBase64Url, isInternalUrl } from "@shared/utils/urls";
|
||||
import env from "@server/env";
|
||||
@@ -162,6 +163,12 @@ export default abstract class BaseStorage {
|
||||
buffer = Buffer.from(match[2], "base64");
|
||||
} else {
|
||||
try {
|
||||
const headers = {
|
||||
"User-Agent": chromeUserAgent,
|
||||
...init?.headers,
|
||||
};
|
||||
const initWithoutHeaders = omit(init, ["headers"]);
|
||||
|
||||
const res = await fetch(url, {
|
||||
follow: 3,
|
||||
redirect: "follow",
|
||||
@@ -169,11 +176,9 @@ export default abstract class BaseStorage {
|
||||
options?.maxUploadSize ?? Infinity,
|
||||
env.FILE_STORAGE_UPLOAD_MAX_SIZE
|
||||
),
|
||||
headers: {
|
||||
"User-Agent": chromeUserAgent,
|
||||
},
|
||||
headers,
|
||||
timeout: 10000,
|
||||
...init,
|
||||
...initWithoutHeaders,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "../styles";
|
||||
|
||||
const BacktickSpan = styled.span`
|
||||
font-family: ${s("fontFamilyMono")};
|
||||
background: ${s("codeBackground")};
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
font-size: 90%;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
/** The content to be rendered that may contain backticks. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render backticked content with styling.
|
||||
* @param props - Props object containing the content to be rendered.
|
||||
* @returns JSX.Element - The rendered component.
|
||||
*/
|
||||
export const Backticks: React.FC<Props> = ({ content }) => {
|
||||
// Regex to match text between backticks
|
||||
const regex = /`([^`]+)`/g;
|
||||
const parts = content.split(regex);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Even indices are normal text, odd indices are backticked content
|
||||
if (i % 2 === 0) {
|
||||
return part;
|
||||
}
|
||||
return <BacktickSpan key={i}>{part}</BacktickSpan>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,15 +2,15 @@ import React from "react";
|
||||
import { BaseIconProps } from ".";
|
||||
|
||||
export function GitHubIssueStatusIcon(props: BaseIconProps) {
|
||||
const { state, className } = props;
|
||||
const { state, className, size = 16 } = props;
|
||||
|
||||
switch (state.name) {
|
||||
case "open":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={state.color}
|
||||
className={className}
|
||||
>
|
||||
@@ -22,8 +22,8 @@ export function GitHubIssueStatusIcon(props: BaseIconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={state.color}
|
||||
className={className}
|
||||
>
|
||||
@@ -35,8 +35,8 @@ export function GitHubIssueStatusIcon(props: BaseIconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={state.color}
|
||||
className={className}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ enum StateType {
|
||||
|
||||
export function LinearIssueStatusIcon(props: BaseIconProps) {
|
||||
const theme = useTheme();
|
||||
const { state } = props;
|
||||
const { state, size = 16 } = props;
|
||||
const percentage =
|
||||
state.type === StateType.Triage ||
|
||||
state.type === StateType.Backlog ||
|
||||
@@ -31,7 +31,7 @@ export function LinearIssueStatusIcon(props: BaseIconProps) {
|
||||
const magicFour = isSafari() ? 3.895 : 3.98;
|
||||
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<circle
|
||||
cx={7}
|
||||
cy={7}
|
||||
|
||||
@@ -12,23 +12,27 @@ import { LinearIssueStatusIcon } from "./LinearIssueStatusIcon";
|
||||
export type BaseIconProps = {
|
||||
state: UnfurlResponse[UnfurlResourceType.Issue]["state"];
|
||||
className?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type Props = BaseIconProps & {
|
||||
size?: number;
|
||||
service: IssueTrackerIntegrationService;
|
||||
};
|
||||
|
||||
export function IssueStatusIcon(props: Props) {
|
||||
return <Icon size={props.size}>{getIcon(props)}</Icon>;
|
||||
return (
|
||||
<Icon size={props.size} className={props.className}>
|
||||
{getIcon(props)}
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
function getIcon({ service, ...rest }: Props) {
|
||||
switch (service) {
|
||||
function getIcon(props: Props) {
|
||||
switch (props.service) {
|
||||
case IntegrationService.GitHub:
|
||||
return <GitHubIssueStatusIcon {...rest} />;
|
||||
return <GitHubIssueStatusIcon {...props} />;
|
||||
case IntegrationService.Linear:
|
||||
return <LinearIssueStatusIcon {...rest} />;
|
||||
return <LinearIssueStatusIcon {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "../types";
|
||||
|
||||
type Props = {
|
||||
status: string;
|
||||
color: string;
|
||||
state: UnfurlResponse[UnfurlResourceType.PR]["state"];
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
@@ -11,10 +11,10 @@ type Props = {
|
||||
/**
|
||||
* Issue status icon based on GitHub pull requests, but can be used for any git-style integration.
|
||||
*/
|
||||
export function PullRequestIcon({ size, ...rest }: Props) {
|
||||
export function PullRequestIcon({ size, className, state }: Props) {
|
||||
return (
|
||||
<Icon size={size}>
|
||||
<BaseIcon {...rest} />
|
||||
<Icon size={size} className={className}>
|
||||
<BaseIcon state={state} />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
@@ -28,42 +28,32 @@ const Icon = styled.span<{ size?: number }>`
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
function BaseIcon(props: Props) {
|
||||
switch (props.status) {
|
||||
function BaseIcon({ state }: Pick<Props, "state">) {
|
||||
switch (state.name) {
|
||||
case "open":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
<svg viewBox="0 0 16 16" fill={state.color}>
|
||||
{state.draft ? (
|
||||
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 14a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM14 7.5a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm0-4.25a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z" />
|
||||
) : (
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
case "merged":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill={state.color}>
|
||||
<path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />
|
||||
</svg>
|
||||
);
|
||||
case "closed":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill={props.color}
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
||||
<svg viewBox="0 0 16 16" fill={state.color}>
|
||||
{state.draft ? (
|
||||
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
||||
) : (
|
||||
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "../../components/Backticks";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
import { IssueStatusIcon } from "../../components/IssueStatusIcon";
|
||||
@@ -207,7 +208,9 @@ export const MentionIssue = observer((props: IssuePrProps) => {
|
||||
<Flex align="center" gap={6}>
|
||||
<IssueStatusIcon size={14} service={service} state={issue.state} />
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{issue.title}</Text>
|
||||
<Text>
|
||||
<Backticks content={issue.title} />
|
||||
</Text>
|
||||
<Text type="tertiary">{issue.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -276,13 +279,11 @@ export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<PullRequestIcon
|
||||
size={14}
|
||||
status={pullRequest.state.name}
|
||||
color={pullRequest.state.color}
|
||||
/>
|
||||
<PullRequestIcon size={14} state={pullRequest.state} />
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{pullRequest.title}</Text>
|
||||
<Text>
|
||||
<Backticks content={pullRequest.title} />
|
||||
</Text>
|
||||
<Text type="tertiary">{pullRequest.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -1091,6 +1091,10 @@ p a {
|
||||
}
|
||||
}
|
||||
|
||||
.heading-content a {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${props.theme.link};
|
||||
cursor: pointer;
|
||||
|
||||
@@ -56,8 +56,8 @@ export const basicExtensions: Nodes = [
|
||||
Emoji,
|
||||
Text,
|
||||
SimpleImage,
|
||||
Bold,
|
||||
Code,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Link,
|
||||
|
||||
+1
-1
@@ -461,7 +461,7 @@ export type UnfurlResponse = {
|
||||
/** Pull Request author */
|
||||
author: { name: string; avatarUrl: string };
|
||||
/** Pull Request status */
|
||||
state: { name: string; color: string };
|
||||
state: { name: string; color: string; draft?: boolean };
|
||||
/** Pull Request creation time */
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -117,6 +117,7 @@ export const RESERVED_SUBDOMAINS = [
|
||||
"localhost",
|
||||
"mail",
|
||||
"marketing",
|
||||
"mcp",
|
||||
"mobile",
|
||||
"multiplayer",
|
||||
"new",
|
||||
|
||||
+10
-2
@@ -3,12 +3,12 @@ import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import browserslistToEsbuild from "browserslist-to-esbuild";
|
||||
import webpackStats from "rollup-plugin-webpack-stats";
|
||||
import { CommonServerOptions, defineConfig } from "vite";
|
||||
import { ServerOptions, defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import environment from "./server/utils/environment";
|
||||
|
||||
let httpsConfig: CommonServerOptions["https"] | undefined;
|
||||
let httpsConfig: ServerOptions["https"] | undefined;
|
||||
let host: string | undefined;
|
||||
|
||||
if (environment.NODE_ENV === "development") {
|
||||
@@ -145,6 +145,7 @@ export default () =>
|
||||
},
|
||||
}),
|
||||
// Generate a stats.json file for webpack that will be consumed by RelativeCI
|
||||
// @ts-expect-error Type mismatch with latest versions but Plugin runs without issue
|
||||
webpackStats(),
|
||||
],
|
||||
optimizeDeps: {
|
||||
@@ -175,6 +176,13 @@ export default () =>
|
||||
keep_fnames: true,
|
||||
},
|
||||
rollupOptions: {
|
||||
onwarn(warning, warn) {
|
||||
// Suppress noisy warnings about module-level directives, e.g. "use client"
|
||||
if (warning.code === "MODULE_LEVEL_DIRECTIVE") {
|
||||
return;
|
||||
}
|
||||
warn(warning);
|
||||
},
|
||||
input: {
|
||||
index: "./app/index.tsx",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user