Compare commits

..

1 Commits

Author SHA1 Message Date
Tom Moor be209157f9 fix: Line numbers flash in on load
fix: Text color of plain text and markdown code blocks
2025-04-12 21:10:29 -04:00
147 changed files with 3415 additions and 5874 deletions
-4
View File
@@ -127,10 +127,6 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Linear
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
+1 -1
View File
@@ -145,7 +145,7 @@ jobs:
bundle-size:
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+1
View File
@@ -69,6 +69,7 @@ function CollectionDescription({ collection }: Props) {
readOnly={!can.update}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
<div ref={childRef} />
</React.Suspense>
+11 -44
View File
@@ -18,13 +18,6 @@ type Props = {
children?: React.ReactNode;
document: Document;
onlyText?: boolean;
reverse?: boolean;
/**
* Maximum number of items to show in the breadcrumb.
* If value is less than or equals to 0, no items will be shown.
* If value is undefined, all items will be shown.
*/
maxDepth?: number;
};
function useCategory(document: Document): MenuInternalLink | null {
@@ -61,7 +54,7 @@ function useCategory(document: Document): MenuInternalLink | null {
}
function DocumentBreadcrumb(
{ document, children, onlyText, reverse = false, maxDepth }: Props,
{ document, children, onlyText }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { collections } = useStores();
@@ -72,7 +65,6 @@ function DocumentBreadcrumb(
? collections.get(document.collectionId)
: undefined;
const can = usePolicy(collection);
const depth = maxDepth === undefined ? undefined : Math.max(0, maxDepth);
React.useEffect(() => {
void document.loadRelations({ withoutPolicies: true });
@@ -99,23 +91,20 @@ function DocumentBreadcrumb(
};
}
const path = document.pathTo.slice(0, -1);
const path = document.pathTo;
const items = React.useMemo(() => {
const output: MenuInternalLink[] = [];
if (depth === 0) {
return output;
}
const output = [];
if (category) {
output.push(category);
}
if (collectionNode) {
output.push(collectionNode);
}
path.forEach((node: NavigationNode) => {
path.slice(0, -1).forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({
type: "route",
@@ -132,43 +121,21 @@ function DocumentBreadcrumb(
},
});
});
return reverse
? depth !== undefined
? output.slice(-depth)
: output
: depth !== undefined
? output.slice(0, depth)
: output;
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
return output;
}, [t, path, category, sidebarContext, collectionNode]);
if (!collections.isLoaded) {
return null;
}
if (onlyText) {
if (depth === 0) {
return <></>;
}
const slicedPath = reverse
? path.slice(depth && -depth)
: path.slice(0, depth);
const showCollection =
collection &&
(!reverse || depth === undefined || slicedPath.length < depth);
if (onlyText === true) {
return (
<>
{showCollection && collection.name}
{slicedPath.map((node: NavigationNode, index: number) => (
{collection?.name}
{path.slice(0, -1).map((node: NavigationNode) => (
<React.Fragment key={node.id}>
{showCollection && <SmallSlash />}
<SmallSlash />
{node.title || t("Untitled")}
{!showCollection && index !== slicedPath.length - 1 && (
<SmallSlash />
)}
</React.Fragment>
))}
</>
+2 -2
View File
@@ -46,10 +46,10 @@ function DocumentViews({ document, isOpen }: Props) {
return (
<>
{isOpen && (
<PaginatedList<User>
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(model) => {
renderItem={(model: User) => {
const view = documentViews.find((v) => v.userId === model.id);
const isPresent = presentIds.includes(model.id);
const isEditing = editingIds.includes(model.id);
+2 -2
View File
@@ -56,7 +56,7 @@ const FilterOptions = ({
: "";
const renderItem = React.useCallback(
(option) => (
(option: TFilterOption) => (
<MenuItem
key={option.key}
onClick={() => {
@@ -174,7 +174,7 @@ const FilterOptions = ({
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList<TFilterOption>
<PaginatedList
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
+7 -18
View File
@@ -2,6 +2,7 @@ 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;
@@ -32,7 +33,7 @@ export const Title = styled(Text).attrs({ as: "h2", size: "large" })`
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 6px;
gap: 4px;
`;
export const Info = styled(StyledText).attrs(() => ({
@@ -59,27 +60,15 @@ export const Thumbnail = styled.img`
export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
color?: string;
}>`
border: 1px solid ${(props) => props.theme.divider};
background-color: ${(props) =>
props.color ?? props.theme.backgroundSecondary};
color: ${(props) =>
props.color ? getTextColor(props.color) : props.theme.text};
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
padding: 0 8px;
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,15 +1,9 @@
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,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -29,11 +23,6 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -42,18 +31,13 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
<CardContent>
<Flex gap={2} column>
<Title>
<StyledIssueStatusIcon
service={service}
state={state}
size={18}
/>
<IssueStatusIcon status={state.name} color={state.color} />
<span>
<Backticks content={title} />
&nbsp;<Text type="tertiary">{id}</Text>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={6}>
<Avatar src={author.avatarUrl} size={18} />
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} created{" "}
@@ -78,8 +62,4 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
);
});
const StyledIssueStatusIcon = styled(IssueStatusIcon)`
margin-top: 2px;
`;
export default HoverPreviewIssue;
@@ -1,11 +1,9 @@
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";
import Flex from "~/components/Flex";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -33,14 +31,13 @@ const HoverPreviewPullRequest = React.forwardRef(
<CardContent>
<Flex gap={2} column>
<Title>
<StyledPullRequestIcon size={18} state={state} />
<PullRequestIcon status={state.name} color={state.color} />
<span>
<Backticks content={title} />
&nbsp;<Text type="tertiary">{id}</Text>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={6}>
<Avatar src={author.avatarUrl} size={18} />
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} opened{" "}
@@ -58,8 +55,4 @@ const HoverPreviewPullRequest = React.forwardRef(
}
);
const StyledPullRequestIcon = styled(PullRequestIcon)`
margin-top: 2px;
`;
export default HoverPreviewPullRequest;
+74
View File
@@ -0,0 +1,74 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
*/
export function IssueStatusIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
</svg>
);
case "canceled":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
</svg>
);
default:
return null;
}
}
+72
View File
@@ -0,0 +1,72 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub pull requests, but can be used for any git-style integration.
*/
export function PullRequestIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
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>
);
case "merged":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<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>
);
default:
return null;
}
}
-3
View File
@@ -114,8 +114,6 @@ const Modal: React.FC<Props> = ({
<Small {...props}>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
style={{ maxHeight: "65vh" }}
column
reverse
>
@@ -261,7 +259,6 @@ const Small = styled.div`
width: 75vw;
min-width: 350px;
max-width: 450px;
max-height: 65vh;
z-index: ${depths.modal};
display: flex;
justify-content: center;
@@ -79,11 +79,11 @@ function Notifications(
</Header>
<React.Suspense fallback={null}>
<Scrollable ref={ref} flex topShadow>
<PaginatedList<Notification>
<PaginatedList
fetch={notifications.fetchPage}
options={{ archived: false }}
items={isOpen ? notifications.orderedData : undefined}
renderItem={(item) => (
renderItem={(item: Notification) => (
<NotificationListItem
key={item.id}
notification={item}
+3 -3
View File
@@ -10,7 +10,7 @@ type Props = {
fetch: (options: any) => Promise<Document[] | undefined>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
empty?: React.ReactNode;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
@@ -34,7 +34,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
const { t } = useTranslation();
return (
<PaginatedList<Document>
<PaginatedList
aria-label={t("Documents")}
items={documents}
empty={empty}
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item, _index) => (
renderItem={(item: Document, _index) => (
<DocumentListItem
key={item.id}
document={item}
+1 -1
View File
@@ -10,7 +10,7 @@ type Props = {
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
empty?: React.ReactNode;
};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
+15 -23
View File
@@ -1,15 +1,13 @@
import "../stores";
import { render } from "@testing-library/react";
import { TFunction } from "i18next";
import { Provider } from "mobx-react";
import * as React from "react";
import { getI18n } from "react-i18next";
import { Pagination } from "@shared/constants";
import PaginatedList from "./PaginatedList";
import { Component as PaginatedList } from "./PaginatedList";
describe("PaginatedList", () => {
const i18n = getI18n();
const authStore = {};
const props = {
i18n,
@@ -19,23 +17,19 @@ describe("PaginatedList", () => {
it("with no items renders nothing", () => {
const result = render(
<Provider auth={authStore}>
<PaginatedList items={[]} renderItem={render} {...props} />
</Provider>
<PaginatedList items={[]} renderItem={render} {...props} />
);
expect(result.container.innerHTML).toEqual("");
});
it("with no items renders empty prop", async () => {
const result = render(
<Provider auth={authStore}>
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>{" "}
</Provider>
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>
);
await expect(
result.findAllByText("Sorry, no results")
@@ -48,15 +42,13 @@ describe("PaginatedList", () => {
id: "one",
};
render(
<Provider auth={authStore}>
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>{" "}
</Provider>
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>
);
expect(fetch).toHaveBeenCalledWith({
...options,
+194 -244
View File
@@ -1,315 +1,265 @@
import isEqual from "lodash/isEqual";
import { observable, action, computed } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePrevious from "~/hooks/usePrevious";
import withStores from "~/components/withStores";
import { dateToHeading } from "~/utils/date";
/**
* Base interface for items that can be paginated
* @interface PaginatedItem
*/
export interface PaginatedItem {
/** Unique identifier for the item */
id?: string;
/** Last update timestamp of the item */
updatedAt?: string;
/** Creation timestamp of the item */
createdAt?: string;
}
/**
* Props for the PaginatedList component
* @template T Type of items in the list, must extend PaginatedItem
*/
interface Props<T extends PaginatedItem>
extends React.HTMLAttributes<HTMLDivElement> {
/**
* Function to fetch paginated data. Should return a promise resolving to an array of items
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
type Props<T> = WithTranslation &
RootStore &
React.HTMLAttributes<HTMLDivElement> & {
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
className?: string;
renderItem: (item: T, index: number) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
listRef?: React.RefObject<HTMLDivElement>;
};
/** Additional options to pass to the fetch function */
options?: Record<string, any>;
@observer
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
Props<T>
> {
@observable
error?: Error;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@observable
isFetchingMore = false;
/** Content to display when the list is empty */
empty?: JSX.Element | null;
@observable
isFetching = false;
/** Optional loading state content */
loading?: JSX.Element | null;
@observable
isFetchingInitial = !this.props.items?.length;
/** Array of items to display in the list */
items?: T[];
@observable
fetchCounter = 0;
/** CSS class name to apply to the list container */
className?: string;
@observable
renderCount = Pagination.defaultLimit;
/**
* Function to render each individual item in the list
* @param item The item to render
* @param index The index of the item in the list
*/
renderItem: (item: T, index: number) => React.ReactNode;
@observable
offset = 0;
/**
* Function to render error state
* @param options Object containing error details and retry function
*/
renderError?: (options: {
/** Details of the error */
error: Error;
/** Function to retry the fetch operation */
retry: () => void;
}) => JSX.Element;
@observable
allowLoadMore = true;
/**
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
componentDidMount() {
void this.fetchResults();
}
/**
* Handler for escape key press
* @param ev Keyboard event object
*/
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
componentDidUpdate(prevProps: Props<T>) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
) {
this.reset();
void this.fetchResults();
}
}
/** Reference to the list container element */
listRef?: React.RefObject<HTMLDivElement>;
}
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
this.renderCount = Pagination.defaultLimit;
this.isFetching = false;
this.isFetchingInitial = false;
this.isFetchingMore = false;
};
/**
* A reusable component that renders a paginated list with infinite scrolling
* and optional date-based section headings.
*
* @template T Type of the list items, must extend PaginatedItem
*/
const PaginatedList = <T extends PaginatedItem>({
fetch,
options,
heading,
empty = null,
loading = null,
items = [],
className,
renderItem,
renderError,
renderHeading,
onEscape,
listRef,
...rest
}: Props<T>): JSX.Element | null => {
const user = useCurrentUser({ rejectOnEmpty: false });
const { t } = useTranslation();
const [error, setError] = React.useState<Error | undefined>();
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const [isFetchingInitial, setIsFetchingInitial] = React.useState(
!items?.length
);
const [fetchCounter, setFetchCounter] = React.useState(0);
const [renderCount, setRenderCount] = React.useState(Pagination.defaultLimit);
const [offset, setOffset] = React.useState(0);
const [allowLoadMore, setAllowLoadMore] = React.useState(true);
const reset = React.useCallback(() => {
setOffset(0);
setAllowLoadMore(true);
setRenderCount(Pagination.defaultLimit);
setIsFetching(false);
setIsFetchingInitial(false);
setIsFetchingMore(false);
}, []);
const fetchResults = React.useCallback(async () => {
if (!fetch) {
@action
fetchResults = async () => {
if (!this.props.fetch) {
return;
}
setIsFetching(true);
const counter = fetchCounter + 1;
setFetchCounter(counter);
const limit = options?.limit ?? Pagination.defaultLimit;
setError(undefined);
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = this.props.options?.limit ?? Pagination.defaultLimit;
this.error = undefined;
try {
const results = await fetch({
const results = await this.props.fetch({
limit,
offset,
...options,
offset: this.offset,
...this.props.options,
});
if (offset !== 0) {
setRenderCount((prevCount) => prevCount + limit);
if (this.offset !== 0) {
this.renderCount += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
setAllowLoadMore(false);
this.allowLoadMore = false;
} else {
setOffset((prevOffset) => prevOffset + limit);
this.offset += limit;
}
setIsFetchingInitial(false);
this.isFetchingInitial = false;
} catch (err) {
setError(err);
this.error = err;
} finally {
// only the most recent fetch should end the loading state
if (counter >= fetchCounter) {
setIsFetching(false);
setIsFetchingMore(false);
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
}
}, [fetch, fetchCounter, offset, options]);
};
const loadMoreResults = React.useCallback(async () => {
// Don't paginate if there aren't more results or we're currently fetching
if (!allowLoadMore || isFetching) {
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were currently fetching
if (!this.allowLoadMore || this.isFetching) {
return;
}
// If there are already cached results that we haven't yet rendered because
// of lazy rendering then show another page.
const leftToRender = (items?.length ?? 0) - renderCount;
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
if (leftToRender > 0) {
setRenderCount((prevCount) => prevCount + Pagination.defaultLimit);
this.renderCount += Pagination.defaultLimit;
}
// If there are less than a pages results in the cache go ahead and fetch
// another page from the server
if (leftToRender <= Pagination.defaultLimit) {
setIsFetchingMore(true);
await fetchResults();
this.isFetchingMore = true;
await this.fetchResults();
}
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
};
const prevFetch = usePrevious(fetch);
const prevOptions = usePrevious(options);
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
// Initial fetch on mount
React.useEffect(() => {
if (fetch) {
void fetchResults();
}
}, [fetch]);
render() {
const {
items = [],
heading,
auth,
empty = null,
renderHeading,
renderError,
onEscape,
} = this.props;
// Handle updates to fetch or options
React.useEffect(() => {
if (!prevFetch || !prevOptions) {
return; // Skip on initial mount since it's handled by the above effect
const showLoading =
this.isFetching &&
!this.isFetchingMore &&
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
if (showLoading) {
return (
this.props.loading || (
<DelayedMount>
<div className={this.props.className}>
<PlaceholderList count={5} />
</div>
</DelayedMount>
)
);
}
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
reset();
void fetchResults();
if (items?.length === 0) {
if (this.error && renderError) {
return renderError({ error: this.error, retry: this.fetchResults });
}
return empty;
}
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
// Computed property equivalent
const itemsToRender = React.useMemo(
() => items?.slice(0, renderCount) ?? [],
[items, renderCount]
);
const showLoading =
isFetching &&
!isFetchingMore &&
(!items?.length || (fetchCounter <= 1 && isFetchingInitial));
if (showLoading) {
return (
loading || (
<DelayedMount>
<div className={className}>
<PlaceholderList count={5} />
<>
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
items={this.itemsToRender}
ref={this.props.listRef}
>
{() => {
let previousHeading = "";
return this.itemsToRender.map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (
children &&
(!previousHeading || currentHeading !== previousHeading)
) {
previousHeading = currentHeading;
return (
<React.Fragment
key={"id" in item && item.id ? item.id : index}
>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
});
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
</div>
</DelayedMount>
)
)}
</>
);
}
}
if (items?.length === 0) {
if (error && renderError) {
return renderError({ error, retry: fetchResults });
}
export const Component = PaginatedList;
return empty;
}
return (
<React.Fragment>
{heading}
<ArrowKeyNavigation
aria-label={rest["aria-label"]}
onEscape={onEscape}
className={className}
items={itemsToRender}
ref={listRef}
>
{() => {
let previousHeading = "";
return itemsToRender.map((item, index) => {
const children = renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
t,
user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (
children &&
(!previousHeading || currentHeading !== previousHeading)
) {
previousHeading = currentHeading;
return (
<React.Fragment key={"id" in item && item.id ? item.id : index}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
});
}}
</ArrowKeyNavigation>
{allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={renderCount} onEnter={loadMoreResults} />
</div>
)}
</React.Fragment>
);
};
export default PaginatedList;
export default withTranslation()(withStores(PaginatedList));
+2 -2
View File
@@ -200,7 +200,7 @@ function SearchPopover({ shareId, className }: Props) {
style={{ zIndex: depths.sidebar + 1 }}
shrink
>
<PaginatedList<SearchResult>
<PaginatedList
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
@@ -209,7 +209,7 @@ function SearchPopover({ shareId, className }: Props) {
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item, index) => (
renderItem={(item: SearchResult, index) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
@@ -82,12 +82,12 @@ function ArchiveLink() {
</div>
{expanded === true ? (
<Relative>
<PaginatedList<Collection>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item) => (
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
@@ -54,7 +54,7 @@ function Collections() {
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList<Collection>
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.allActive}
@@ -69,7 +69,7 @@ function Collections() {
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item, index) => (
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
@@ -148,12 +148,7 @@ function InnerDocumentLink(
const color = document?.color || node.color;
// Draggable
const [{ isDragging }, drag] = useDragDocument(
node,
depth,
document,
isEditing
);
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
// Drop to re-parent
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -275,8 +270,6 @@ function InnerDocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
@@ -292,7 +285,6 @@ function InnerDocumentLink(
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
+6 -12
View File
@@ -39,7 +39,6 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
location?: Location;
strict?: boolean;
to: LocationDescriptor;
component?: React.ComponentType;
onBeforeClick?: () => void;
}
@@ -147,22 +146,17 @@ const NavLink = ({
setPreActive(undefined);
}, [currentLocation]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
},
[navigateTo]
);
return (
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
onClick={handleClick}
onKeyDown={handleKeyDown}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -166,13 +166,11 @@ export function useDropToReorderStar(getIndex?: () => string) {
* @param node The NavigationNode model to drag.
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
* @param isEditing Whether the sidebar item is currently being edited.
*/
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document,
isEditing?: boolean
document?: Document
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
@@ -190,7 +188,7 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
canDrag: () => !!document?.isActive && !isEditing,
canDrag: () => !!document?.isActive,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
+1 -3
View File
@@ -335,7 +335,6 @@ const TR = styled.div<{ $columns: string }>`
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
overflow: hidden;
&:last-child {
border-bottom: 0;
@@ -358,8 +357,7 @@ const TD = styled.span`
padding: 10px 6px;
font-size: 14px;
text-wrap: wrap;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
&:first-child {
font-size: 15px;
+71 -1
View File
@@ -1,3 +1,73 @@
import Text from "@shared/components/Text";
import styled, { css } from "styled-components";
import { ellipsis } from "@shared/styles";
type Props = {
/** The type of text to render */
type?: "secondary" | "tertiary" | "danger";
/** The size of the text */
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
/** The direction of the text (defaults to ltr) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
* Use this component for all interface text that should not be selectable
* by the user, this is the majority of UI text explainers, notes, headings.
*/
const Text = styled.span<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
: props.type === "tertiary"
? props.theme.textTertiary
: props.type === "danger"
? props.theme.brand.red
: props.theme.text};
font-size: ${(props) =>
props.size === "xlarge"
? "26px"
: props.size === "large"
? "18px"
: props.size === "medium"
? "16px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
${(props) => props.ellipsis && ellipsis()}
`;
export default Text;
+1 -9
View File
@@ -10,7 +10,6 @@ import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
@@ -254,14 +253,7 @@ const LinkEditor: React.FC<Props> = ({
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
key={doc.id}
subtitle={
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
}
subtitle={doc.collection?.name}
title={doc.title}
icon={
doc.icon ? (
+3 -11
View File
@@ -11,7 +11,6 @@ import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import {
DocumentsSection,
@@ -58,7 +57,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users, collections])
}, [search, documents, users])
);
React.useEffect(() => {
@@ -69,7 +68,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
React.useEffect(() => {
if (actorId && !loading) {
const items: MentionItem[] = users
const items = users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
@@ -113,14 +112,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
<DocumentIcon />
),
title: doc.title,
subtitle: (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
),
subtitle: doc.collection?.name,
section: DocumentsSection,
appendSpace: true,
attrs: {
+24 -59
View File
@@ -1,15 +1,7 @@
import { observer } from "mobx-react";
import { EmailIcon, LinkIcon } from "outline-icons";
import { LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -23,65 +15,34 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const PasteMenu = ({ embeds, ...props }: Props) => {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (url) {
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
}
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(pastedText);
const matches = e.matcher(props.pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, pastedText]);
}, [embeds, props.pastedText]);
const items = React.useMemo(
() =>
[
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "mention",
title: t("Mention"),
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: v4(),
actorId: user?.id,
},
appendSpace: true,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
] satisfies MenuItem[],
[t, embed, mentionType, pastedText, user]
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
);
return (
@@ -91,7 +52,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
onClick={() => {
props.onSelect?.(item);
}}
selected={options.selected}
title={item.title}
icon={item.icon}
@@ -100,4 +63,6 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
items={items}
/>
);
});
};
export default PasteMenu;
+1 -1
View File
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
if ((readOnly && !canComment) || isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
+1 -21
View File
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import { PasteMenu } from "../components/PasteMenu";
import PasteMenu from "../components/PasteMenu";
export default class PasteHandler extends Extension {
state: {
@@ -415,21 +415,6 @@ export default class PasteHandler extends Extension {
});
};
private insertMention = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
// Remove just the placeholder here.
// Mention node will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
@@ -465,11 +450,6 @@ export default class PasteHandler extends Extension {
this.insertEmbed();
break;
}
case "mention": {
this.hidePasteMenu();
this.insertMention();
break;
}
default:
break;
}
+18 -2
View File
@@ -1,3 +1,19 @@
import useIsMounted from "@shared/hooks/useIsMounted";
import * as React from "react";
export default useIsMounted;
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export default function useIsMounted() {
const isMounted = React.useRef(false);
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return React.useCallback(() => isMounted.current, []);
}
+10
View File
@@ -8,6 +8,7 @@ import {
GlobeIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
SettingsIcon,
ExportIcon,
ImportIcon,
@@ -39,6 +40,7 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
@@ -175,6 +177,14 @@ const useSettingsConfig = () => {
icon: ExportIcon,
},
// Integrations
{
name: t("Self Hosted"),
path: integrationSettingsPath("self-hosted"),
component: SelfHosted,
enabled: can.update && !isCloudHosted,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
+3 -3
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import {
import type {
IntegrationService,
type IntegrationSettings,
type IntegrationType,
IntegrationSettings,
IntegrationType,
} from "@shared/types";
import User from "~/models/User";
import Model from "~/models/base/Model";
+2 -2
View File
@@ -144,10 +144,10 @@ function Insights() {
small
/>
)}
<PaginatedList<User>
<PaginatedList
aria-label={t("Contributors")}
items={document.collaborators}
renderItem={(model) => (
renderItem={(model: User) => (
<ListItem
key={model.id}
title={model.name}
-2
View File
@@ -209,9 +209,7 @@ function Invite({ onSubmit }: Props) {
placeholder={`name@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoComplete="off"
autoFocus
data-1p-ignore
flex
/>
<StyledInput
+1 -4
View File
@@ -39,10 +39,7 @@ export function LoginDialog() {
maxLength={255}
autoComplete="off"
placeholder={t("subdomain")}
{...register("subdomain", {
required: true,
pattern: /^[a-z\d-]{1,63}$/,
})}
{...register("subdomain", { required: true, pattern: /^[a-z\d-]+$/ })}
>
<Domain>.getoutline.com</Domain>
</Input>
+1 -10
View File
@@ -3,15 +3,6 @@ import { parseDomain } from "@shared/utils/domains";
import env from "~/env";
import Desktop from "~/utils/Desktop";
function validateAndEncodeSubdomain(subdomain: string): string {
const encodedSubdomain = encodeURIComponent(subdomain);
const urlPattern = /^[a-z\d-]{1,63}$/;
if (!urlPattern.test(encodedSubdomain)) {
throw new Error("Invalid subdomain");
}
return `https://${encodedSubdomain}.getoutline.com`;
}
/**
* If we're on a custom domain or a subdomain then the auth must point to the
* apex (env.URL) for authentication so that the state cookie can be set and read.
@@ -45,7 +36,7 @@ export async function navigateToSubdomain(subdomain: string) {
.toLowerCase()
.trim()
.replace(/^https?:\/\//, "");
const host = validateAndEncodeSubdomain(normalizedSubdomain);
const host = `https://${normalizedSubdomain}.getoutline.com`;
await Desktop.bridge?.addCustomHost(host);
window.location.href = host;
}
+2 -2
View File
@@ -58,11 +58,11 @@ function ApiKeys() {
}}
/>
</Text>
<PaginatedList<ApiKey>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<h2>{t("All")}</h2>}
renderItem={(apiKey) => (
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
+2 -2
View File
@@ -48,7 +48,7 @@ function Export() {
{t("Export data")}
</Button>
<br />
<PaginatedList<FileOperation>
<PaginatedList
items={fileOperations.exports}
fetch={fileOperations.fetchPage}
options={{
@@ -59,7 +59,7 @@ function Export() {
<Trans>Recent exports</Trans>
</h2>
}
renderItem={(item) => (
renderItem={(item: FileOperation) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>
+2 -2
View File
@@ -183,7 +183,7 @@ function Import() {
))}
</div>
<br />
<PaginatedList<ImportModel | FileOperation>
<PaginatedList
items={allImports}
fetch={fetchImports}
heading={
@@ -191,7 +191,7 @@ function Import() {
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item) =>
renderItem={(item: ImportModel | FileOperation) =>
item instanceof ImportModel ? (
<ImportListItem key={item.id} importModel={item} />
) : (
+2 -2
View File
@@ -61,12 +61,12 @@ function PersonalApiKeys() {
}}
/>
</Text>
<PaginatedList<ApiKey>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey) => (
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
+140
View File
@@ -0,0 +1,140 @@
import find from "lodash/find";
import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IntegrationService, IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import useStores from "~/hooks/useStores";
import SettingRow from "./components/SettingRow";
type FormData = {
drawIoUrl: string;
gristUrl: string;
};
function SelfHosted() {
const { integrations } = useStores();
const { t } = useTranslation();
const integrationDiagrams = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined;
const integrationGrist = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Grist,
}) as Integration<IntegrationType.Embed> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Embed,
});
}, [integrations]);
React.useEffect(() => {
reset({
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
});
}, [integrationDiagrams, integrationGrist, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.drawIoUrl) {
await integrations.save({
id: integrationDiagrams?.id,
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
settings: {
url: data.drawIoUrl,
},
});
} else {
await integrationDiagrams?.delete();
}
if (data.gristUrl) {
await integrations.save({
id: integrationGrist?.id,
type: IntegrationType.Embed,
service: IntegrationService.Grist,
settings: {
url: data.gristUrl,
},
});
} else {
await integrationGrist?.delete();
}
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[integrations, integrationDiagrams, integrationGrist, t]
);
return (
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
<Heading>{t("Self Hosted")}</Heading>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Draw.io deployment")}
name="drawIoUrl"
description={t(
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
)}
border={false}
>
<Input
placeholder="https://app.diagrams.net/"
pattern="https?://.*"
{...register("drawIoUrl")}
/>
</SettingRow>
<SettingRow
label={t("Grist deployment")}
name="gristUrl"
description={t("Add your self-hosted grist installation URL here.")}
border={false}
>
<Input
placeholder="https://docs.getgrist.com/"
pattern="https?://.*"
{...register("gristUrl")}
/>
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(SelfHosted);
@@ -4,7 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import {
FileOperationFormat,
FileOperationState,
@@ -14,6 +13,7 @@ import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -268,14 +268,14 @@ export const ViewGroupMembersDialog = observer(function ({
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList<User>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(user) => (
renderItem={(user: User) => (
<GroupMemberListItem
key={user.id}
user={user}
@@ -382,7 +382,7 @@ const AddPeopleToGroupDialog = observer(function ({
<PlaceholderList count={5} />
</DelayedMount>
) : (
<PaginatedList<User>
<PaginatedList
empty={
query ? (
<Empty>{t("No people matching your search")}</Empty>
@@ -392,7 +392,7 @@ const AddPeopleToGroupDialog = observer(function ({
}
items={users.notInGroup(group.id, query)}
fetch={query ? undefined : users.fetchPage}
renderItem={(item) => (
renderItem={(item: User) => (
<GroupMemberListItem
key={item.id}
user={item}
+79 -117
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useState, useRef } from "react";
import AvatarEditor from "react-avatar-editor";
import { useDropzone } from "react-dropzone";
import Dropzone from "react-dropzone";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
@@ -12,6 +12,7 @@ import { AttachmentValidation } from "@shared/validations";
import ButtonLarge from "~/components/ButtonLarge";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import useStores from "~/hooks/useStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
@@ -27,138 +28,81 @@ const ImageUpload: React.FC<Props> = ({
onSuccess,
onError,
submitText,
borderRadius,
borderRadius = 150,
children,
}) => {
const { dialogs } = useStores();
const { ui } = useStores();
const { t } = useTranslation();
submitText || t("Crop image");
const [isUploading, setIsUploading] = useState(false);
const [isCropping, setIsCropping] = useState(false);
const [zoom, setZoom] = useState(1);
const [file, setFile] = useState<File | null>(null);
const uploadImage = React.useCallback(
async (blob: Blob, file: File) => {
try {
const compressed = await compressImage(blob, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Avatar,
});
void onSuccess(attachment.url);
} catch (err) {
onError(err.message);
} finally {
setIsUploading(false);
setIsCropping(false);
dialogs.closeAllModals();
}
},
[dialogs, onSuccess, onError]
);
const avatarEditorRef = useRef<AvatarEditor>(null);
const handleUpload = React.useCallback(
(blob: Blob, file: File) => {
setIsUploading(true);
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(() => uploadImage(blob, file), 0);
},
[uploadImage]
);
const onDropAccepted = async (files: File[]) => {
setIsCropping(true);
setFile(files[0]);
};
const handleClose = React.useCallback(() => {
const handleCrop = () => {
setIsUploading(true);
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(uploadImage, 0);
};
const uploadImage = async () => {
const canvas = avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const imageBlob = dataUrlToBlob(canvas.toDataURL());
try {
const compressed = await compressImage(imageBlob, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: file!.name,
preset: AttachmentPreset.Avatar,
});
void onSuccess(attachment.url);
} catch (err) {
onError(err.message);
} finally {
setIsUploading(false);
setIsCropping(false);
}
};
const handleClose = () => {
setIsUploading(false);
setIsCropping(false);
}, []);
};
const onDropAccepted = React.useCallback(
async (files: File[]) => {
setIsCropping(true);
dialogs.openModal({
title: "",
content: (
<AvatarEditorDialog
file={files[0]}
onUpload={handleUpload}
isUploading={isUploading}
borderRadius={borderRadius ?? 150}
submitText={submitText || t("Crop image")}
/>
),
onClose: handleClose,
});
},
[
t,
dialogs,
handleUpload,
handleClose,
isUploading,
borderRadius,
submitText,
]
);
const handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
const { getRootProps, getInputProps } = useDropzone({
accept: AttachmentValidation.avatarContentTypes.join(", "),
onDropAccepted,
});
if (target instanceof HTMLInputElement) {
setZoom(parseFloat(target.value));
}
};
if (isCropping) {
return null; // onDropAccepted would have opened a modal for cropping the image.
}
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
{children}
</div>
);
};
type AvatarEditorDialogProps = {
file: File;
onUpload: (blob: Blob, file: File) => void;
isUploading: boolean;
borderRadius: number;
submitText: string;
};
const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
({ file, onUpload, isUploading, borderRadius, submitText }) => {
const { ui } = useStores();
const { t } = useTranslation();
const [zoom, setZoom] = useState(1);
const avatarEditorRef = useRef<AvatarEditor>(null);
const handleUpload = React.useCallback(() => {
const canvas = avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const blob = dataUrlToBlob(canvas.toDataURL());
onUpload(blob, file);
}, [file, onUpload]);
const handleZoom = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
setZoom(parseFloat(target.value));
}
},
[]
);
return (
const renderCropping = () => (
<Modal
onRequestClose={handleClose}
fullscreen={false}
title={<>&nbsp;</>}
isOpen
>
<Flex auto column align="center" justify="center">
{isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={avatarEditorRef}
image={file}
image={file!}
width={250}
height={250}
border={25}
@@ -177,13 +121,31 @@ const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
onChange={handleZoom}
/>
<br />
<ButtonLarge fullwidth onClick={handleUpload} disabled={isUploading}>
<ButtonLarge fullwidth onClick={handleCrop} disabled={isUploading}>
{isUploading ? `${t(`Uploading`)}` : submitText}
</ButtonLarge>
</Flex>
);
</Modal>
);
if (isCropping && file) {
return renderCropping();
}
);
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{children}
</div>
)}
</Dropzone>
);
};
const AvatarEditorContainer = styled(Flex)`
margin-bottom: 30px;
@@ -5,12 +5,12 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import { ImportState } from "@shared/types";
import Import from "~/models/Import";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
+16 -28
View File
@@ -2,7 +2,6 @@ import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "@shared/components/Text";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -15,7 +14,6 @@ import {
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import UserMenu from "~/menus/UserMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
@@ -29,7 +27,6 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
export function MembersTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isMobile = useMobile();
const columns = React.useMemo<TableColumn<User>[]>(
() =>
@@ -41,20 +38,13 @@ export function MembersTable({ canManage, ...rest }: Props) {
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Large} />{" "}
<Flex column>
<Text>
{user.name} {currentUser.id === user.id && `(${t("You")})`}
</Text>
{isMobile && canManage && (
<Text type="tertiary">{user.email}</Text>
)}
</Flex>
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
</Flex>
),
width: "4fr",
},
canManage && !isMobile
canManage
? {
type: "data",
id: "email",
@@ -64,19 +54,17 @@ export function MembersTable({ canManage, ...rest }: Props) {
width: "4fr",
}
: undefined,
isMobile
? undefined
: {
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "role",
@@ -97,7 +85,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
),
width: "80px",
width: "2fr",
},
canManage
? {
@@ -109,7 +97,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
}
: undefined,
]),
[t, currentUser, canManage, isMobile]
[t, currentUser, canManage]
);
return (
-7
View File
@@ -21,13 +21,6 @@ class IntegrationsStore extends Store<Integration> {
(integration) => integration.service === IntegrationService.GitHub
);
}
@computed
get linear(): Integration<IntegrationType.Embed>[] {
return this.orderedData.filter(
(integration) => integration.service === IntegrationService.Linear
);
}
}
export default IntegrationsStore;
+1 -8
View File
@@ -99,14 +99,7 @@ export default abstract class Store<T extends Model> {
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
if (!normalized) {
return this.orderedData
.filter((item) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
return this.orderedData.slice(0, options?.maxResults);
}
return this.orderedData
-73
View File
@@ -1,73 +0,0 @@
import {
IntegrationService,
IntegrationSettings,
IntegrationType,
MentionType,
} from "@shared/types";
import Integration from "~/models/Integration";
export const isURLMentionable = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): boolean => {
const { hostname, pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "github.com" &&
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
);
}
case IntegrationService.Linear: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "linear.app" &&
settings.linear?.workspace.key === pathParts[1] // ensure installed workspace key matches with the provided url.
);
}
default:
return false;
}
};
export const determineMentionType = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): MentionType | undefined => {
const { pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const type = pathParts[3];
return type === "pull"
? MentionType.PullRequest
: type === "issues"
? MentionType.Issue
: undefined;
}
case IntegrationService.Linear: {
const type = pathParts[2];
return type === "issue" ? MentionType.Issue : undefined;
}
default:
return;
}
};
+19 -20
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.782.0",
"@aws-sdk/lib-storage": "3.782.0",
"@aws-sdk/s3-presigned-post": "3.782.0",
"@aws-sdk/s3-request-presigner": "3.782.0",
"@aws-sdk/signature-v4-crt": "^3.782.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
@@ -79,13 +79,12 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^39.0.0",
"@notionhq/client": "^2.3.0",
"@notionhq/client": "^2.2.16",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-visually-hidden": "^1.2.0",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
@@ -115,7 +114,7 @@
"date-fns": "^3.6.0",
"dd-trace": "^5.40.0",
"diff": "^5.2.0",
"dotenv": "^16.5.0",
"dotenv": "^16.4.7",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
@@ -180,7 +179,7 @@
"png-chunks-extract": "^1.0.0",
"polished": "^4.3.1",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.7.1",
"prosemirror-commands": "^1.7.0",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
@@ -188,11 +187,11 @@
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.0",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.39.1",
"prosemirror-view": "^1.38.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
@@ -211,7 +210,7 @@
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-virtualized-auto-sizer": "^1.0.21",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"reakit": "^1.3.11",
@@ -220,7 +219,7 @@
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
"resolve-path": "^1.4.0",
"rfc6902": "^5.1.2",
"rfc6902": "^5.1.1",
"sanitize-filename": "^1.6.3",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.7.1",
@@ -249,8 +248,8 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^6.3.3",
"vite-plugin-pwa": "^0.21.2",
"vite": "^5.4.18",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -361,9 +360,9 @@
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.5",
"rollup-plugin-webpack-stats": "^2.0.3",
"terser": "^5.39.0",
"typescript": "^5.8.3",
"typescript": "^5.7.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -375,8 +374,8 @@
"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",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"version": "0.83.0"
}
@@ -1,40 +0,0 @@
import { Endpoints } from "@octokit/types";
import { IssueSource } from "@shared/schema";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
import { GitHub } from "./github";
// This is needed to handle Octokit paginate response type mismatch.
type ReposForInstallation =
Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
export class GitHubIssueProvider extends BaseIssueProvider {
constructor() {
super(IntegrationService.GitHub);
}
async fetchSources(
integration: Integration<IntegrationType.Embed>
): Promise<IssueSource[]> {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const sources: IssueSource[] = [];
for await (const response of client.requestRepos()) {
const repos = response.data as unknown as ReposForInstallation;
sources.push(
...repos.map<IssueSource>((repo) => ({
id: String(repo.id),
name: repo.name,
owner: { id: String(repo.owner.id), name: repo.owner.login },
service: IntegrationService.GitHub,
}))
);
}
return sources;
}
}
+53 -30
View File
@@ -1,12 +1,12 @@
import Router from "koa-router";
import find from "lodash/find";
import { IntegrationService, IntegrationType } from "@shared/types";
import { createContext } from "@server/context";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration } from "@server/models";
import { IntegrationAuthentication, Integration, Team } from "@server/models";
import { APIContext } from "@server/types";
import { GitHubUtils } from "../../shared/GitHubUtils";
import { GitHub } from "../github";
@@ -16,17 +16,10 @@ const router = new Router();
router.get(
"github.callback",
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"),
auth({
optional: true,
}),
validate(T.GitHubCallbackSchema),
transaction(),
async (ctx: APIContext<T.GitHubCallbackReq>) => {
const {
@@ -49,6 +42,33 @@ 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(
@@ -68,27 +88,30 @@ router.get(
},
{ transaction }
);
await Integration.createWithCtx(createContext({ user, transaction }), {
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
await Integration.create(
{
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
},
},
},
},
});
{ transaction }
);
ctx.redirect(GitHubUtils.url);
}
);
+23 -95
View File
@@ -4,55 +4,36 @@ import {
type OAuthWebFlowAuthOptions,
type InstallationAuthOptions,
} from "@octokit/auth-app";
import { Endpoints, OctokitResponse } from "@octokit/types";
import { Octokit } from "octokit";
import pluralize from "pluralize";
import {
IntegrationService,
IntegrationType,
JSONObject,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration, User } from "@server/models";
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
import { GitHubUtils } from "../shared/GitHubUtils";
import { UnfurlSignature } from "@server/types";
import env from "./env";
type PR =
Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
type Issue =
Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"];
const requestPlugin = (octokit: Octokit) => ({
requestRepos: () =>
octokit.paginate.iterator(
octokit.rest.apps.listReposAccessibleToInstallation,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
),
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
owner: params.owner,
repo: params.repo,
pull_number: params.id,
requestPR: async (params: ReturnType<typeof GitHub.parseUrl>) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{id}`, {
owner: params?.owner,
repo: params?.repo,
id: params?.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
requestIssue: async (
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
owner: params.owner,
repo: params.repo,
issue_number: params.id,
requestIssue: async (params: ReturnType<typeof GitHub.parseUrl>) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{id}`, {
owner: params?.owner,
repo: params?.repo,
id: params?.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -75,14 +56,14 @@ const requestPlugin = (octokit: Octokit) => ({
*/
requestResource: async function requestResource(
resource: ReturnType<typeof GitHub.parseUrl>
): Promise<OctokitResponse<Issue | PR> | undefined> {
): Promise<{ data?: JSONObject }> {
switch (resource?.type) {
case UnfurlResourceType.PR:
return this.requestPR(resource) as Promise<OctokitResponse<PR>>;
return this.requestPR(resource);
case UnfurlResourceType.Issue:
return this.requestIssue(resource) as Promise<OctokitResponse<Issue>>;
return this.requestIssue(resource);
default:
return;
return { data: undefined };
}
},
@@ -110,10 +91,7 @@ export class GitHub {
private static appOctokit: Octokit;
private static supportedResources = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
private static supportedResources = Object.values(UnfurlResourceType);
/**
* Parses a given URL and returns resource identifiers for GitHub specific URLs
@@ -133,7 +111,7 @@ export class GitHub {
const type = parts[3]
? (pluralize.singular(parts[3]) as UnfurlResourceType)
: undefined;
const id = Number(parts[4]);
const id = parts[4];
if (!type || !GitHub.supportedResources.includes(type)) {
return;
@@ -226,64 +204,14 @@ export class GitHub {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const res = await client.requestResource(resource);
if (!res) {
return { error: "Resource not found" };
const { data } = await client.requestResource(resource);
if (!data) {
return;
}
return GitHub.transformData(res.data, resource.type);
return { ...data, type: resource.type };
} catch (err) {
Logger.warn("Failed to fetch resource from GitHub", err);
return { error: err.message || "Unknown error" };
return;
}
};
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
if (type === UnfurlResourceType.Issue) {
const issue = data as Issue;
return {
type: UnfurlResourceType.Issue,
url: issue.html_url,
id: `#${issue.number}`,
title: issue.title,
description: issue.body_text ?? null,
author: {
name: issue.user?.login ?? "",
avatarUrl: issue.user?.avatar_url ?? "",
},
labels: issue.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: issue.state,
color: GitHubUtils.getColorForStatus(issue.state),
},
createdAt: issue.created_at,
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
}
const pr = data as PR;
const prState = pr.merged ? "merged" : pr.state;
return {
type: UnfurlResourceType.PR,
url: pr.html_url,
id: `#${pr.number}`,
title: pr.title,
description: pr.body,
author: {
name: pr.user.login,
avatarUrl: pr.user.avatar_url,
},
state: {
name: prState,
color: GitHubUtils.getColorForStatus(prState, !!pr.draft),
draft: pr.draft,
},
createdAt: pr.created_at,
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
}
}
-5
View File
@@ -1,7 +1,6 @@
import { Minute } from "@shared/utils/time";
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import { GitHubIssueProvider } from "./GitHubIssueProvider";
import router from "./api/github";
import env from "./env";
import { GitHub } from "./github";
@@ -21,10 +20,6 @@ if (enabled) {
type: Hook.API,
value: router,
},
{
type: Hook.IssueProvider,
value: new GitHubIssueProvider(),
},
{
type: Hook.UnfurlProvider,
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
+2 -2
View File
@@ -45,10 +45,10 @@ export class GitHubUtils {
return `${this.url}?install_request=true`;
}
public static getColorForStatus(status: string, isDraftPR: boolean = false) {
public static getColorForStatus(status: string) {
switch (status) {
case "open":
return isDraftPR ? "#848d97" : "#238636";
return "#238636";
case "done":
return "#a371f7";
case "closed":
+4 -6
View File
@@ -1,6 +1,6 @@
import { JSONObject, UnfurlResourceType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { UnfurlError, UnfurlSignature } from "@server/types";
import { UnfurlSignature } from "@server/types";
import fetch from "@server/utils/fetch";
import env from "./env";
@@ -10,7 +10,7 @@ class Iframely {
public static async requestResource(
url: string,
type = "oembed"
): Promise<JSONObject | UnfurlError> {
): Promise<JSONObject | undefined> {
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
// Cloud Iframely requires /api path, while self-hosted does not.
@@ -25,7 +25,7 @@ class Iframely {
return await res.json();
} catch (err) {
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
return { error: err.message || "Unknown error" };
return;
}
}
@@ -36,9 +36,7 @@ class Iframely {
*/
public static unfurl: UnfurlSignature = async (url: string) => {
const data = await Iframely.requestResource(url);
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
? ({ error: data.error } as UnfurlError)
: { ...data, type: UnfurlResourceType.OEmbed };
return { ...data, type: UnfurlResourceType.OEmbed };
};
}
-41
View File
@@ -1,41 +0,0 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
d="M3.93091 12.8481C4.11753 14.6298 4.89358 16.3615 6.25902 17.727C7.62446 19.0923 9.35612 19.8684 11.1378 20.0551L3.93091 12.8481Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M3.89929 11.5437L12.4422 20.0865C13.1671 20.0459 13.8876 19.9084 14.5827 19.6738L4.31194 9.4032C4.07743 10.0982 3.93988 10.8187 3.89929 11.5437Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M4.67981 8.49828L15.4875 19.306C16.0482 19.0374 16.5845 18.7005 17.0837 18.2953L5.6905 6.90222C5.28537 7.40142 4.94847 7.93759 4.67981 8.49828Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M6.29602 6.23494C9.46213 3.10852 14.5632 3.12079 17.7141 6.27173C20.865 9.42266 20.8774 14.5237 17.7509 17.6898L6.29602 6.23494Z"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
);
}
-140
View File
@@ -1,140 +0,0 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import LinearIcon from "./Icon";
import { LinearConnectButton } from "./components/LinearButton";
function Linear() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
React.useEffect(() => {
void integrations.fetchAll({
service: IntegrationService.Linear,
withRelations: true,
});
}, [integrations]);
return (
<Scene title="Linear" icon={<LinearIcon />}>
<Heading>Linear</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in Linear to connect{" "}
{{ appName }} to your workspace. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{env.LINEAR_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Enable previews of Linear issues in documents by connecting a
Linear workspace to {appName}.
</Trans>
</Text>
{integrations.linear.length ? (
<>
<Heading as="h2">
<Flex justify="space-between" auto>
{t("Connected")}
<LinearConnectButton icon={<PlusIcon />} />
</Flex>
</Heading>
<List>
{integrations.linear.map((integration) => {
const linearWorkspace =
integration.settings?.linear?.workspace;
const integrationCreatedBy = integration.user
? integration.user.name
: undefined;
return (
<ListItem
key={linearWorkspace?.id}
small
title={linearWorkspace?.name}
subtitle={
integrationCreatedBy ? (
<>
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
&middot;{" "}
<Time
dateTime={integration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
) : (
<PlaceholderText />
)
}
image={
<TeamLogo
src={linearWorkspace?.logoUrl}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={integration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?"
)}
/>
}
/>
);
})}
</List>
</>
) : (
<p>
<LinearConnectButton icon={<LinearIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The Linear integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
export default observer(Linear);
@@ -1,23 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { LinearUtils } from "../../shared/LinearUtils";
export function LinearConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(LinearUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
-16
View File
@@ -1,16 +0,0 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
-7
View File
@@ -1,7 +0,0 @@
{
"id": "linear",
"name": "Linear",
"priority": 15,
"description": "Adds a Linear integration for link unfurling and converting links to mentions.",
"after": "github"
}
-93
View File
@@ -1,93 +0,0 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
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 } 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";
const router = new Router();
router.get(
"linear.callback",
auth({
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, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(LinearUtils.errorUrl(error));
return;
}
// validation middleware ensures that code is non-null at this point.
const oauth = await Linear.oauthAccess(code!);
const workspace = await Linear.getInstalledWorkspace(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Linear,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
scopes: oauth.scope.split(" "),
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.Embed>
>(
{
service: IntegrationService.Linear,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
linear: {
workspace: {
id: workspace.id,
name: workspace.name,
key: workspace.urlKey,
logoUrl: workspace.logoUrl,
},
},
},
},
{ transaction }
);
if (workspace.logoUrl) {
transaction.afterCommit(async () => {
await UploadLinearWorkspaceLogoTask.schedule({
integrationId: integration.id,
logoUrl: workspace.logoUrl,
});
});
}
ctx.redirect(LinearUtils.successUrl());
}
);
export default router;
-17
View File
@@ -1,17 +0,0 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const LinearCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
}),
});
export type LinearCallbackReq = z.infer<typeof LinearCallbackSchema>;
-25
View File
@@ -1,25 +0,0 @@
import { IsOptional } from "class-validator";
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
class LinearPluginEnvironment extends Environment {
/**
* Linear OAuth2 app client id. To enable integration with Linear.
*/
@Public
@IsOptional()
public LINEAR_CLIENT_ID = this.toOptionalString(environment.LINEAR_CLIENT_ID);
/**
* Linear OAuth2 app client secret. To enable integration with Linear.
*/
@IsOptional()
@CannotUseWithout("LINEAR_CLIENT_ID")
public LINEAR_CLIENT_SECRET = this.toOptionalString(
environment.LINEAR_CLIENT_SECRET
);
}
export default new LinearPluginEnvironment();
-32
View File
@@ -1,32 +0,0 @@
import { Minute } from "@shared/utils/time";
import { Hook, PluginManager } from "@server/utils/PluginManager";
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;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.Task,
value: UploadLinearWorkspaceLogoTask,
},
{
type: Hook.UnfurlProvider,
value: { unfurl: Linear.unfurl, cacheExpiry: Minute.seconds },
},
{
type: Hook.Uninstall,
value: uninstall,
},
]);
}
-211
View File
@@ -1,211 +0,0 @@
import { Issue, LinearClient, WorkflowState } from "@linear/sdk";
import sortBy from "lodash/sortBy";
import { z } from "zod";
import {
IntegrationService,
IntegrationType,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import User from "@server/models/User";
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
import { LinearUtils } from "../shared/LinearUtils";
import env from "./env";
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number(),
scope: z.string(),
});
export class Linear {
private static supportedUnfurls = [UnfurlResourceType.Issue];
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("code", code);
body.set("client_id", env.LINEAR_CLIENT_ID!);
body.set("client_secret", env.LINEAR_CLIENT_SECRET!);
body.set("redirect_uri", LinearUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(LinearUtils.tokenUrl, {
method: "POST",
headers,
body,
});
return AccessTokenResponseSchema.parse(await res.json());
}
static async revokeAccess(accessToken: string) {
const headers = {
Authorization: `Bearer ${accessToken}`,
};
await fetch(LinearUtils.revokeUrl, {
method: "POST",
headers,
});
}
static async getInstalledWorkspace(accessToken: string) {
const client = new LinearClient({ accessToken });
return client.organization;
}
/**
*
* @param url Linear resource url
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a Linear issue details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
const resource = Linear.parseUrl(url);
if (!resource) {
return;
}
const integration = (await Integration.scope("withAuthentication").findOne({
where: {
service: IntegrationService.Linear,
teamId: actor.teamId,
"settings.linear.workspace.key": resource.workspaceKey,
},
})) as Integration<IntegrationType.Embed>;
if (!integration) {
return;
}
try {
const client = new LinearClient({
accessToken: integration.authentication.token,
});
const issue = await client.issue(resource.id);
if (!issue) {
return { error: "Resource not found" };
}
const [author, state, labels] = await Promise.all([
issue.creator,
issue.state,
issue.paginate(issue.labels, {}),
]);
if (!author || !state || !labels) {
return { error: "Failed to fetch auxiliary data from Linear" };
}
const completionPercentage = await Linear.completionPercentage(
client,
issue,
state
);
return {
type: UnfurlResourceType.Issue,
url: issue.url,
id: issue.identifier,
title: issue.title,
description: issue.description ?? null,
author: {
name: author.name,
avatarUrl: author.avatarUrl ?? "",
},
labels: labels.map((label) => ({
name: label.name,
color: label.color,
})),
state: {
type: state.type,
name: state.name,
color: state.color,
completionPercentage,
},
createdAt: issue.createdAt.toISOString(),
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
} catch (err) {
Logger.warn("Failed to fetch resource from Linear", err);
return { error: err.message || "Unknown error" };
}
};
private static async completionPercentage(
client: LinearClient,
issue: Issue,
state: WorkflowState
) {
const defaultCompletionPercentage = 0.5; // fallback when we cannot determine actual value.
if (state.type !== "started") {
return defaultCompletionPercentage;
}
const team = await issue.team;
if (!team) {
return defaultCompletionPercentage;
}
const allStates = await client.paginate(client.workflowStates, {
filter: {
team: { id: { eq: team.id } },
type: { eq: "started" },
},
});
const states = sortBy(
allStates.map((s) => ({
name: s.name,
position: s.position,
})),
(s) => s.position
);
const idx = states.findIndex((s) => s.name === state.name);
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.
}
}
/**
* Parses a given URL and returns resource identifiers for Linear specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "linear.app") {
return;
}
const parts = pathname.split("/");
const workspaceKey = parts[1];
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
const id = parts[3];
const name = parts[4];
if (!type || !Linear.supportedUnfurls.includes(type)) {
return;
}
return { workspaceKey, type, id, name };
}
}
@@ -1,52 +0,0 @@
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,
};
}
}
-25
View File
@@ -1,25 +0,0 @@
import { IntegrationService, IntegrationType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import { Linear } from "./linear";
export async function uninstall(
integration: Integration<IntegrationType.Embed>
) {
if (integration.service !== IntegrationService.Linear) {
return;
}
const authentication = await integration.$get("authentication");
if (!authentication) {
return;
}
try {
await Linear.revokeAccess(authentication.token);
} catch (err) {
// suppress error since this is a best-effort operation.
Logger.error("Failed to revoke Linear access token", err);
}
}
-53
View File
@@ -1,53 +0,0 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class LinearUtils {
private static oauthScopes = "read,issues:create";
public static tokenUrl = "https://api.linear.app/oauth/token";
public static revokeUrl = "https://api.linear.app/oauth/revoke";
private static authBaseUrl = "https://linear.app/oauth/authorize";
private static settingsUrl = integrationSettingsPath("linear");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
params: undefined,
}
) {
return params
? `${baseUrl}/api/linear.callback?${params}`
: `${baseUrl}/api/linear.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.LINEAR_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes,
response_type: "code",
prompt: "consent",
actor: "app",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+41 -12
View File
@@ -1,10 +1,11 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Integration, IntegrationAuthentication } from "@server/models";
import { Integration, IntegrationAuthentication, Team } from "@server/models";
import { APIContext } from "@server/types";
import { NotionClient } from "../notion";
import * as T from "./schema";
@@ -16,21 +17,49 @@ 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, error } = ctx.input.query;
const { code, state, 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));
+16 -58
View File
@@ -13,13 +13,9 @@ import {
RichTextItemResponse,
} from "@notionhq/client/build/src/api-endpoints";
import { RateLimit } from "async-sema";
import emojiRegex from "emoji-regex";
import compact from "lodash/compact";
import truncate from "lodash/truncate";
import { z } from "zod";
import { Second } from "@shared/utils/time";
import { isUrl } from "@shared/utils/urls";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import { NotionUtils } from "../shared/NotionUtils";
import { Block, Page, PageType } from "../shared/types";
import env from "./env";
@@ -41,16 +37,7 @@ const AccessTokenResponseSchema = z.object({
bot_id: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
workspace_icon: z
.string()
.nullish()
.transform((val) => {
const emojiRegexp = emojiRegex();
if (val && (isUrl(val) || emojiRegexp.test(val))) {
return val;
}
return undefined;
}),
workspace_icon: z.string().url().nullish(),
});
export class NotionClient {
@@ -118,9 +105,7 @@ export class NotionClient {
pages.push({
type: item.object === "page" ? PageType.Page : PageType.Database,
id: item.id,
name: this.parseTitle(item, {
maxLength: CollectionValidation.maxNameLength,
}),
name: this.parseTitle(item),
emoji: this.parseEmoji(item),
});
}
@@ -133,22 +118,14 @@ export class NotionClient {
return pages;
}
async fetchPage(
pageId: string,
{ titleMaxLength }: { titleMaxLength: number }
) {
const pageInfo = await this.fetchPageInfo(pageId, { titleMaxLength });
async fetchPage(pageId: string) {
const pageInfo = await this.fetchPageInfo(pageId);
const blocks = await this.fetchBlockChildren(pageId);
return { ...pageInfo, blocks };
}
async fetchDatabase(
databaseId: string,
{ titleMaxLength }: { titleMaxLength: number }
) {
const databaseInfo = await this.fetchDatabaseInfo(databaseId, {
titleMaxLength,
});
async fetchDatabase(databaseId: string) {
const databaseInfo = await this.fetchDatabaseInfo(databaseId);
const pages = await this.queryDatabase(databaseId);
return { ...databaseInfo, pages };
}
@@ -174,6 +151,7 @@ export class NotionClient {
cursor = response.next_cursor ?? undefined;
}
// Recursive fetch when direct children have their own children.
await Promise.all(
blocks.map(async (block) => {
if (
@@ -214,9 +192,7 @@ export class NotionClient {
return {
type: PageType.Page,
id: item.id,
name: this.parseTitle(item, {
maxLength: DocumentValidation.maxTitleLength,
}),
name: this.parseTitle(item),
emoji: this.parseEmoji(item),
};
})
@@ -231,10 +207,7 @@ export class NotionClient {
return pages;
}
private async fetchPageInfo(
pageId: string,
{ titleMaxLength }: { titleMaxLength: number }
): Promise<PageInfo> {
private async fetchPageInfo(pageId: string): Promise<PageInfo> {
await this.limiter();
const page = (await this.client.pages.retrieve({
page_id: pageId,
@@ -243,9 +216,7 @@ export class NotionClient {
const author = await this.fetchUsername(page.created_by.id);
return {
title: this.parseTitle(page, {
maxLength: titleMaxLength,
}),
title: this.parseTitle(page),
emoji: this.parseEmoji(page),
author: author ?? undefined,
createdAt: !page.created_time ? undefined : new Date(page.created_time),
@@ -255,10 +226,7 @@ export class NotionClient {
};
}
private async fetchDatabaseInfo(
databaseId: string,
{ titleMaxLength }: { titleMaxLength: number }
): Promise<PageInfo> {
private async fetchDatabaseInfo(databaseId: string): Promise<PageInfo> {
await this.limiter();
const database = (await this.client.databases.retrieve({
database_id: databaseId,
@@ -267,9 +235,7 @@ export class NotionClient {
const author = await this.fetchUsername(database.created_by.id);
return {
title: this.parseTitle(database, {
maxLength: titleMaxLength,
}),
title: this.parseTitle(database),
emoji: this.parseEmoji(database),
author: author ?? undefined,
createdAt: !database.created_time
@@ -290,12 +256,12 @@ export class NotionClient {
return user.name;
}
// bot belongs to a user, get the user's name
// bot belongs to a user, get the user's name.
if (user.bot.owner.type === "user" && isFullUser(user.bot.owner.user)) {
return user.bot.owner.user.name;
}
// bot belongs to a workspace, fallback to bot's name
// bot belongs to a workspace, fallback to bot's name.
return user.name;
} catch (error) {
// Handle the case where a user can't be found
@@ -309,12 +275,7 @@ export class NotionClient {
}
}
private parseTitle(
item: PageObjectResponse | DatabaseObjectResponse,
{
maxLength = DocumentValidation.maxTitleLength,
}: { maxLength?: number } = {}
) {
private parseTitle(item: PageObjectResponse | DatabaseObjectResponse) {
let richTexts: RichTextItemResponse[];
if (item.object === "page") {
@@ -326,10 +287,7 @@ export class NotionClient {
richTexts = item.title;
}
const title = richTexts.map((richText) => richText.plain_text).join("");
// Truncate title to fit within validation limits
return truncate(title, { length: maxLength });
return richTexts.map((richText) => richText.plain_text).join("");
}
private parseEmoji(item: PageObjectResponse | DatabaseObjectResponse) {
@@ -2,7 +2,6 @@ import { APIResponseError, APIErrorCode } from "@notionhq/client";
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import ImportTask from "@server/models/ImportTask";
@@ -96,17 +95,12 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
client: NotionClient;
}): Promise<ParsePageOutput | null> {
const collectionExternalId = item.collectionExternalId ?? item.externalId;
const titleMaxLength =
item.externalId === collectionExternalId // This means it's a root page which will be imported as a collection
? CollectionValidation.maxNameLength
: DocumentValidation.maxTitleLength;
try {
// Convert Notion database to an empty page with "pages in database" as its children.
if (item.type === PageType.Database) {
const { pages, ...databaseInfo } = await client.fetchDatabase(
item.externalId,
{ titleMaxLength }
item.externalId
);
return {
@@ -121,9 +115,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
};
}
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId, {
titleMaxLength,
});
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
return {
...pageInfo,
+27 -11
View File
@@ -4,15 +4,16 @@ 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";
@@ -125,15 +126,6 @@ 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;
@@ -152,7 +144,31 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
throw ValidationError("Invalid state");
}
const { collectionId, type } = parsedState;
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"));
}
}
switch (type) {
case IntegrationType.Post: {
-29
View File
@@ -277,35 +277,6 @@ describe("#files.get", () => {
);
});
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
const attachment = await buildAttachment({
key,
teamId: user.teamId,
userId: user.id,
contentType: "image/jpg",
acl: "public-read",
});
await attachment.destroy({
hooks: false,
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
const res = await server.get(`/api/files.get?key=${key}`);
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
});
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
+9 -6
View File
@@ -78,13 +78,17 @@ router.get(
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
const skipAuthorize = isPublicBucket || isSignedRequest;
const cacheHeader = "max-age=604800, immutable";
const attachment = await Attachment.findByKey(key);
const attachment = await Attachment.findOne({
where: { key },
});
// Attachment is requested with a key, but it was not found
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
}
if (!skipAuthorize) {
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
}
authorize(actor, "read", attachment);
}
@@ -96,7 +100,6 @@ router.get(
ctx.set("Accept-Ranges", "bytes");
ctx.set("Cache-Control", cacheHeader);
ctx.set("Content-Type", contentType);
ctx.set("Content-Security-Policy", "sandbox");
ctx.attachment(fileName, {
type: forceDownload
? "attachment"
+4 -4
View File
@@ -52,18 +52,18 @@ function Webhooks() {
in near real-time.
</Trans>
</Text>
<PaginatedList<WebhookSubscription>
<PaginatedList
fetch={webhookSubscriptions.fetchPage}
items={webhookSubscriptions.enabled}
heading={<h2>{t("Active")}</h2>}
renderItem={(webhook) => (
renderItem={(webhook: WebhookSubscription) => (
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
)}
/>
<PaginatedList<WebhookSubscription>
<PaginatedList
items={webhookSubscriptions.disabled}
heading={<h2>{t("Inactive")}</h2>}
renderItem={(webhook) => (
renderItem={(webhook: WebhookSubscription) => (
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
)}
/>
-54
View File
@@ -1,54 +0,0 @@
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,15 +0,0 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
queryInterface.addColumn("integrations", "issueSources", {
type: Sequelize.JSONB,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
queryInterface.removeColumn("integrations", "issueSources");
},
};
-15
View File
@@ -5,7 +5,6 @@ import {
InferAttributes,
InferCreationAttributes,
QueryTypes,
FindOptions,
} from "sequelize";
import {
BeforeDestroy,
@@ -165,20 +164,6 @@ class Attachment extends IdModel<
// static methods
/**
* Find an attachment by its key.
*
* @param key - The key of the attachment to find.
* @param options - Additional options for the query.
* @returns A promise resolving to the attachment with the given key, or null if not found.
*/
static async findByKey(
key: string,
options?: FindOptions<Attachment>
): Promise<Attachment | null> {
return this.findOne({ where: { key }, ...options });
}
/**
* Get the total size of all attachments for a given team.
*
-4
View File
@@ -13,7 +13,6 @@ import {
IsIn,
AfterDestroy,
} from "sequelize-typescript";
import { IssueSource } from "@shared/schema";
import { IntegrationType, IntegrationService } from "@shared/types";
import type { IntegrationSettings } from "@shared/types";
import Collection from "@server/models/Collection";
@@ -54,9 +53,6 @@ class Integration<T = unknown> extends ParanoidModel<
@Column(DataType.ARRAY(DataType.STRING))
events: string[];
@Column(DataType.JSONB)
issueSources: IssueSource[] | null;
// associations
@BelongsTo(() => User, "userId")
@@ -251,10 +251,6 @@ describe("NotificationHelper", () => {
userId: subscribedUser.id,
collectionId: document.collectionId!,
});
await buildSubscription({
userId: subscribedUser.id,
documentId: document.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
+7 -9
View File
@@ -1,5 +1,4 @@
import uniq from "lodash/uniq";
import uniqBy from "lodash/uniqBy";
import { Op } from "sequelize";
import {
NotificationEventType,
@@ -188,6 +187,7 @@ export default class NotificationHelper {
});
} else {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: {
[Op.ne]: actorId,
@@ -206,19 +206,17 @@ export default class NotificationHelper {
],
});
recipients = uniqBy(
subscriptions.map((s) => s.user),
(user) => user.id
);
recipients = subscriptions.map((s) => s.user);
}
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(notificationType)
);
const filtered = [];
for (const recipient of recipients) {
if (
recipient.isSuspended ||
!recipient.subscribedToEventType(notificationType)
) {
if (recipient.isSuspended) {
continue;
}
+3 -5
View File
@@ -1,7 +1,7 @@
import { JSDOM } from "jsdom";
import compact from "lodash/compact";
import flatten from "lodash/flatten";
import isMatch from "lodash/isMatch";
import isEqual from "lodash/isEqual";
import uniq from "lodash/uniq";
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
import * as React from "react";
@@ -12,7 +12,7 @@ import * as Y from "yjs";
import EditorContainer from "@shared/editor/components/Styles";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
import { MentionType, ProsemirrorData, UnfurlResponse } from "@shared/types";
import { MentionType, ProsemirrorData } from "@shared/types";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
@@ -42,8 +42,6 @@ export type MentionAttrs = {
modelId: string;
actorId: string | undefined;
id: string;
href?: string;
unfurl?: UnfurlResponse[keyof UnfurlResponse];
};
@trace()
@@ -196,7 +194,7 @@ export class ProsemirrorHelper {
node.descendants((childNode: Node) => {
if (
childNode.type.name === "mention" &&
isMatch(childNode.attrs, mention)
isEqual(childNode.attrs, mention)
) {
foundMention = true;
return false;
+1 -1
View File
@@ -57,7 +57,7 @@ describe("policies/team", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, true],
[UserRole.Viewer, true],
[UserRole.Viewer, false],
[UserRole.Guest, true],
]);
for (const [role, permission] of permissions.entries()) {
+5 -1
View File
@@ -9,7 +9,7 @@ import {
or,
} from "./utils";
allow(User, ["read", "readTemplate"], Team, isTeamModel);
allow(User, "read", Team, isTeamModel);
allow(User, "share", Team, (actor, team) =>
and(
@@ -50,6 +50,10 @@ allow(User, "createTemplate", Team, (actor, team) =>
)
);
allow(User, "readTemplate", Team, (actor, team) =>
and(!actor.isViewer, isTeamModel(actor, team))
);
allow(User, "updateTemplate", Team, (actor, team) =>
and(
//
+38 -54
View File
@@ -71,63 +71,47 @@ const presentDocument = (
const presentPR = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.PR] => {
// TODO: For backwards compatibility, remove once cache has expired in next release.
if (data.transformed_unfurl) {
delete data.transformed_unfurl;
return data as UnfurlResponse[UnfurlResourceType.PR]; // this would have been transformed by the unfurl plugin.
}
return {
url: data.html_url,
type: UnfurlResourceType.PR,
id: `#${data.number}`,
title: data.title,
description: data.body,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
state: {
name: data.merged ? "merged" : data.state,
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
},
createdAt: data.created_at,
};
};
): UnfurlResponse[UnfurlResourceType.PR] => ({
url: data.html_url,
type: UnfurlResourceType.PR,
id: `#${data.number}`,
title: data.title,
description: data.body,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
state: {
name: data.merged ? "merged" : data.state,
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
},
createdAt: data.created_at,
});
const presentIssue = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.Issue] => {
// TODO: For backwards compatibility, remove once cache has expired in next release.
if (data.transformed_unfurl) {
delete data.transformed_unfurl;
return data as UnfurlResponse[UnfurlResourceType.Issue]; // this would have been transformed by the unfurl plugin.
}
return {
url: data.html_url,
type: UnfurlResourceType.Issue,
id: `#${data.number}`,
title: data.title,
description: data.body_text,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
labels: data.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: data.state,
color: GitHubUtils.getColorForStatus(
data.state === "closed" ? "done" : data.state
),
},
createdAt: data.created_at,
};
};
): UnfurlResponse[UnfurlResourceType.Issue] => ({
url: data.html_url,
type: UnfurlResourceType.Issue,
id: `#${data.number}`,
title: data.title,
description: data.body_text,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
labels: data.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: data.state,
color: GitHubUtils.getColorForStatus(
data.state === "closed" ? "done" : data.state
),
},
createdAt: data.created_at,
});
const presentLastOnlineInfoFor = (user: User) => {
const locale = dateLocale(user.language);
@@ -3,7 +3,6 @@ import { Integration } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import { IntegrationEvent, Event } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
export default class IntegrationCreatedProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["integrations.create"];
@@ -19,11 +18,6 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
return;
}
// Store the available issue sources in the integration record.
await CacheIssueSourcesTask.schedule({
integrationId: integration.id,
});
// Clear the cache of unfurled data for the team as it may be stale now.
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
}
@@ -1,32 +0,0 @@
import { Integration } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import BaseTask from "./BaseTask";
const plugins = PluginManager.getHooks(Hook.IssueProvider);
type Props = {
integrationId: string;
};
export default class CacheIssueSourcesTask extends BaseTask<Props> {
async perform({ integrationId }: Props) {
const integration = await Integration.findByPk(integrationId);
if (!integration) {
return;
}
const plugin = plugins.find((p) => p.value.service === integration.service);
if (!plugin) {
return;
}
const sources = await plugin.value.fetchSources(integration);
await sequelize.transaction(async (transaction) => {
await integration.reload({ transaction, lock: transaction.LOCK.UPDATE });
integration.issueSources = sources;
await integration.save({ transaction });
});
}
}
@@ -85,21 +85,16 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
)
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
await sequelize.transaction(async (transaction) => {
for (const recipient of recipients) {
await Notification.create(
{
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
},
{ transaction }
);
}
});
for (const recipient of recipients) {
await Notification.create({
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
});
}
}
public get options() {
+1 -1
View File
@@ -20,7 +20,7 @@ jest.mock("dns", () => ({
jest
.spyOn(Iframely, "requestResource")
.mockImplementation(() => Promise.resolve({}));
.mockImplementation(() => Promise.resolve(undefined));
const server = getTestServer();
+3 -4
View File
@@ -98,12 +98,11 @@ router.post(
}
for (const plugin of plugins) {
const unfurl = await plugin.value.unfurl(url, actor);
if (unfurl) {
if ("error" in unfurl) {
const data = await plugin.value.unfurl(url, actor);
if (data) {
if ("error" in data) {
return (ctx.response.status = 204);
} else {
const data = unfurl as Unfurl;
await CacheHelper.setData(
CacheHelper.getUnfurlKey(actor.teamId, url),
data,
+4 -9
View File
@@ -1,7 +1,6 @@
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";
@@ -163,12 +162,6 @@ 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",
@@ -176,9 +169,11 @@ export default abstract class BaseStorage {
options?.maxUploadSize ?? Infinity,
env.FILE_STORAGE_UPLOAD_MAX_SIZE
),
headers,
headers: {
"User-Agent": chromeUserAgent,
},
timeout: 10000,
...initWithoutHeaders,
...init,
});
if (!res.ok) {
+2 -18
View File
@@ -10,7 +10,6 @@ import {
JSONValue,
UnfurlResourceType,
ProsemirrorData,
UnfurlResponse,
} from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
import { AccountProvisionerResult } from "./commands/accountProvisioner";
@@ -577,27 +576,12 @@ export type CollectionJSONExport = {
};
};
export type UnfurlIssueAndPR = (
| UnfurlResponse[UnfurlResourceType.Issue]
| UnfurlResponse[UnfurlResourceType.PR]
) & { transformed_unfurl: true };
export type Unfurl =
| UnfurlIssueAndPR
| {
type: Exclude<
UnfurlResourceType,
UnfurlResourceType.Issue | UnfurlResourceType.PR
>;
[x: string]: JSONValue;
};
export type UnfurlError = { error: string };
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
export type UnfurlSignature = (
url: string,
actor?: User
) => Promise<Unfurl | UnfurlError | undefined>;
) => Promise<Unfurl | void>;
export type UninstallSignature = (integration: Integration) => Promise<void>;
-15
View File
@@ -1,15 +0,0 @@
import { IssueSource } from "@shared/schema";
import { IntegrationType, IssueTrackerIntegrationService } from "@shared/types";
import { Integration } from "@server/models";
export abstract class BaseIssueProvider {
service: IssueTrackerIntegrationService;
constructor(service: IssueTrackerIntegrationService) {
this.service = service;
}
abstract fetchSources(
integration: Integration<IntegrationType.Embed>
): Promise<IssueSource[]>;
}
-3
View File
@@ -9,7 +9,6 @@ import Logger from "@server/logging/Logger";
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
import type BaseTask from "@server/queues/tasks/BaseTask";
import { UnfurlSignature, UninstallSignature } from "@server/types";
import { BaseIssueProvider } from "./BaseIssueProvider";
export enum PluginPriority {
VeryHigh = 0,
@@ -26,7 +25,6 @@ export enum Hook {
API = "api",
AuthProvider = "authProvider",
EmailTemplate = "emailTemplate",
IssueProvider = "issueProvider",
Processor = "processor",
Task = "task",
UnfurlProvider = "unfurl",
@@ -41,7 +39,6 @@ type PluginValueMap = {
[Hook.API]: Router;
[Hook.AuthProvider]: { router: Router; id: string };
[Hook.EmailTemplate]: typeof BaseEmail;
[Hook.IssueProvider]: BaseIssueProvider;
[Hook.Processor]: typeof BaseProcessor;
[Hook.Task]: typeof BaseTask<any>;
[Hook.Uninstall]: UninstallSignature;
-39
View File
@@ -1,39 +0,0 @@
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>;
})}
</>
);
};
@@ -1,49 +0,0 @@
import React from "react";
import { BaseIconProps } from ".";
export function GitHubIssueStatusIcon(props: BaseIconProps) {
const { state, className, size = 16 } = props;
switch (state.name) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width={size}
height={size}
fill={state.color}
className={className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width={size}
height={size}
fill={state.color}
className={className}
>
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
</svg>
);
case "canceled":
return (
<svg
viewBox="0 0 16 16"
width={size}
height={size}
fill={state.color}
className={className}
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
</svg>
);
default:
return null;
}
}
@@ -1,75 +0,0 @@
import * as React from "react";
import { useTheme } from "styled-components";
import { isSafari } from "../../utils/browser";
import { BaseIconProps } from ".";
enum StateType {
Triage = "triage",
Backlog = "backlog",
Unstarted = "unstarted",
Started = "started",
Completed = "completed",
Canceled = "canceled",
}
export function LinearIssueStatusIcon(props: BaseIconProps) {
const theme = useTheme();
const { state, size = 16 } = props;
const percentage =
state.type === StateType.Triage ||
state.type === StateType.Backlog ||
state.type === StateType.Unstarted
? 0
: state.type === StateType.Started
? state.completionPercentage ?? 0.5
: 1;
const isTriage = state.type === StateType.Triage;
const isBacklog = state.type === StateType.Backlog;
const isCompleted = state.type === StateType.Completed;
// Due to some rendering issues and differences between browsers, the logical constant 4 in the rendering below
// needs to be a bit less to make 50% look like half a circle.
const magicFour = isSafari() ? 3.895 : 3.98;
return (
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
<circle
cx={7}
cy={7}
r={isTriage ? 3.5 : 6}
fill="none"
stroke={state.color}
strokeWidth={isTriage ? 7 : 1.5}
strokeDasharray={isTriage ? "2 0" : isBacklog ? "1.4 1.74" : "3.14 0"}
strokeDashoffset={isTriage ? 3.2 : isBacklog ? 0.65 : -0.7}
/>
<circle
cx={7}
cy={7}
r={percentage === 1 ? 3 : 2}
fill="none"
stroke={state.color}
strokeWidth={percentage === 1 ? 6 : 4}
strokeDasharray={`${
percentage * Math.PI * (percentage === 1 ? 6 : magicFour)
} 100`}
strokeDashoffset={0}
transform={`rotate(-90 7 7)`}
/>
{(isTriage || percentage === 1) && (
<path
className="icon"
stroke="none"
d={isTriage ? triageIcon : isCompleted ? checkMarkIcon : closeIcon}
fill={theme.background}
/>
)}
</svg>
);
}
const checkMarkIcon =
"M10.951 4.24896C11.283 4.58091 11.283 5.11909 10.951 5.45104L5.95104 10.451C5.61909 10.783 5.0809 10.783 4.74896 10.451L2.74896 8.45104C2.41701 8.11909 2.41701 7.5809 2.74896 7.24896C3.0809 6.91701 3.61909 6.91701 3.95104 7.24896L5.35 8.64792L9.74896 4.24896C10.0809 3.91701 10.6191 3.91701 10.951 4.24896Z";
const triageIcon =
"M8.0126 7.98223V9.50781C8.0126 9.92901 8.52329 10.1548 8.85102 9.87854L11.8258 7.37066C12.0581 7.17486 12.0581 6.82507 11.8258 6.62927L8.85102 4.12139C8.52329 3.84509 8.0126 4.07092 8.0126 4.49212V6.01763H5.98739V4.49218C5.98739 4.07098 5.4767 3.84515 5.14897 4.12146L2.17419 6.62933C1.94194 6.82513 1.94194 7.17492 2.17419 7.37072L5.14897 9.8786C5.4767 10.1549 5.98739 9.92907 5.98739 9.50787V7.98223H8.0126Z";
const closeIcon =
"M3.73657 3.73657C4.05199 3.42114 4.56339 3.42114 4.87881 3.73657L5.93941 4.79716L7 5.85775L9.12117 3.73657C9.4366 3.42114 9.94801 3.42114 10.2634 3.73657C10.5789 4.05199 10.5789 4.56339 10.2634 4.87881L8.14225 7L10.2634 9.12118C10.5789 9.4366 10.5789 9.94801 10.2634 10.2634C9.94801 10.5789 9.4366 10.5789 9.12117 10.2634L7 8.14225L4.87881 10.2634C4.56339 10.5789 4.05199 10.5789 3.73657 10.2634C3.42114 9.94801 3.42114 9.4366 3.73657 9.12118L4.79716 8.06059L5.85775 7L3.73657 4.87881C3.42114 4.56339 3.42114 4.05199 3.73657 3.73657Z";

Some files were not shown because too many files have changed in this diff Show More