Compare commits

..

1 Commits

Author SHA1 Message Date
Tom Moor 087f5bd1d3 fix: Infinite loop loading page with vbnet code embed 2025-04-15 21:47:34 -04:00
177 changed files with 3344 additions and 5257 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
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,13 +1,7 @@
import * as React from "react";
import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import {
IntegrationService,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
@@ -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,7 +1,5 @@
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";
@@ -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;
@@ -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}
-1
View File
@@ -321,7 +321,6 @@ const Container = styled(Flex)<ContainerProps>`
z-index: ${depths.mobileSidebar};
max-width: 80%;
min-width: 280px;
padding-left: var(--sal);
${fadeOnDesktopBackgrounded()}
@media print {
@@ -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}
@@ -38,10 +38,10 @@ function StarredLink({ star }: Props) {
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collectionId ? collections.get(collectionId) : undefined;
const collection = collections.get(collectionId);
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = starredSidebarContext(
star.documentId ?? star.collectionId ?? ""
star.documentId ?? star.collectionId
);
const [expanded, setExpanded] = useState(
(star.documentId
@@ -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;
+1 -6
View File
@@ -7,7 +7,6 @@ import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { getSafeAreaInsets } from "@shared/utils/browser";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useEventListener from "~/hooks/useEventListener";
@@ -242,16 +241,12 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
if (props.active) {
const rect = document.body.getBoundingClientRect();
const safeAreaInsets = getSafeAreaInsets();
return (
<ReactPortal>
<MobileWrapper
ref={menuRef}
style={{
bottom: `calc(100% - ${
height - rect.y - safeAreaInsets.bottom
}px)`,
bottom: `calc(100% - ${height - rect.y}px)`,
}}
>
{props.children}
+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: {
+2 -3
View File
@@ -6,7 +6,6 @@ import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isUrl } from "@shared/utils/urls";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -30,9 +29,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (pastedText && isUrl(pastedText)) {
const url = new URL(pastedText);
if (url) {
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
+1 -2
View File
@@ -2,8 +2,7 @@ import Extension from "@shared/editor/lib/Extension";
import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
// Note that the suppression of pipe here prevents conflict with table creation rule.
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
+3 -3
View File
@@ -67,7 +67,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+B`,
icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: !isCode && (!isMobile || !isEmpty),
},
{
name: "em",
@@ -75,7 +75,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+I`,
icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: !isCode && (!isMobile || !isEmpty),
},
{
name: "strikethrough",
@@ -83,7 +83,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+D`,
icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: !isCode && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.mark,
+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"),
-10
View File
@@ -331,16 +331,6 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
/**
* Returns the documents that link to this document.
*
* @returns documents that link to this document
*/
@computed
get backlinks(): Document[] {
return this.store.getBacklinkedDocuments(this.id);
}
/**
* Returns users that have been individually given access to the document.
*
+1 -1
View File
@@ -22,7 +22,7 @@ class Star extends Model {
document?: Document;
/** The collection ID that is starred. */
collectionId?: string;
collectionId: string;
/** The collection that is starred. */
@Relation(() => Collection, { onDelete: "cascade" })
+12 -6
View File
@@ -66,6 +66,7 @@ const CollectionScene = observer(function _CollectionScene() {
const location = useLocation();
const { t } = useTranslation();
const { documents, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
@@ -119,16 +120,21 @@ const CollectionScene = observer(function _CollectionScene() {
React.useEffect(() => {
async function fetchData() {
try {
setError(undefined);
await collections.fetch(id);
} catch (err) {
setError(err);
if ((!can || !collection) && !error && !isFetching) {
try {
setError(undefined);
setFetching(true);
await collections.fetch(id);
} catch (err) {
setError(err);
} finally {
setFetching(false);
}
}
}
void fetchData();
}, []);
}, [collections, isFetching, collection, error, id, can]);
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
+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}
@@ -18,7 +18,7 @@ type Props = {
};
function References({ document }: Props) {
const { documents } = useStores();
const { collections, documents } = useStores();
const user = useCurrentUser();
const location = useLocation();
const locationSidebarContext = useLocationSidebarContext();
@@ -27,8 +27,10 @@ function References({ document }: Props) {
void documents.fetchBacklinks(document.id);
}, [documents, document.id]);
const backlinks = document.backlinks;
const collection = document.collection;
const backlinks = documents.getBacklinkedDocuments(document.id);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const children = collection
? collection.getChildrenForDocument(document.id)
: [];
+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);
@@ -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}
+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
@@ -186,13 +186,6 @@ export default class CollectionsStore extends Store<Collection> {
statusFilter: [CollectionStatusFilter.Archived],
});
get(id: string): Collection | undefined {
return (
this.data.get(id) ??
this.orderedData.find((collection) => id.endsWith(collection.urlId))
);
}
@computed
get archived(): Collection[] {
return orderBy(this.orderedData, "archivedAt", "desc").filter(
+2 -2
View File
@@ -300,8 +300,8 @@ export default class DocumentsStore extends Store<Document> {
const documentIds = this.backlinks.get(documentId) || [];
return orderBy(
compact(documentIds.map((id) => this.data.get(id))),
"title",
"asc"
"updatedAt",
"desc"
);
}
-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
-15
View File
@@ -27,16 +27,6 @@ export const isURLMentionable = ({
);
}
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;
}
@@ -62,11 +52,6 @@ export const determineMentionType = ({
: 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.797.0",
"@aws-sdk/lib-storage": "3.797.0",
"@aws-sdk/s3-presigned-post": "3.797.0",
"@aws-sdk/s3-request-presigner": "3.797.0",
"@aws-sdk/signature-v4-crt": "^3.796.0",
"@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",
"@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",
@@ -174,13 +173,13 @@
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^7.0.2",
"pg": "^8.15.6",
"pg": "^8.14.1",
"pg-tsquery": "^8.4.2",
"pluralize": "^8.0.0",
"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",
@@ -192,7 +191,7 @@
"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",
@@ -209,7 +208,7 @@
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.13",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
@@ -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.4",
"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",
@@ -356,12 +355,12 @@
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
"nodemon": "^3.1.10",
"nodemon": "^3.1.9",
"postinstall-postinstall": "^2.1.0",
"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",
"vite-plugin-static-copy": "^0.17.0",
@@ -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 }
);
transaction.afterCommit(async () => {
if (workspace.logoUrl) {
await new 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) {
@@ -66,6 +66,6 @@ export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.
protected async scheduleTask(
importTask: ImportTask<IntegrationService.Notion>
): Promise<void> {
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
}
}
@@ -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";
@@ -77,7 +76,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
protected async scheduleNextTask(
importTask: ImportTask<IntegrationService.Notion>
) {
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
return;
}
@@ -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} />
)}
/>
@@ -29,12 +29,8 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalled();
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
event,
subscriptionId: subscription.id,
});
@@ -57,9 +53,7 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledTimes(0);
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(0);
});
it("it schedules a delivery for the event for each subscription", async () => {
@@ -85,21 +79,13 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalled();
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledTimes(2);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(2);
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
event,
subscriptionId: subscription.id,
});
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
event,
subscriptionId: subscriptionTwo.id,
});
@@ -24,10 +24,7 @@ export default class WebhookProcessor extends BaseProcessor {
await Promise.all(
applicableSubscriptions.map((subscription) =>
new DeliverWebhookTask().schedule({
event,
subscriptionId: subscription.id,
})
DeliverWebhookTask.schedule({ event, subscriptionId: subscription.id })
)
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ type Props = {
/** Position of moved document within document structure */
index?: number;
/** The IP address of the user moving the document */
ip: string | null;
ip: string;
/** The database transaction to run within */
transaction?: Transaction;
};
@@ -4,11 +4,9 @@ import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import { buildAttachment, buildDocument } from "@server/test/factories";
import documentPermanentDeleter from "./documentPermanentDeleter";
jest.mock("@server/queues/tasks/DeleteAttachmentTask");
beforeEach(() => {
jest.resetAllMocks();
});
jest.mock("@server/queues/tasks/DeleteAttachmentTask", () => ({
schedule: jest.fn(),
}));
describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => {
@@ -62,9 +60,7 @@ describe("documentPermanentDeleter", () => {
await document.save();
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(
jest.mocked(DeleteAttachmentTask.prototype.schedule)
).toHaveBeenCalledTimes(2);
expect(DeleteAttachmentTask.schedule).toHaveBeenCalledTimes(2);
expect(
await Document.unscoped().count({
where: {
+1 -1
View File
@@ -67,7 +67,7 @@ export default async function documentPermanentDeleter(documents: Document[]) {
"commands",
`Attachment ${attachmentId} scheduled for deletion`
);
await new DeleteAttachmentTask().schedule({
await DeleteAttachmentTask.schedule({
attachmentId,
teamId: document.teamId,
});
+1 -1
View File
@@ -56,5 +56,5 @@ export default async function userSuspender({
}
);
await new CleanupDemotedUserTask().schedule({ userId: user.id });
await CleanupDemotedUserTask.schedule({ userId: user.id });
}
-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.
*
+36 -20
View File
@@ -13,6 +13,7 @@ import {
Transaction,
Op,
FindOptions,
ScopeOptions,
WhereOptions,
EmptyResultError,
} from "sequelize";
@@ -105,6 +106,20 @@ type AdditionalFindOptions = {
},
}))
@Scopes(() => ({
withCollectionPermissions: (userId: string, paranoid = true) => ({
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
},
],
}),
withoutState: {
attributes: {
exclude: ["state"],
@@ -154,23 +169,13 @@ type AdditionalFindOptions = {
],
};
},
withMembership: (userId: string, paranoid = true) => {
withMembership: (userId: string) => {
if (!userId) {
return {};
}
return {
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
},
{
association: "memberships",
where: {
@@ -632,15 +637,21 @@ class Document extends ArchivableModel<
return uniq(membershipUserIds);
}
static withMembershipScope(userId: string, options?: FindOptions<Document>) {
static defaultScopeWithUser(userId: string) {
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", userId],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", userId],
};
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", userId],
};
return this.scope([
"defaultScope",
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId, options?.paranoid],
},
collectionScope,
viewScope,
membershipScope,
]);
}
@@ -674,12 +685,14 @@ class Document extends ArchivableModel<
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"withDrafts",
options.includeState ? "withState" : "withoutState",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId, rest.paranoid],
method: ["withMembership", userId],
},
]);
@@ -737,6 +750,9 @@ class Document extends ArchivableModel<
const user = userId ? await User.findByPk(userId) : null;
const documents = await this.scope([
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
{
method: ["withViews", userId],
},
-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")
+1 -1
View File
@@ -408,7 +408,7 @@ class Team extends ParanoidModel<
});
if (attachment) {
await new DeleteAttachmentTask().schedule({
await DeleteAttachmentTask.schedule({
attachmentId: attachment.id,
teamId: model.id,
});
+1 -1
View File
@@ -717,7 +717,7 @@ class User extends ParanoidModel<
});
if (attachment) {
await new DeleteAttachmentTask().schedule({
await DeleteAttachmentTask.schedule({
attachmentId: attachment.id,
teamId: model.teamId,
});
@@ -1,9 +1,7 @@
import { DocumentPermission, NotificationEventType } from "@shared/types";
import { UserMembership } from "@server/models";
import { NotificationEventType } from "@shared/types";
import {
buildComment,
buildDocument,
buildDraftDocument,
buildSubscription,
buildUser,
} from "@server/test/factories";
@@ -56,78 +54,6 @@ describe("NotificationHelper", () => {
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
it("should only return users who have notification enabled for comment creation and are subscribed to the document in case of new thread in draft", async () => {
const documentAuthor = await buildUser();
// create a draft
const document = await buildDraftDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
collectionId: null,
});
// add a bunch of users as direct members
const user = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const user2 = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const user3 = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
await UserMembership.create({
documentId: document.id,
userId: user.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
await UserMembership.create({
documentId: document.id,
userId: user2.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
await UserMembership.create({
documentId: document.id,
userId: user3.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
// Add a subscription for only one of those users
await Promise.all([
buildSubscription({
userId: user.id,
}),
buildSubscription({
userId: user2.id,
}),
buildSubscription({
userId: user3.id,
documentId: document.id,
}),
]);
const comment = await buildComment({
documentId: document.id,
userId: documentAuthor.id,
});
const recipients =
await NotificationHelper.getCommentNotificationRecipients(
document,
comment,
comment.createdById
);
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(user3.id);
});
it("should only return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
@@ -325,10 +251,6 @@ describe("NotificationHelper", () => {
userId: subscribedUser.id,
collectionId: document.collectionId!,
});
await buildSubscription({
userId: subscribedUser.id,
documentId: document.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
+11 -19
View File
@@ -1,5 +1,4 @@
import uniq from "lodash/uniq";
import uniqBy from "lodash/uniqBy";
import { Op } from "sequelize";
import {
NotificationEventType,
@@ -188,21 +187,16 @@ export default class NotificationHelper {
});
} else {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: {
[Op.ne]: actorId,
},
event: SubscriptionType.Document,
...(document.collectionId
? {
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
}
: {
documentId: document.id,
}),
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
},
include: [
{
@@ -212,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;
}
+36 -18
View File
@@ -182,16 +182,25 @@ export default class SearchHelper {
},
];
return Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
include,
offset,
limit,
});
return Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
include,
offset,
limit,
});
}
public static async searchCollectionsForUser(
@@ -264,14 +273,23 @@ export default class SearchHelper {
// Final query to get associated document data
const [documents, count] = await Promise.all([
Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
},
}),
Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
},
}),
results.length < limit && offset === 0
? Promise.resolve(results.length)
: countQuery,
+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);
+2 -2
View File
@@ -17,7 +17,7 @@ export default class AvatarProcessor extends BaseProcessor {
});
if (user.avatarUrl) {
await new UploadUserAvatarTask().schedule({
await UploadUserAvatarTask.schedule({
userId: event.userId,
avatarUrl: user.avatarUrl,
});
@@ -30,7 +30,7 @@ export default class AvatarProcessor extends BaseProcessor {
});
if (team.avatarUrl) {
await new UploadTeamAvatarTask().schedule({
await UploadTeamAvatarTask.schedule({
teamId: event.teamId,
avatarUrl: team.avatarUrl,
});
@@ -12,7 +12,7 @@ export default class CollectionsProcessor extends BaseProcessor {
];
async perform(event: CollectionEvent) {
await new DetachDraftsFromCollectionTask().schedule({
await DetachDraftsFromCollectionTask.schedule({
collectionId: event.collectionId,
actorId: event.actorId,
ip: event.ip,
@@ -27,7 +27,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async perform(event: ReceivedEvent) {
switch (event.name) {
case "collections.remove_user": {
await new CollectionSubscriptionRemoveUserTask().schedule(event);
await CollectionSubscriptionRemoveUserTask.schedule(event);
return;
}
@@ -35,7 +35,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
return this.handleRemoveGroupFromCollection(event);
case "documents.remove_user": {
await new DocumentSubscriptionRemoveUserTask().schedule(event);
await DocumentSubscriptionRemoveUserTask.schedule(event);
return;
}
@@ -57,11 +57,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
new CollectionSubscriptionRemoveUserTask().schedule({
CollectionSubscriptionRemoveUserTask.schedule({
...event,
name: "collections.remove_user",
userId: groupUser.userId,
} as CollectionUserEvent)
})
)
);
}
@@ -79,11 +79,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
new DocumentSubscriptionRemoveUserTask().schedule({
DocumentSubscriptionRemoveUserTask.schedule({
...event,
name: "documents.remove_user",
userId: groupUser.userId,
} as DocumentUserEvent)
})
)
);
}
@@ -20,12 +20,12 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
if (fileOperation.type === FileOperationType.Import) {
switch (fileOperation.format) {
case FileOperationFormat.MarkdownZip:
await new ImportMarkdownZipTask().schedule({
await ImportMarkdownZipTask.schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.JSON:
await new ImportJSONTask().schedule({
await ImportJSONTask.schedule({
fileOperationId: event.modelId,
});
break;
@@ -36,17 +36,17 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
if (fileOperation.type === FileOperationType.Export) {
switch (fileOperation.format) {
case FileOperationFormat.HTMLZip:
await new ExportHTMLZipTask().schedule({
await ExportHTMLZipTask.schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.MarkdownZip:
await new ExportMarkdownZipTask().schedule({
await ExportMarkdownZipTask.schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.JSON:
await new ExportJSONTask().schedule({
await ExportJSONTask.schedule({
fileOperationId: event.modelId,
});
break;
@@ -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 new 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));
}

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