Compare commits

...

26 Commits

Author SHA1 Message Date
Tom Moor 7c8ba94551 fix: Subtle collection loading bug 2025-05-01 23:20:28 -04:00
dependabot[bot] 00bab31cff chore(deps): bump vite from 6.3.3 to 6.3.4 (#9112)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.3 to 6.3.4.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-30 18:16:38 -04:00
Tom Moor 3ef2b7cf42 fix: Backlinks should be ordered alphabetically (#9106) 2025-04-30 02:17:03 +00:00
Tom Moor 18743da2fc fix: bold inline code marks cause formatting to split (#9105)
* fix: Inline code mark split around bold

* Show inline formatting options + code in toolbar
2025-04-30 01:50:52 +00:00
dependabot[bot] fe1307d7e7 chore(deps): bump the aws group with 5 updates (#9086)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.787.0` | `3.797.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.787.0` | `3.797.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.787.0` | `3.797.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.787.0` | `3.797.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.787.0` | `3.796.0` |


Updates `@aws-sdk/client-s3` from 3.787.0 to 3.797.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.797.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.787.0 to 3.797.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.797.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.787.0 to 3.797.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.797.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.787.0 to 3.797.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.797.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.787.0 to 3.796.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.796.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.796.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-29 06:48:04 -04:00
codegen-sh[bot] a226889143 Update task scheduling to use instance method (#9092)
* Update task scheduling to use instance method

* Delete update_task_schedule.sh

* Applied automatic fixes

* tsc

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-29 06:47:51 -04:00
Tom Moor 347f033802 fix: Notifications received for draft with access but no subscription (#9099) 2025-04-29 06:45:15 -04:00
Tom Moor f5c659f902 fix: Prevent cross-domain websocket connections to on-premise instances (#9064) 2025-04-28 17:27:40 -04:00
Hemachandar 722d10e7de Implement type-safe schedule method for tasks (#9079)
* Implement type-safe task scheduler

* introduce 'schedule' instance method

* typo
2025-04-28 17:27:24 -04:00
Hemachandar ce001547b5 fix: Check pasted text is url before creating an URL object (#9082) 2025-04-28 17:27:12 -04:00
dependabot[bot] 8d05e2b095 chore(deps): bump pg from 8.14.1 to 8.15.6 (#9084)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.14.1 to 8.15.6.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.15.6/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.15.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 17:26:54 -04:00
dependabot[bot] 19e40cf814 chore(deps-dev): bump nodemon from 3.1.9 to 3.1.10 (#9085)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.9 to 3.1.10.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: nodemon
  dependency-version: 3.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 17:26:28 -04:00
dependabot[bot] 2bb9b50637 chore(deps): bump react-portal from 4.2.2 to 4.3.0 (#9087)
Bumps [react-portal](https://github.com/tajo/react-portal) from 4.2.2 to 4.3.0.
- [Release notes](https://github.com/tajo/react-portal/releases)
- [Commits](https://github.com/tajo/react-portal/commits)

---
updated-dependencies:
- dependency-name: react-portal
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 17:26:18 -04:00
Tom Moor 4885612661 Switch Linear to actor=app method (#9074) 2025-04-27 15:01:23 +00:00
Tom Moor e2dd6221f8 Extract subdomain auth redirect (#9070)
* Extract subdomain auth redirect

* docs
2025-04-27 10:55:05 -04:00
Hemachandar 7f513a6950 fix: Store Linear workspace logo only when it's available (#9072) 2025-04-27 09:26:36 -04:00
Tom Moor 6440d78b6f fix: Double fetch on refactored paginated list (#9068) 2025-04-26 21:35:41 +00:00
Tom Moor 7e05fc1017 Revert "Add recency boost to search results (#9038)" (#9065)
This reverts commit 2bc47cfcef.
2025-04-26 16:44:49 +00:00
Tom Moor 2bc47cfcef Add recency boost to search results (#9038)
* Add recency boost to search helpers

* Restore tests

* Use boost
2025-04-26 08:27:45 -04:00
Hemachandar e8e46a438c fix: Store Linear workspace logo in storage (#9061)
* fix: Store Linear workspace logo in Outline

* use async task

* Move task into plugin

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-25 23:18:21 -04:00
Tom Moor 3156f62e94 Vite 5 -> 6 upgrade (#9057)
* Vite 5 -> 6

* Revert i18next-parser upgrade

* rolldown

* fix build

* tsc
2025-04-25 18:22:53 -04:00
Hemachandar 9274f56ef6 Show correct icon & color for GitHub draft PR (#9063) 2025-04-25 17:37:54 -04:00
Hemachandar 4bb9ac40c7 fix: Linear status icon completion percentage edge case (#9062) 2025-04-25 13:17:28 -04:00
Tom Moor 36772f1444 fix: Heading weight changes when linkified (#9058) 2025-04-25 12:53:28 +00:00
Tom Moor e503225f04 fix: Tidying mention hover cards (#9051)
* Tidying hover card layout

* Handle backticks in titles (common on GitHub + Linear)

* Improve label display
2025-04-24 23:49:19 -04:00
codegen-sh[bot] 762140e493 Add mcp to reserved subdomains (#9052)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-04-25 03:07:39 +00:00
69 changed files with 1087 additions and 731 deletions
+18 -7
View File
@@ -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}&nbsp;<Text type="tertiary">{id}</Text>
<Backticks content={title} />
&nbsp;<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}&nbsp;<Text type="tertiary">{id}</Text>
<Backticks content={title} />
&nbsp;<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;
+13 -7
View File
@@ -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(
+3 -2
View File
@@ -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 })
);
+3 -3
View File
@@ -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,
+10
View File
@@ -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.
*
+6 -12
View File
@@ -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)
: [];
+7
View File
@@ -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(
+2 -2
View File
@@ -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
View File
@@ -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",
+12 -33
View File
@@ -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(
+2 -1
View File
@@ -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,
+2 -2
View File
@@ -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":
+25 -42
View File
@@ -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());
}
);
+5
View File
@@ -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 },
+10 -3
View File
@@ -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,
};
}
}
+1 -1
View File
@@ -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)}`;
}
+12 -41
View File
@@ -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;
}
+11 -27
View File
@@ -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,
})
)
);
}
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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,
});
+1 -1
View File
@@ -56,5 +56,5 @@ export default async function userSuspender({
}
);
await CleanupDemotedUserTask.schedule({ userId: user.id });
await new CleanupDemotedUserTask().schedule({ userId: user.id });
}
+54
View File
@@ -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));
}
};
}
+1 -1
View File
@@ -408,7 +408,7 @@ class Team extends ParanoidModel<
});
if (attachment) {
await DeleteAttachmentTask.schedule({
await new DeleteAttachmentTask().schedule({
attachmentId: attachment.id,
teamId: model.id,
});
+1 -1
View File
@@ -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({
+10 -4
View File
@@ -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: [
{
+2 -2
View File
@@ -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 });
}
}
+3 -1
View File
@@ -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.
+18 -1
View File
@@ -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);
})
);
}
+1 -1
View File
@@ -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 });
}
}
);
+1 -1
View File
@@ -166,7 +166,7 @@ router.post(
)
);
const job = await UploadAttachmentFromUrlTask.schedule({
const job = await new UploadAttachmentFromUrlTask().schedule({
attachmentId: attachment.id,
url,
});
+1 -1
View File
@@ -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 = {
+6 -3
View File
@@ -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 });
}
}
+3 -2
View File
@@ -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),
});
}
+2 -1
View File
@@ -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 });
}
}
}
+13 -1
View File
@@ -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;
}
+9 -4
View File
@@ -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) {
+39
View File
@@ -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}
+10 -6
View File
@@ -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} />;
}
}
+20 -30
View File
@@ -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:
+8 -7
View File
@@ -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>
+4
View File
@@ -1091,6 +1091,10 @@ p a {
}
}
.heading-content a {
font-weight: inherit;
}
a {
color: ${props.theme.link};
cursor: pointer;
+1 -1
View File
@@ -56,8 +56,8 @@ export const basicExtensions: Nodes = [
Emoji,
Text,
SimpleImage,
Bold,
Code,
Bold,
Italic,
Underline,
Link,
+1 -1
View File
@@ -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;
};
+1
View File
@@ -117,6 +117,7 @@ export const RESERVED_SUBDOMAINS = [
"localhost",
"mail",
"marketing",
"mcp",
"mobile",
"multiplayer",
"new",
+10 -2
View File
@@ -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",
},
+483 -397
View File
File diff suppressed because it is too large Load Diff