Compare commits

..

4 Commits

184 changed files with 3966 additions and 6836 deletions
-4
View File
@@ -127,10 +127,6 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Linear
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
+1 -1
View File
@@ -65,7 +65,7 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["error", "method"],
"lodash/import-scope": ["warn", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+4 -6
View File
@@ -11,7 +11,7 @@ env:
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=8000
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
@@ -63,7 +63,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
@@ -82,7 +81,7 @@ jobs:
- 'yarn.lock'
test:
needs: [build, changes]
needs: build
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -144,8 +143,8 @@ jobs:
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -162,4 +161,3 @@ jobs:
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+19 -179
View File
@@ -3,32 +3,25 @@ name: Docker
on:
push:
tags:
- "v*"
- 'v*'
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -36,177 +29,24 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build and push
id: build
uses: docker/build-push-action@v6
- name: Build and push main image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
+2 -3
View File
@@ -1,6 +1,5 @@
ARG APP_PATH=/opt/outline
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
FROM outlinewiki/outline-base AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -31,7 +30,7 @@ RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+1 -4
View File
@@ -1,14 +1,11 @@
ARG APP_PATH=/opt/outline
FROM node:20 AS deps
FROM node:20-slim AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.83.0
Licensed Work: Outline 0.82.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-04-11
Change Date: 2029-02-15
Change License: Apache License, Version 2.0
+1 -27
View File
@@ -29,7 +29,6 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -511,25 +510,6 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
@@ -575,12 +555,7 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
@@ -1230,7 +1205,6 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
+2 -1
View File
@@ -5,6 +5,7 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default observer(Authenticated);
+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`
+141 -147
View File
@@ -1,5 +1,4 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
@@ -9,7 +8,6 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
@@ -25,9 +23,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -38,155 +36,151 @@ enum Direction {
DOWN,
}
const HoverPreviewDesktop = observer(
({ element, unfurlId, dataLoading, onClose }: Props) => {
const { unfurls } = useStores();
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
);
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -196,7 +190,7 @@ function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
unfurlId={unfurlId}
data={data}
dataLoading={dataLoading}
/>
);
@@ -1,15 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import {
IntegrationService,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -29,11 +23,6 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -42,18 +31,13 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
<CardContent>
<Flex gap={2} column>
<Title>
<StyledIssueStatusIcon
service={service}
state={state}
size={18}
/>
<IssueStatusIcon status={state.name} color={state.color} />
<span>
<Backticks content={title} />
&nbsp;<Text type="tertiary">{id}</Text>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={6}>
<Avatar src={author.avatarUrl} size={18} />
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} created{" "}
@@ -78,8 +62,4 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
);
});
const StyledIssueStatusIcon = styled(IssueStatusIcon)`
margin-top: 2px;
`;
export default HoverPreviewIssue;
@@ -1,11 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -33,14 +31,13 @@ const HoverPreviewPullRequest = React.forwardRef(
<CardContent>
<Flex gap={2} column>
<Title>
<StyledPullRequestIcon size={18} state={state} />
<PullRequestIcon status={state.name} color={state.color} />
<span>
<Backticks content={title} />
&nbsp;<Text type="tertiary">{id}</Text>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={6}>
<Avatar src={author.avatarUrl} size={18} />
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} opened{" "}
@@ -58,8 +55,4 @@ const HoverPreviewPullRequest = React.forwardRef(
}
);
const StyledPullRequestIcon = styled(PullRequestIcon)`
margin-top: 2px;
`;
export default HoverPreviewPullRequest;
+74
View File
@@ -0,0 +1,74 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
*/
export function IssueStatusIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
</svg>
);
case "canceled":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
</svg>
);
default:
return null;
}
}
+72
View File
@@ -0,0 +1,72 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub pull requests, but can be used for any git-style integration.
*/
export function PullRequestIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
case "merged":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
</svg>
);
default:
return null;
}
}
+1 -6
View File
@@ -45,10 +45,6 @@ export const NativeInput = styled.input<{
${ellipsis()}
${undraggableOnDesktop()}
&[readOnly] {
color: ${s("textSecondary")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -130,14 +126,13 @@ export interface Props
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
type?: "text" | "email" | "checkbox" | "search" | "textarea";
labelHidden?: boolean;
label?: string;
flex?: boolean;
short?: boolean;
margin?: string | number;
error?: string;
rows?: number;
/** Optional component that appears inside the input before the textarea and any icon */
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
+26
View File
@@ -0,0 +1,26 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
type Props = {
children?: React.ReactNode;
label: React.ReactNode | string;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
</Flex>
);
export const Label = styled(Flex)`
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${s("text")};
`;
export default observer(Labeled);
-3
View File
@@ -114,8 +114,6 @@ const Modal: React.FC<Props> = ({
<Small {...props}>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
style={{ maxHeight: "65vh" }}
column
reverse
>
@@ -261,7 +259,6 @@ const Small = styled.div`
width: 75vw;
min-width: 350px;
max-width: 450px;
max-height: 65vh;
z-index: ${depths.modal};
display: flex;
justify-content: center;
@@ -79,11 +79,11 @@ function Notifications(
</Header>
<React.Suspense fallback={null}>
<Scrollable ref={ref} flex topShadow>
<PaginatedList<Notification>
<PaginatedList
fetch={notifications.fetchPage}
options={{ archived: false }}
items={isOpen ? notifications.orderedData : undefined}
renderItem={(item) => (
renderItem={(item: Notification) => (
<NotificationListItem
key={item.id}
notification={item}
+3 -3
View File
@@ -10,7 +10,7 @@ type Props = {
fetch: (options: any) => Promise<Document[] | undefined>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
empty?: React.ReactNode;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
@@ -34,7 +34,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
const { t } = useTranslation();
return (
<PaginatedList<Document>
<PaginatedList
aria-label={t("Documents")}
items={documents}
empty={empty}
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item, _index) => (
renderItem={(item: Document, _index) => (
<DocumentListItem
key={item.id}
document={item}
+1 -1
View File
@@ -10,7 +10,7 @@ type Props = {
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
empty?: React.ReactNode;
};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
+15 -23
View File
@@ -1,15 +1,13 @@
import "../stores";
import { render } from "@testing-library/react";
import { TFunction } from "i18next";
import { Provider } from "mobx-react";
import * as React from "react";
import { getI18n } from "react-i18next";
import { Pagination } from "@shared/constants";
import PaginatedList from "./PaginatedList";
import { Component as PaginatedList } from "./PaginatedList";
describe("PaginatedList", () => {
const i18n = getI18n();
const authStore = {};
const props = {
i18n,
@@ -19,23 +17,19 @@ describe("PaginatedList", () => {
it("with no items renders nothing", () => {
const result = render(
<Provider auth={authStore}>
<PaginatedList items={[]} renderItem={render} {...props} />
</Provider>
<PaginatedList items={[]} renderItem={render} {...props} />
);
expect(result.container.innerHTML).toEqual("");
});
it("with no items renders empty prop", async () => {
const result = render(
<Provider auth={authStore}>
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>{" "}
</Provider>
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>
);
await expect(
result.findAllByText("Sorry, no results")
@@ -48,15 +42,13 @@ describe("PaginatedList", () => {
id: "one",
};
render(
<Provider auth={authStore}>
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>{" "}
</Provider>
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>
);
expect(fetch).toHaveBeenCalledWith({
...options,
+194 -244
View File
@@ -1,315 +1,265 @@
import isEqual from "lodash/isEqual";
import { observable, action, computed } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePrevious from "~/hooks/usePrevious";
import withStores from "~/components/withStores";
import { dateToHeading } from "~/utils/date";
/**
* Base interface for items that can be paginated
* @interface PaginatedItem
*/
export interface PaginatedItem {
/** Unique identifier for the item */
id?: string;
/** Last update timestamp of the item */
updatedAt?: string;
/** Creation timestamp of the item */
createdAt?: string;
}
/**
* Props for the PaginatedList component
* @template T Type of items in the list, must extend PaginatedItem
*/
interface Props<T extends PaginatedItem>
extends React.HTMLAttributes<HTMLDivElement> {
/**
* Function to fetch paginated data. Should return a promise resolving to an array of items
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
type Props<T> = WithTranslation &
RootStore &
React.HTMLAttributes<HTMLDivElement> & {
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
className?: string;
renderItem: (item: T, index: number) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
listRef?: React.RefObject<HTMLDivElement>;
};
/** Additional options to pass to the fetch function */
options?: Record<string, any>;
@observer
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
Props<T>
> {
@observable
error?: Error;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@observable
isFetchingMore = false;
/** Content to display when the list is empty */
empty?: JSX.Element | null;
@observable
isFetching = false;
/** Optional loading state content */
loading?: JSX.Element | null;
@observable
isFetchingInitial = !this.props.items?.length;
/** Array of items to display in the list */
items?: T[];
@observable
fetchCounter = 0;
/** CSS class name to apply to the list container */
className?: string;
@observable
renderCount = Pagination.defaultLimit;
/**
* Function to render each individual item in the list
* @param item The item to render
* @param index The index of the item in the list
*/
renderItem: (item: T, index: number) => React.ReactNode;
@observable
offset = 0;
/**
* Function to render error state
* @param options Object containing error details and retry function
*/
renderError?: (options: {
/** Details of the error */
error: Error;
/** Function to retry the fetch operation */
retry: () => void;
}) => JSX.Element;
@observable
allowLoadMore = true;
/**
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
componentDidMount() {
void this.fetchResults();
}
/**
* Handler for escape key press
* @param ev Keyboard event object
*/
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
componentDidUpdate(prevProps: Props<T>) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
) {
this.reset();
void this.fetchResults();
}
}
/** Reference to the list container element */
listRef?: React.RefObject<HTMLDivElement>;
}
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
this.renderCount = Pagination.defaultLimit;
this.isFetching = false;
this.isFetchingInitial = false;
this.isFetchingMore = false;
};
/**
* A reusable component that renders a paginated list with infinite scrolling
* and optional date-based section headings.
*
* @template T Type of the list items, must extend PaginatedItem
*/
const PaginatedList = <T extends PaginatedItem>({
fetch,
options,
heading,
empty = null,
loading = null,
items = [],
className,
renderItem,
renderError,
renderHeading,
onEscape,
listRef,
...rest
}: Props<T>): JSX.Element | null => {
const user = useCurrentUser({ rejectOnEmpty: false });
const { t } = useTranslation();
const [error, setError] = React.useState<Error | undefined>();
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const [isFetchingInitial, setIsFetchingInitial] = React.useState(
!items?.length
);
const [fetchCounter, setFetchCounter] = React.useState(0);
const [renderCount, setRenderCount] = React.useState(Pagination.defaultLimit);
const [offset, setOffset] = React.useState(0);
const [allowLoadMore, setAllowLoadMore] = React.useState(true);
const reset = React.useCallback(() => {
setOffset(0);
setAllowLoadMore(true);
setRenderCount(Pagination.defaultLimit);
setIsFetching(false);
setIsFetchingInitial(false);
setIsFetchingMore(false);
}, []);
const fetchResults = React.useCallback(async () => {
if (!fetch) {
@action
fetchResults = async () => {
if (!this.props.fetch) {
return;
}
setIsFetching(true);
const counter = fetchCounter + 1;
setFetchCounter(counter);
const limit = options?.limit ?? Pagination.defaultLimit;
setError(undefined);
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = this.props.options?.limit ?? Pagination.defaultLimit;
this.error = undefined;
try {
const results = await fetch({
const results = await this.props.fetch({
limit,
offset,
...options,
offset: this.offset,
...this.props.options,
});
if (offset !== 0) {
setRenderCount((prevCount) => prevCount + limit);
if (this.offset !== 0) {
this.renderCount += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
setAllowLoadMore(false);
this.allowLoadMore = false;
} else {
setOffset((prevOffset) => prevOffset + limit);
this.offset += limit;
}
setIsFetchingInitial(false);
this.isFetchingInitial = false;
} catch (err) {
setError(err);
this.error = err;
} finally {
// only the most recent fetch should end the loading state
if (counter >= fetchCounter) {
setIsFetching(false);
setIsFetchingMore(false);
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
}
}, [fetch, fetchCounter, offset, options]);
};
const loadMoreResults = React.useCallback(async () => {
// Don't paginate if there aren't more results or we're currently fetching
if (!allowLoadMore || isFetching) {
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were currently fetching
if (!this.allowLoadMore || this.isFetching) {
return;
}
// If there are already cached results that we haven't yet rendered because
// of lazy rendering then show another page.
const leftToRender = (items?.length ?? 0) - renderCount;
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
if (leftToRender > 0) {
setRenderCount((prevCount) => prevCount + Pagination.defaultLimit);
this.renderCount += Pagination.defaultLimit;
}
// If there are less than a pages results in the cache go ahead and fetch
// another page from the server
if (leftToRender <= Pagination.defaultLimit) {
setIsFetchingMore(true);
await fetchResults();
this.isFetchingMore = true;
await this.fetchResults();
}
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
};
const prevFetch = usePrevious(fetch);
const prevOptions = usePrevious(options);
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
// Initial fetch on mount
React.useEffect(() => {
if (fetch) {
void fetchResults();
}
}, [fetch]);
render() {
const {
items = [],
heading,
auth,
empty = null,
renderHeading,
renderError,
onEscape,
} = this.props;
// Handle updates to fetch or options
React.useEffect(() => {
if (!prevFetch || !prevOptions) {
return; // Skip on initial mount since it's handled by the above effect
const showLoading =
this.isFetching &&
!this.isFetchingMore &&
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
if (showLoading) {
return (
this.props.loading || (
<DelayedMount>
<div className={this.props.className}>
<PlaceholderList count={5} />
</div>
</DelayedMount>
)
);
}
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
reset();
void fetchResults();
if (items?.length === 0) {
if (this.error && renderError) {
return renderError({ error: this.error, retry: this.fetchResults });
}
return empty;
}
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
// Computed property equivalent
const itemsToRender = React.useMemo(
() => items?.slice(0, renderCount) ?? [],
[items, renderCount]
);
const showLoading =
isFetching &&
!isFetchingMore &&
(!items?.length || (fetchCounter <= 1 && isFetchingInitial));
if (showLoading) {
return (
loading || (
<DelayedMount>
<div className={className}>
<PlaceholderList count={5} />
<>
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
items={this.itemsToRender}
ref={this.props.listRef}
>
{() => {
let previousHeading = "";
return this.itemsToRender.map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (
children &&
(!previousHeading || currentHeading !== previousHeading)
) {
previousHeading = currentHeading;
return (
<React.Fragment
key={"id" in item && item.id ? item.id : index}
>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
});
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
</div>
</DelayedMount>
)
)}
</>
);
}
}
if (items?.length === 0) {
if (error && renderError) {
return renderError({ error, retry: fetchResults });
}
export const Component = PaginatedList;
return empty;
}
return (
<React.Fragment>
{heading}
<ArrowKeyNavigation
aria-label={rest["aria-label"]}
onEscape={onEscape}
className={className}
items={itemsToRender}
ref={listRef}
>
{() => {
let previousHeading = "";
return itemsToRender.map((item, index) => {
const children = renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
t,
user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (
children &&
(!previousHeading || currentHeading !== previousHeading)
) {
previousHeading = currentHeading;
return (
<React.Fragment key={"id" in item && item.id ? item.id : index}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
});
}}
</ArrowKeyNavigation>
{allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={renderCount} onEnter={loadMoreResults} />
</div>
)}
</React.Fragment>
);
};
export default PaginatedList;
export default withTranslation()(withStores(PaginatedList));
+2 -2
View File
@@ -200,7 +200,7 @@ function SearchPopover({ shareId, className }: Props) {
style={{ zIndex: depths.sidebar + 1 }}
shrink
>
<PaginatedList<SearchResult>
<PaginatedList
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
@@ -209,7 +209,7 @@ function SearchPopover({ shareId, className }: Props) {
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item, index) => (
renderItem={(item: SearchResult, index) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
+6 -20
View File
@@ -54,8 +54,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
@@ -116,10 +114,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerActivity = React.useCallback(() => {
if (ui.sidebarIsClosed) {
// clear the timeout when mouse exits
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
setHovering(document.hasFocus());
setPointerMoved(true);
}
@@ -128,20 +122,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerLeave = React.useCallback(
(ev) => {
if (hasPointerMoved) {
// clear any previous timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// add a short delay when mouse exits the sidebar before closing
hoverTimeoutRef.current = setTimeout(() => {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}, 500);
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}
},
[width, hasPointerMoved]
@@ -82,12 +82,12 @@ function ArchiveLink() {
</div>
{expanded === true ? (
<Relative>
<PaginatedList<Collection>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item) => (
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
@@ -54,7 +54,7 @@ function Collections() {
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList<Collection>
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.allActive}
@@ -69,7 +69,7 @@ function Collections() {
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item, index) => (
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
@@ -148,12 +148,7 @@ function InnerDocumentLink(
const color = document?.color || node.color;
// Draggable
const [{ isDragging }, drag] = useDragDocument(
node,
depth,
document,
isEditing
);
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
// Drop to re-parent
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -275,8 +270,6 @@ function InnerDocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
@@ -292,7 +285,6 @@ function InnerDocumentLink(
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
+6 -12
View File
@@ -39,7 +39,6 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
location?: Location;
strict?: boolean;
to: LocationDescriptor;
component?: React.ComponentType;
onBeforeClick?: () => void;
}
@@ -147,22 +146,17 @@ const NavLink = ({
setPreActive(undefined);
}, [currentLocation]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
},
[navigateTo]
);
return (
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
onClick={handleClick}
onKeyDown={handleKeyDown}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -166,13 +166,11 @@ export function useDropToReorderStar(getIndex?: () => string) {
* @param node The NavigationNode model to drag.
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
* @param isEditing Whether the sidebar item is currently being edited.
*/
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document,
isEditing?: boolean
document?: Document
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
@@ -190,7 +188,7 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
canDrag: () => !!document?.isActive && !isEditing,
canDrag: () => !!document?.isActive,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
+1 -3
View File
@@ -335,7 +335,6 @@ const TR = styled.div<{ $columns: string }>`
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
overflow: hidden;
&:last-child {
border-bottom: 0;
@@ -358,8 +357,7 @@ const TD = styled.span`
padding: 10px 6px;
font-size: 14px;
text-wrap: wrap;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
&:first-child {
font-size: 15px;
+71 -1
View File
@@ -1,3 +1,73 @@
import Text from "@shared/components/Text";
import styled, { css } from "styled-components";
import { ellipsis } from "@shared/styles";
type Props = {
/** The type of text to render */
type?: "secondary" | "tertiary" | "danger";
/** The size of the text */
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
/** The direction of the text (defaults to ltr) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
* Use this component for all interface text that should not be selectable
* by the user, this is the majority of UI text explainers, notes, headings.
*/
const Text = styled.span<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
: props.type === "tertiary"
? props.theme.textTertiary
: props.type === "danger"
? props.theme.brand.red
: props.theme.text};
font-size: ${(props) =>
props.size === "xlarge"
? "26px"
: props.size === "large"
? "18px"
: props.size === "medium"
? "16px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
${(props) => props.ellipsis && ellipsis()}
`;
export default Text;
+1 -9
View File
@@ -10,7 +10,6 @@ import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
@@ -254,14 +253,7 @@ const LinkEditor: React.FC<Props> = ({
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
key={doc.id}
subtitle={
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
}
subtitle={doc.collection?.name}
title={doc.title}
icon={
doc.icon ? (
+3 -11
View File
@@ -11,7 +11,6 @@ import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import {
DocumentsSection,
@@ -58,7 +57,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users, collections])
}, [search, documents, users])
);
React.useEffect(() => {
@@ -69,7 +68,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
React.useEffect(() => {
if (actorId && !loading) {
const items: MentionItem[] = users
const items = users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
@@ -113,14 +112,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
<DocumentIcon />
),
title: doc.title,
subtitle: (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
),
subtitle: doc.collection?.name,
section: DocumentsSection,
appendSpace: true,
attrs: {
+24 -60
View File
@@ -1,16 +1,7 @@
import { observer } from "mobx-react";
import { EmailIcon, LinkIcon } from "outline-icons";
import { LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isUrl } from "@shared/utils/urls";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -24,65 +15,34 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const PasteMenu = ({ embeds, ...props }: Props) => {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
if (pastedText && isUrl(pastedText)) {
const url = new URL(pastedText);
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
}
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(pastedText);
const matches = e.matcher(props.pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, pastedText]);
}, [embeds, props.pastedText]);
const items = React.useMemo(
() =>
[
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "mention",
title: t("Mention"),
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: v4(),
actorId: user?.id,
},
appendSpace: true,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
] satisfies MenuItem[],
[t, embed, mentionType, pastedText, user]
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
);
return (
@@ -92,7 +52,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
onClick={() => {
props.onSelect?.(item);
}}
selected={options.selected}
title={item.title}
icon={item.icon}
@@ -101,4 +63,6 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
items={items}
/>
);
});
};
export default PasteMenu;
+1 -1
View File
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
if ((readOnly && !canComment) || isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
@@ -1,6 +1,5 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { isList } from "@shared/editor/queries/isList";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
@@ -19,25 +18,17 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice, view) => {
clipboardTextSerializer: (slice) => {
const isMultiline = slice.content.childCount > 1;
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const hasMultipleListItems = slice.content.content
.filter((node) => node.content.content.length > 1)
.some((node) => isList(node, view.state.schema));
const hasMultipleBlockTypes =
[
...new Set(
slice.content.content
.filter((node) => node.content.content.length > 1)
.map((node) => node.type.name)
),
].length > 1;
const copyAsMarkdown =
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
isMultiline ||
slice.content.content.some(
(node) => node.content.content.length > 1
);
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
+13 -20
View File
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import stores from "~/stores";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
unfurlId: string | null;
data: Record<string, any> | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
unfurlId: null,
data: null,
dataLoading: false,
});
@@ -62,25 +62,19 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
this.state.activeLinkElement = element;
this.state.unfurlId = transformedUrl;
} else {
this.state.data = data;
} catch (err) {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -107,11 +101,10 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
unfurlId={this.state.unfurlId}
data={this.state.data}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
+1 -21
View File
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import { PasteMenu } from "../components/PasteMenu";
import PasteMenu from "../components/PasteMenu";
export default class PasteHandler extends Extension {
state: {
@@ -415,21 +415,6 @@ export default class PasteHandler extends Extension {
});
};
private insertMention = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
// Remove just the placeholder here.
// Mention node will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
@@ -465,11 +450,6 @@ export default class PasteHandler extends Extension {
this.insertEmbed();
break;
}
case "mention": {
this.hidePasteMenu();
this.insertMention();
break;
}
default:
break;
}
+18 -2
View File
@@ -1,3 +1,19 @@
import useIsMounted from "@shared/hooks/useIsMounted";
import * as React from "react";
export default useIsMounted;
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export default function useIsMounted() {
const isMounted = React.useRef(false);
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return React.useCallback(() => isMounted.current, []);
}
+10
View File
@@ -8,6 +8,7 @@ import {
GlobeIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
SettingsIcon,
ExportIcon,
ImportIcon,
@@ -39,6 +40,7 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
@@ -175,6 +177,14 @@ const useSettingsConfig = () => {
icon: ExportIcon,
},
// Integrations
{
name: t("Self Hosted"),
path: integrationSettingsPath("self-hosted"),
component: SelfHosted,
enabled: can.update && !isCloudHosted,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
-19
View File
@@ -17,7 +17,6 @@ import {
NavigationNodeType,
NotificationEventType,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
@@ -664,24 +663,6 @@ export default class Document extends ArchivableModel implements Searchable {
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
toPlainText = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data),
schema
);
return text;
};
download = (contentType: ExportContentType) =>
client.post(
`/documents.export`,
+3 -3
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import {
import type {
IntegrationService,
type IntegrationSettings,
type IntegrationType,
IntegrationSettings,
IntegrationType,
} from "@shared/types";
import User from "~/models/User";
import Model from "~/models/base/Model";
-18
View File
@@ -1,18 +0,0 @@
import { observable } from "mobx";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Model from "./base/Model";
class Unfurl<UnfurlType extends UnfurlResourceType> extends Model {
static modelName = "Unfurl";
@observable
type: UnfurlType;
@observable
data: UnfurlResponse[UnfurlType];
@observable
fetchedAt: string;
}
export default Unfurl;
+2 -2
View File
@@ -144,10 +144,10 @@ function Insights() {
small
/>
)}
<PaginatedList<User>
<PaginatedList
aria-label={t("Contributors")}
items={document.collaborators}
renderItem={(model) => (
renderItem={(model: User) => (
<ListItem
key={model.id}
title={model.name}
-2
View File
@@ -209,9 +209,7 @@ function Invite({ onSubmit }: Props) {
placeholder={`name@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoComplete="off"
autoFocus
data-1p-ignore
flex
/>
<StyledInput
+1 -4
View File
@@ -39,10 +39,7 @@ export function LoginDialog() {
maxLength={255}
autoComplete="off"
placeholder={t("subdomain")}
{...register("subdomain", {
required: true,
pattern: /^[a-z\d-]{1,63}$/,
})}
{...register("subdomain", { required: true, pattern: /^[a-z\d-]+$/ })}
>
<Domain>.getoutline.com</Domain>
</Input>
+1 -10
View File
@@ -3,15 +3,6 @@ import { parseDomain } from "@shared/utils/domains";
import env from "~/env";
import Desktop from "~/utils/Desktop";
function validateAndEncodeSubdomain(subdomain: string): string {
const encodedSubdomain = encodeURIComponent(subdomain);
const urlPattern = /^[a-z\d-]{1,63}$/;
if (!urlPattern.test(encodedSubdomain)) {
throw new Error("Invalid subdomain");
}
return `https://${encodedSubdomain}.getoutline.com`;
}
/**
* If we're on a custom domain or a subdomain then the auth must point to the
* apex (env.URL) for authentication so that the state cookie can be set and read.
@@ -45,7 +36,7 @@ export async function navigateToSubdomain(subdomain: string) {
.toLowerCase()
.trim()
.replace(/^https?:\/\//, "");
const host = validateAndEncodeSubdomain(normalizedSubdomain);
const host = `https://${normalizedSubdomain}.getoutline.com`;
await Desktop.bridge?.addCustomHost(host);
window.location.href = host;
}
+2 -2
View File
@@ -58,11 +58,11 @@ function ApiKeys() {
}}
/>
</Text>
<PaginatedList<ApiKey>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<h2>{t("All")}</h2>}
renderItem={(apiKey) => (
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
+2 -2
View File
@@ -48,7 +48,7 @@ function Export() {
{t("Export data")}
</Button>
<br />
<PaginatedList<FileOperation>
<PaginatedList
items={fileOperations.exports}
fetch={fileOperations.fetchPage}
options={{
@@ -59,7 +59,7 @@ function Export() {
<Trans>Recent exports</Trans>
</h2>
}
renderItem={(item) => (
renderItem={(item: FileOperation) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>
+2 -2
View File
@@ -183,7 +183,7 @@ function Import() {
))}
</div>
<br />
<PaginatedList<ImportModel | FileOperation>
<PaginatedList
items={allImports}
fetch={fetchImports}
heading={
@@ -191,7 +191,7 @@ function Import() {
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item) =>
renderItem={(item: ImportModel | FileOperation) =>
item instanceof ImportModel ? (
<ImportListItem key={item.id} importModel={item} />
) : (
+2 -2
View File
@@ -61,12 +61,12 @@ function PersonalApiKeys() {
}}
/>
</Text>
<PaginatedList<ApiKey>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey) => (
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
+140
View File
@@ -0,0 +1,140 @@
import find from "lodash/find";
import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IntegrationService, IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import useStores from "~/hooks/useStores";
import SettingRow from "./components/SettingRow";
type FormData = {
drawIoUrl: string;
gristUrl: string;
};
function SelfHosted() {
const { integrations } = useStores();
const { t } = useTranslation();
const integrationDiagrams = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined;
const integrationGrist = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Grist,
}) as Integration<IntegrationType.Embed> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Embed,
});
}, [integrations]);
React.useEffect(() => {
reset({
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
});
}, [integrationDiagrams, integrationGrist, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.drawIoUrl) {
await integrations.save({
id: integrationDiagrams?.id,
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
settings: {
url: data.drawIoUrl,
},
});
} else {
await integrationDiagrams?.delete();
}
if (data.gristUrl) {
await integrations.save({
id: integrationGrist?.id,
type: IntegrationType.Embed,
service: IntegrationService.Grist,
settings: {
url: data.gristUrl,
},
});
} else {
await integrationGrist?.delete();
}
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[integrations, integrationDiagrams, integrationGrist, t]
);
return (
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
<Heading>{t("Self Hosted")}</Heading>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Draw.io deployment")}
name="drawIoUrl"
description={t(
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
)}
border={false}
>
<Input
placeholder="https://app.diagrams.net/"
pattern="https?://.*"
{...register("drawIoUrl")}
/>
</SettingRow>
<SettingRow
label={t("Grist deployment")}
name="gristUrl"
description={t("Add your self-hosted grist installation URL here.")}
border={false}
>
<Input
placeholder="https://docs.getgrist.com/"
pattern="https?://.*"
{...register("gristUrl")}
/>
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(SelfHosted);
@@ -4,7 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import {
FileOperationFormat,
FileOperationState,
@@ -14,6 +13,7 @@ import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -268,14 +268,14 @@ export const ViewGroupMembersDialog = observer(function ({
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList<User>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(user) => (
renderItem={(user: User) => (
<GroupMemberListItem
key={user.id}
user={user}
@@ -382,7 +382,7 @@ const AddPeopleToGroupDialog = observer(function ({
<PlaceholderList count={5} />
</DelayedMount>
) : (
<PaginatedList<User>
<PaginatedList
empty={
query ? (
<Empty>{t("No people matching your search")}</Empty>
@@ -392,7 +392,7 @@ const AddPeopleToGroupDialog = observer(function ({
}
items={users.notInGroup(group.id, query)}
fetch={query ? undefined : users.fetchPage}
renderItem={(item) => (
renderItem={(item: User) => (
<GroupMemberListItem
key={item.id}
user={item}
+123 -153
View File
@@ -1,18 +1,19 @@
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useState, useRef } from "react";
import AvatarEditor from "react-avatar-editor";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import Dropzone from "react-dropzone";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { AttachmentValidation } from "@shared/validations";
import ButtonLarge from "~/components/ButtonLarge";
import RootStore from "~/stores/RootStore";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import useStores from "~/hooks/useStores";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
@@ -23,167 +24,132 @@ export type Props = {
borderRadius?: number;
};
const ImageUpload: React.FC<Props> = ({
onSuccess,
onError,
submitText,
borderRadius,
children,
}) => {
const { dialogs } = useStores();
const { t } = useTranslation();
@observer
class ImageUpload extends React.Component<RootStore & Props> {
@observable
isUploading = false;
const [isUploading, setIsUploading] = useState(false);
const [isCropping, setIsCropping] = useState(false);
@observable
isCropping = false;
const uploadImage = React.useCallback(
async (blob: Blob, file: File) => {
try {
const compressed = await compressImage(blob, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Avatar,
});
void onSuccess(attachment.url);
} catch (err) {
onError(err.message);
} finally {
setIsUploading(false);
setIsCropping(false);
dialogs.closeAllModals();
}
},
[dialogs, onSuccess, onError]
);
@observable
zoom = 1;
const handleUpload = React.useCallback(
(blob: Blob, file: File) => {
setIsUploading(true);
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(() => uploadImage(blob, file), 0);
},
[uploadImage]
);
@observable
file: File;
const handleClose = React.useCallback(() => {
setIsUploading(false);
setIsCropping(false);
}, []);
avatarEditorRef = React.createRef<AvatarEditor>();
const onDropAccepted = React.useCallback(
async (files: File[]) => {
setIsCropping(true);
dialogs.openModal({
title: "",
content: (
<AvatarEditorDialog
file={files[0]}
onUpload={handleUpload}
isUploading={isUploading}
borderRadius={borderRadius ?? 150}
submitText={submitText || t("Crop image")}
/>
),
onClose: handleClose,
static defaultProps = {
submitText: "Crop Image",
borderRadius: 150,
};
onDropAccepted = async (files: File[]) => {
this.isCropping = true;
this.file = files[0];
};
handleCrop = () => {
this.isUploading = true;
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(this.uploadImage, 0);
};
uploadImage = async () => {
const canvas = this.avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const imageBlob = dataUrlToBlob(canvas.toDataURL());
try {
const compressed = await compressImage(imageBlob, {
maxHeight: 512,
maxWidth: 512,
});
},
[
t,
dialogs,
handleUpload,
handleClose,
isUploading,
borderRadius,
submitText,
]
);
const attachment = await uploadFile(compressed, {
name: this.file.name,
preset: AttachmentPreset.Avatar,
});
void this.props.onSuccess(attachment.url);
} catch (err) {
this.props.onError(err.message);
} finally {
this.isUploading = false;
this.isCropping = false;
}
};
const { getRootProps, getInputProps } = useDropzone({
accept: AttachmentValidation.avatarContentTypes.join(", "),
onDropAccepted,
});
handleClose = () => {
this.isUploading = false;
this.isCropping = false;
};
if (isCropping) {
return null; // onDropAccepted would have opened a modal for cropping the image.
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
this.zoom = parseFloat(target.value);
}
};
renderCropping() {
const { ui, submitText } = this.props;
return (
<Modal isOpen onRequestClose={this.handleClose} title="">
<Flex auto column align="center" justify="center">
{this.isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={this.avatarEditorRef}
image={this.file}
width={250}
height={250}
border={25}
borderRadius={this.props.borderRadius}
color={
ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]
} // RGBA
scale={this.zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={this.handleZoom}
/>
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
{this.isUploading ? "Uploading…" : submitText}
</CropButton>
</Flex>
</Modal>
);
}
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
{children}
</div>
);
};
type AvatarEditorDialogProps = {
file: File;
onUpload: (blob: Blob, file: File) => void;
isUploading: boolean;
borderRadius: number;
submitText: string;
};
const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
({ file, onUpload, isUploading, borderRadius, submitText }) => {
const { ui } = useStores();
const { t } = useTranslation();
const [zoom, setZoom] = useState(1);
const avatarEditorRef = useRef<AvatarEditor>(null);
const handleUpload = React.useCallback(() => {
const canvas = avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const blob = dataUrlToBlob(canvas.toDataURL());
onUpload(blob, file);
}, [file, onUpload]);
const handleZoom = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
setZoom(parseFloat(target.value));
}
},
[]
);
render() {
if (this.isCropping) {
return this.renderCropping();
}
return (
<Flex auto column align="center" justify="center">
{isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={avatarEditorRef}
image={file}
width={250}
height={250}
border={25}
borderRadius={borderRadius}
color={ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]} // RGBA
scale={zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={handleZoom}
/>
<br />
<ButtonLarge fullwidth onClick={handleUpload} disabled={isUploading}>
{isUploading ? `${t(`Uploading`)}` : submitText}
</ButtonLarge>
</Flex>
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{this.props.children}
</div>
)}
</Dropzone>
);
}
);
}
const AvatarEditorContainer = styled(Flex)`
margin-bottom: 30px;
@@ -214,4 +180,8 @@ const RangeInput = styled.input`
}
`;
export default observer(ImageUpload);
const CropButton = styled(Button)`
width: 300px;
`;
export default withStores(ImageUpload);
@@ -5,12 +5,12 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import { ImportState } from "@shared/types";
import Import from "~/models/Import";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
+16 -28
View File
@@ -2,7 +2,6 @@ import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "@shared/components/Text";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -15,7 +14,6 @@ import {
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import UserMenu from "~/menus/UserMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
@@ -29,7 +27,6 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
export function MembersTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isMobile = useMobile();
const columns = React.useMemo<TableColumn<User>[]>(
() =>
@@ -41,20 +38,13 @@ export function MembersTable({ canManage, ...rest }: Props) {
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Large} />{" "}
<Flex column>
<Text>
{user.name} {currentUser.id === user.id && `(${t("You")})`}
</Text>
{isMobile && canManage && (
<Text type="tertiary">{user.email}</Text>
)}
</Flex>
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
</Flex>
),
width: "4fr",
},
canManage && !isMobile
canManage
? {
type: "data",
id: "email",
@@ -64,19 +54,17 @@ export function MembersTable({ canManage, ...rest }: Props) {
width: "4fr",
}
: undefined,
isMobile
? undefined
: {
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "role",
@@ -97,7 +85,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
),
width: "80px",
width: "2fr",
},
canManage
? {
@@ -109,7 +97,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
}
: undefined,
]),
[t, currentUser, canManage, isMobile]
[t, currentUser, canManage]
);
return (
+1 -1
View File
@@ -306,7 +306,7 @@ export default class AuthStore extends Store<Team> {
// if this logout was forced from an authenticated route then
// save the current path so we can go back there once signed in
if (savePath) {
setPostLoginPath(window.location.pathname + window.location.search);
setPostLoginPath(window.location.pathname);
}
if (tryRevokingToken) {
-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;
-3
View File
@@ -26,7 +26,6 @@ import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore";
import SubscriptionsStore from "./SubscriptionsStore";
import UiStore from "./UiStore";
import UnfurlsStore from "./UnfurlsStore";
import UserMembershipsStore from "./UserMembershipsStore";
import UsersStore from "./UsersStore";
import ViewsStore from "./ViewsStore";
@@ -56,7 +55,6 @@ export default class RootStore {
searches: SearchesStore;
shares: SharesStore;
ui: UiStore;
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore;
@@ -87,7 +85,6 @@ export default class RootStore {
this.registerStore(SharesStore);
this.registerStore(StarsStore);
this.registerStore(SubscriptionsStore);
this.registerStore(UnfurlsStore);
this.registerStore(UsersStore);
this.registerStore(ViewsStore);
this.registerStore(FileOperationsStore);
-85
View File
@@ -1,85 +0,0 @@
import { subMinutes } from "date-fns";
import { action } from "mobx";
import { UnfurlResourceType } from "@shared/types";
import Unfurl from "~/models/Unfurl";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import RootStore from "./RootStore";
import Store from "./base/Store";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class UnfurlsStore extends Store<Unfurl<any>> {
actions = []; // no default actions allowed for unfurls.
constructor(rootStore: RootStore) {
super(rootStore, Unfurl);
}
fetchUnfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
const unfurl = this.get(url);
if (unfurl) {
this.refetch({ unfurl: unfurl as Unfurl<UnfurlType>, documentId });
return unfurl;
}
return this.unfurl<UnfurlType>({ url, documentId });
};
private refetch = <UnfurlType extends UnfurlResourceType>({
unfurl,
documentId,
}: {
unfurl: Unfurl<UnfurlType>;
documentId?: string;
}) => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
if (new Date(unfurl.fetchedAt) < fiveMinutesAgo) {
void this.unfurl({ url: unfurl.id, documentId });
}
};
@action
private unfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
try {
this.isFetching = true;
const data = await client.post("/urls.unfurl", {
url,
documentId,
});
// unfurls can succeed with no data.
if (!data) {
return;
}
return this.add({
id: url,
type: data.type,
fetchedAt: new Date().toISOString(),
data,
} as Unfurl<UnfurlType>);
} catch (err) {
Logger.error(`Failed to unfurl url ${url}`, err);
return;
} finally {
this.isFetching = false;
}
};
}
export default UnfurlsStore;
+1 -9
View File
@@ -1,6 +1,5 @@
import commandScore from "command-score";
import invariant from "invariant";
// eslint-disable-next-line lodash/import-scope
import type { ObjectIterateeCustom } from "lodash";
import deburr from "lodash/deburr";
import filter from "lodash/filter";
@@ -99,14 +98,7 @@ export default abstract class Store<T extends Model> {
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
if (!normalized) {
return this.orderedData
.filter((item) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
return this.orderedData.slice(0, options?.maxResults);
}
return this.orderedData
-73
View File
@@ -1,73 +0,0 @@
import {
IntegrationService,
IntegrationSettings,
IntegrationType,
MentionType,
} from "@shared/types";
import Integration from "~/models/Integration";
export const isURLMentionable = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): boolean => {
const { hostname, pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "github.com" &&
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
);
}
case IntegrationService.Linear: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "linear.app" &&
settings.linear?.workspace.key === pathParts[1] // ensure installed workspace key matches with the provided url.
);
}
default:
return false;
}
};
export const determineMentionType = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): MentionType | undefined => {
const { pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const type = pathParts[3];
return type === "pull"
? MentionType.PullRequest
: type === "issues"
? MentionType.Issue
: undefined;
}
case IntegrationService.Linear: {
const type = pathParts[2];
return type === "issue" ? MentionType.Issue : undefined;
}
default:
return;
}
};
+3 -5
View File
@@ -49,10 +49,8 @@ export function redirectTo(url: string) {
/**
* Check if the path is a valid path for redirect after login.
*
* @param input A path potentially including query string
* @param path
* @returns boolean indicating if the path is a valid redirect
*/
export const isAllowedLoginRedirect = (input: string) => {
const path = input.split("?")[0];
return !["/", "/create", "/home", "/logout", "/auth/"].includes(path);
};
export const isAllowedLoginRedirect = (path: string) =>
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
+28 -29
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/lib-storage": "3.787.0",
"@aws-sdk/s3-presigned-post": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0",
"@aws-sdk/signature-v4-crt": "^3.787.0",
"@aws-sdk/client-s3": "3.777.0",
"@aws-sdk/lib-storage": "3.777.0",
"@aws-sdk/s3-presigned-post": "3.777.0",
"@aws-sdk/s3-request-presigner": "3.777.0",
"@aws-sdk/signature-v4-crt": "^3.775.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",
@@ -142,7 +141,7 @@
"jszip": "^3.10.1",
"katex": "^0.16.21",
"kbar": "0.1.0-beta.41",
"koa": "^2.16.1",
"koa": "^2.15.4",
"koa-body": "^6.0.1",
"koa-compress": "^5.1.1",
"koa-helmet": "^6.1.0",
@@ -154,7 +153,7 @@
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.2",
"mammoth": "^1.9.0",
"mammoth": "^1.8.0",
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
@@ -174,25 +173,25 @@
"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",
"prosemirror-inputrules": "^1.5.0",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.0",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.39.1",
"prosemirror-view": "^1.38.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
@@ -209,9 +208,9 @@
"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-virtualized-auto-sizer": "^1.0.21",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"reakit": "^1.3.11",
@@ -220,7 +219,7 @@
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
"resolve-path": "^1.4.0",
"rfc6902": "^5.1.2",
"rfc6902": "^5.1.1",
"sanitize-filename": "^1.6.3",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.7.1",
@@ -233,7 +232,7 @@
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.4",
"sonner": "^1.7.1",
"stoppable": "^1.1.0",
"string-replace-to-array": "^2.1.1",
"styled-components": "^5.3.11",
@@ -249,8 +248,8 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^6.3.3",
"vite-plugin-pwa": "^0.21.2",
"vite": "^5.4.16",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -297,7 +296,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.30",
"@types/node": "20.17.27",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -356,14 +355,14 @@
"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",
"typescript": "^5.7.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -375,8 +374,8 @@
"node-fetch": "^2.7.0",
"js-yaml": "^3.14.1",
"qs": "6.9.7",
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.83.0",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
"version": "0.82.0"
}
+1 -1
View File
@@ -94,7 +94,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
/** Default user and team names metadata */
let userName = profile.username;
let teamName;
let teamName = "Wiki";
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
let teamAvatarUrl: string | undefined = undefined;
let subdomain = slugifyDomain(domain);
@@ -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) {
@@ -2,7 +2,6 @@ import { APIResponseError, APIErrorCode } from "@notionhq/client";
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import ImportTask from "@server/models/ImportTask";
@@ -96,17 +95,12 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
client: NotionClient;
}): Promise<ParsePageOutput | null> {
const collectionExternalId = item.collectionExternalId ?? item.externalId;
const titleMaxLength =
item.externalId === collectionExternalId // This means it's a root page which will be imported as a collection
? CollectionValidation.maxNameLength
: DocumentValidation.maxTitleLength;
try {
// Convert Notion database to an empty page with "pages in database" as its children.
if (item.type === PageType.Database) {
const { pages, ...databaseInfo } = await client.fetchDatabase(
item.externalId,
{ titleMaxLength }
item.externalId
);
return {
@@ -121,9 +115,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
};
}
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId, {
titleMaxLength,
});
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
return {
...pageInfo,
+3 -13
View File
@@ -396,13 +396,11 @@ export class NotionConverter {
}
private static pdf(item: PdfBlockObjectResponse) {
const caption = item.pdf.caption.map(this.rich_text_to_plaintext).join("");
return {
type: "attachment",
attrs: {
href: "file" in item.pdf ? item.pdf.file.url : item.pdf.external.url,
title: caption,
title: item.pdf.caption.map(this.rich_text_to_plaintext).join(""),
},
};
}
@@ -438,10 +436,6 @@ export class NotionConverter {
}
private static image(item: ImageBlockObjectResponse) {
const caption = item.image.caption
.map(this.rich_text_to_plaintext)
.join("");
return {
type: "paragraph",
content: [
@@ -452,7 +446,7 @@ export class NotionConverter {
"file" in item.image
? item.image.file.url
: item.image.external.url,
alt: caption,
alt: item.image.caption.map(this.rich_text_to_plaintext).join(""),
},
},
],
@@ -573,16 +567,12 @@ export class NotionConverter {
}
private static video(item: VideoBlockObjectResponse) {
const caption = item.video.caption
.map(this.rich_text_to_plaintext)
.join("");
if (item.video.type === "file") {
return {
type: "video",
attrs: {
src: item.video.file.url,
title: caption,
title: item.video.caption.map(this.rich_text_to_plaintext).join(""),
},
};
}
+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} />
)}
/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

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