Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor 7dd3c96b14 doc 2024-12-03 21:59:46 -05:00
Tom Moor f98d1203ea fix: Made the document header components more responsive to the available space 2024-12-03 21:59:05 -05:00
903 changed files with 18681 additions and 50840 deletions
+183
View File
@@ -0,0 +1,183 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
resource_class: large
environment:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
<<: *defaults
environment:
NODE_ENV: production
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: build-vite
command: yarn vite:build
- run:
name: Send bundle stats to RelativeCI
command: npx relative-ci-agent
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- run:
name: Install Docker buildx
command: |
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
- run:
name: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
- run:
name: Initialize Docker buildx
command: |
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
workflows:
version: 2
all:
jobs:
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- build
- types
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
curl --user ${CIRCLE_TOKEN}: \
--request POST \
--form revision=<ENTER COMMIT SHA HERE>\
--form config=@config.yml \
--form notify=false \
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
-3
View File
@@ -1,8 +1,5 @@
URL=https://local.outline.dev:3000
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
REDIS_URL=redis://127.0.0.1:6379
SMTP_FROM_EMAIL=hello@example.com
# Enable unsafe-inline in script-src CSP directive
+7 -11
View File
@@ -12,14 +12,14 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@postgres:5432/outline
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://redis:6379
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
@@ -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/
#
@@ -151,10 +147,6 @@ DISCORD_SERVER_ID=
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
@@ -209,10 +201,14 @@ SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_SERVICE=
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
+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,
+37
View File
@@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots or videos to help explain your problem.
**Outline (please complete the following information):**
- Install: [getoutline.com or self hosted]
- Version: [commit sha if self hosted]
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Mobile (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
-63
View File
@@ -1,63 +0,0 @@
name: Bug report
description: File a bug to help us improve
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- Outline:
- Browser:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
-2
View File
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
-13
View File
@@ -13,16 +13,3 @@ updates:
update-types: ["version-update:semver-major"]
schedule:
interval: "weekly"
groups:
babel:
patterns:
- "@babel/*"
sentry:
patterns:
- "@sentry/*"
fortawesome:
patterns:
- "@fortawesome/*"
aws:
patterns:
- "@aws-sdk/*"
-165
View File
@@ -1,165 +0,0 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
NODE_ENV: test
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
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
SMTP_USERNAME: localhost
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn tsc
changes:
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
id: filter
with:
filters: |
server:
- 'server/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
app:
- 'app/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
test:
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14.2
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: outline_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:5.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
-212
View File
@@ -1,212 +0,0 @@
name: Docker
on:
push:
tags:
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
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 }}
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/arm64
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/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 }}
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 }}
-30
View File
@@ -1,30 +0,0 @@
name: Lint
on:
pull_request:
branches: [ main ]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: 'Applied automatic fixes'
+1 -1
View File
@@ -24,6 +24,6 @@ jobs:
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned,A1"
exempt-issue-labels: "security,pinned"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+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
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.83.0
The Licensed Work is (c) 2025 General Outline, Inc.
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 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
Service.
@@ -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: 2028-11-11
Change License: Apache License, Version 2.0
-4
View File
@@ -171,10 +171,6 @@
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_SERVICE": {
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
+15 -78
View File
@@ -8,13 +8,12 @@ import {
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
@@ -61,7 +60,7 @@ export const createCollection = createAction({
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event, stores }) => {
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
@@ -77,10 +76,10 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -103,10 +102,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -134,7 +133,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -149,7 +148,7 @@ export const searchInCollection = createAction({
},
perform: ({ activeCollectionId }) => {
history.push(searchPath({ collectionId: activeCollectionId }));
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
});
@@ -159,7 +158,7 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -169,7 +168,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -186,7 +185,7 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -196,7 +195,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -206,66 +205,6 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
@@ -338,13 +277,13 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
perform: ({ activeCollectionId, t }) => {
if (!activeCollectionId) {
return;
}
@@ -372,7 +311,7 @@ export const createTemplate = createAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
@@ -392,7 +331,5 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];
+2 -10
View File
@@ -53,13 +53,9 @@ export const resolveCommentFactory = ({
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
state: null,
});
onResolve();
@@ -85,13 +81,9 @@ export const unresolveCommentFactory = ({
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
state: null,
});
onUnresolve();
-34
View File
@@ -2,7 +2,6 @@ import copy from "copy-to-clipboard";
import {
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
@@ -84,38 +83,6 @@ export const copyId = createAction({
},
});
function generateRandomText() {
const characters =
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
let text = "";
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
text += characters.charAt(Math.floor(Math.random() * characters.length));
}
return text;
}
export const startTyping = createAction({
name: "Start automatic typing",
icon: <EditIcon />,
section: DeveloperSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && env.ENVIRONMENT === "development",
perform: () => {
const intervalId = setInterval(() => {
const text = generateRandomText();
document.execCommand("insertText", false, text);
}, 250);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
intervalId && clearInterval(intervalId);
}
});
toast.info("Automatic typing started, press Escape to stop");
},
});
export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"),
icon: <TrashIcon />,
@@ -202,7 +169,6 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
startTyping,
],
});
+10 -55
View File
@@ -29,11 +29,9 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import {
ExportContentType,
TeamPreference,
@@ -48,6 +46,7 @@ import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -126,20 +125,6 @@ export const createDocument = createAction({
}),
});
export const createDraftDocument = createAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
});
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
@@ -334,7 +319,6 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
@@ -363,9 +347,8 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -375,7 +358,7 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
await document?.unsubscribe();
await document?.unsubscribe(currentUserId);
toast.success(t("Unsubscribed from document notifications"));
},
@@ -511,25 +494,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 +539,7 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
@@ -708,7 +667,6 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -718,7 +676,7 @@ export const searchInDocument = createAction({
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath({ documentId: activeDocumentId }));
history.push(searchPath(undefined, { documentId: activeDocumentId }));
},
});
@@ -774,6 +732,7 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
throw err;
}
};
@@ -831,15 +790,15 @@ export const openRandomDocument = createAction({
},
});
export const searchDocumentsForQuery = (query: string) =>
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query })),
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -1221,8 +1180,6 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createDraftDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
importDocument,
@@ -1230,14 +1187,12 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
+1 -10
View File
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query: searchQuery.query })),
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToDrafts = createAction({
@@ -62,15 +62,6 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
-25
View File
@@ -1,25 +0,0 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createOAuthClient = createAction({
name: ({ t }) => t("New App"),
analyticsName: "New App",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New Application"),
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+2 -2
View File
@@ -11,7 +11,7 @@ import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map((session) => ({
id: `switch-${session.id}`,
name: session.name,
@@ -44,7 +44,7 @@ export const switchTeam = createAction({
section: TeamSection,
visible: ({ stores }) =>
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
children: switchTeamsList,
children: createTeamsList,
});
export const createTeam = createAction({
-6
View File
@@ -2,8 +2,6 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
@@ -15,8 +13,6 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
const activeDocument = stores.documents.active;
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
@@ -38,8 +34,6 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
+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);
+2 -15
View File
@@ -1,22 +1,15 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -55,7 +48,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -80,11 +72,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
+23 -28
View File
@@ -7,15 +7,9 @@ export enum AvatarSize {
Small = 16,
Toast = 18,
Medium = 24,
Large = 28,
XLarge = 32,
XXLarge = 48,
Upload = 64,
}
export enum AvatarVariant {
Round = "round",
Square = "square",
Large = 32,
XLarge = 48,
XXLarge = 64,
}
export interface IAvatar {
@@ -26,39 +20,36 @@ export interface IAvatar {
}
type Props = {
/** The size of the avatar */
size: AvatarSize;
/** The variant of the avatar */
variant?: AvatarVariant;
/** The source of the avatar image, if not passing a model. */
src?: string;
/** The avatar model, if not passing a source. */
model?: IAvatar;
/** The alt text for the image */
alt?: string;
/** Optional click handler */
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Optional class name */
className?: string;
/** Optional style */
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { model, style, variant = AvatarVariant.Round, ...rest } = props;
const { showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative style={style} $variant={variant} $size={props.size}>
<Relative style={style}>
{src && !error ? (
<Image onError={handleError} src={src} {...rest} />
<CircleImg
onError={handleError}
src={src}
$showBorder={showBorder}
{...rest}
/>
) : model ? (
<Initials color={model.color} {...rest}>
<Initials color={model.color} $showBorder={showBorder} {...rest}>
{model.initial}
</Initials>
) : (
<Initials {...rest} />
<Initials $showBorder={showBorder} {...rest} />
)}
</Relative>
);
@@ -68,19 +59,23 @@ Avatar.defaultProps = {
size: AvatarSize.Medium,
};
const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
const Relative = styled.div`
position: relative;
user-select: none;
flex-shrink: 0;
border-radius: ${(props) =>
props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`};
overflow: hidden;
`;
const Image = styled.img<{ size: number }>`
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
flex-shrink: 0;
overflow: hidden;
`;
export default Avatar;
+5 -53
View File
@@ -5,45 +5,17 @@ import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
import Avatar from "./Avatar";
/**
* Props for the AvatarWithPresence component
*/
type Props = {
/** The user to display the avatar for */
user: User;
/** Whether the user is currently present in the document */
isPresent: boolean;
/** Whether the user is currently editing the document */
isEditing: boolean;
/** Whether the user is currently observing the document */
isObserving: boolean;
/** Whether this avatar represents the current user */
isCurrentUser: boolean;
/** Optional click handler for the avatar */
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
/**
* AvatarWithPresence component displays a user's avatar with visual indicators
* for their current status (present, editing, observing).
*
* The component shows different visual states:
* - Present users have full opacity
* - Non-present users have reduced opacity
* - Observing users have a colored border matching their user color
* - Hovering shows a colored border
*
* A tooltip displays the user's name and current status.
*
* @param props - Component properties
* @returns React component
*/
function AvatarWithPresence({
onClick,
user,
@@ -51,8 +23,6 @@ function AvatarWithPresence({
isEditing,
isObserving,
isCurrentUser,
size = AvatarSize.Large,
style,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -77,47 +47,29 @@ function AvatarWithPresence({
}
placement="bottom"
>
<AvatarPresence
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={size} />
</AvatarPresence>
<Avatar model={user} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
}
/**
* Centered container for tooltip content
*/
const Centered = styled.div`
text-align: center;
`;
/**
* Props for the AvatarPresence styled component
*/
type AvatarWrapperProps = {
/** Whether the user is currently present */
$isPresent: boolean;
/** Whether the user is currently observing */
$isObserving: boolean;
/** The user's color for border highlighting */
$color: string;
};
/**
* Styled component that wraps the Avatar and provides visual indicators
* for the user's presence status.
*
* - Adjusts opacity based on presence
* - Adds colored borders for observing users
* - Handles hover effects
*/
const AvatarPresence = styled.div<AvatarWrapperProps>`
const AvatarWrapper = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
+9 -13
View File
@@ -1,31 +1,27 @@
import { getLuminance } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
/** The color of the background, defaults to textTertiary. */
color?: string;
/** Content is only used to calculate font size, use children to render. */
content?: string;
/** The size of the avatar */
size: number;
$showBorder?: boolean;
}>`
align-items: center;
justify-content: center;
border-radius: 50%;
width: 100%;
height: 100%;
color: ${(props) =>
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
? s("black50")
: s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
color: ${s("white75")};
background-color: ${(props) => props.color};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
// adjust font size down for each additional character
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
font-size: ${(props) => props.size / 2}px;
font-weight: 500;
`;
+1 -5
View File
@@ -37,11 +37,7 @@ function Breadcrumb(
return (
<Flex justify="flex-start" align="center" ref={ref}>
{topLevelItems.map((item, index) => (
<React.Fragment
key={
(typeof item.to === "string" ? item.to : item.to.pathname) || index
}
>
<React.Fragment key={String(item.to) || index}>
{item.icon}
{item.to ? (
<Item
-4
View File
@@ -80,10 +80,6 @@ const RealButton = styled(ActionButton)<RealProps>`
} 0 0 0 1px inset;
}
&:focus-visible {
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
}
&:disabled {
color: ${props.theme.textTertiary};
background: none;
+36 -43
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import { AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
() =>
orderBy(
filter(
users.all,
users.orderedData,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[document.collaboratorIds, users.all, presentIds]
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't yet have in memory
@@ -78,56 +78,49 @@ function Collaborators(props: Props) {
placement: "bottom-end",
});
const renderAvatar = React.useCallback(
({ model: collaborator, ...rest }) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== currentUserId;
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
},
[presentIds, ui, currentUserId, editingIds]
);
return (
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
width={Math.min(collaborators.length, limit) * 32}
height={32}
{...popoverProps}
>
<Facepile
size={AvatarSize.Large}
limit={limit}
overflow={Math.max(0, collaborators.length - limit)}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={renderAvatar}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== user.id;
return (
<AvatarWithPresence
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
}}
/>
</NudeButton>
)}
+8 -32
View File
@@ -1,10 +1,8 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
@@ -13,17 +11,16 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
@@ -33,26 +30,6 @@ export interface FormData {
permission: CollectionPermission | undefined;
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
const hasMultipleCollections = collections.orderedData.length > 1;
const collectionColors = uniq(
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = React.useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
// otherwise pick a random color from the palette
(hasMultipleCollections && collectionColors.length === 1
? collectionColors[0]
: randomElement(colorPalette)),
[collection?.color]
);
return iconColor;
};
export const CollectionForm = observer(function CollectionForm_({
handleSubmit,
collection,
@@ -65,7 +42,11 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = useIconColor(collection);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
@@ -89,11 +70,6 @@ export const CollectionForm = observer(function CollectionForm_({
const values = watch();
// Preload the IconPicker component on mount
React.useEffect(() => {
void IconPicker.preload();
}, []);
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
@@ -208,7 +184,7 @@ export const CollectionForm = observer(function CollectionForm_({
);
});
const StyledIconPicker = styled(IconPicker.Component)`
const StyledIconPicker = styled(IconPicker)`
margin-left: 4px;
margin-right: 4px;
`;
+237
View File
@@ -0,0 +1,237 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
try {
await collection.save({
data: getValue(false),
});
setDirty(false);
} catch (err) {
toast.error(t("Sorry, an error occurred saving the collection"));
throw err;
}
}, 1000),
[collection, t]
);
const handleChange = React.useCallback(
async (getValue) => {
setDirty(true);
await handleSave(getValue);
},
[handleSave]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input data-editing={isEditing} data-expanded={isExpanded}>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense
fallback={
<Placeholder
onClick={() => {
//
}}
>
Loading
</Placeholder>
}
>
<Editor
key={key}
defaultValue={collection.data}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
/>
</React.Suspense>
) : (
can.update && (
<Placeholder
onClick={() => {
//
}}
>
{placeholder}
</Placeholder>
)
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
)}
</MaxHeight>
);
}
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${s("divider")};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${s("sidebarText")};
}
`;
const Placeholder = styled(ButtonLink)`
color: ${s("placeholder")};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: 8px -8px -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${s("background")} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${s("backgroundSecondary")};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);
+1 -2
View File
@@ -3,7 +3,6 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -71,7 +70,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
<Key key={key}>{key}</Key>
))}
</React.Fragment>
))}
@@ -1,6 +1,6 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
@@ -1,6 +1,6 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
+6 -25
View File
@@ -1,12 +1,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { AuthorizationError } from "~/utils/errors";
type Props = {
/** The navigation node to move, must represent a document. */
@@ -32,29 +30,12 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
};
const handleSubmit = async () => {
try {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
{
documentName: item.title,
collectionName: collection.name,
}
)
);
} else {
toast.error(err.message);
}
} finally {
dialogs.closeAllModals();
}
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
dialogs.closeAllModals();
};
return (
+3 -6
View File
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
/** Callback when the dialog is submitted. Return false to prevent closing. */
onSubmit: () => Promise<void | boolean> | void;
/** Callback when the dialog is submitted */
onSubmit: () => Promise<void> | void;
/** Text to display on the submit button */
submitText?: string;
/** Text to display while the form is saving */
@@ -38,10 +38,7 @@ const ConfirmationDialog: React.FC<Props> = ({
ev.preventDefault();
setIsSaving(true);
try {
const res = await onSubmit();
if (res === false) {
return;
}
await onSubmit();
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
+4 -10
View File
@@ -4,12 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -20,21 +14,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
[DocumentTooLarge.code]: {
1009: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
[AuthenticationFailed.code]: {
4401: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
[AuthorizationFailed.code]: {
4403: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
[TooManyConnections.code]: {
4503: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
+1 -9
View File
@@ -13,7 +13,6 @@ import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
@@ -24,7 +23,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
icon?: React.ReactElement;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
@@ -32,7 +31,6 @@ type Props = {
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
@@ -92,7 +90,6 @@ const MenuItem = (
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
@@ -112,8 +109,6 @@ const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
@@ -161,9 +156,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
+1 -10
View File
@@ -20,7 +20,6 @@ import {
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
@@ -168,7 +167,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "button") {
const menuItem = (
return (
<MenuItem
as="button"
id={`${item.title}-${index}`}
@@ -183,14 +182,6 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
);
}
if (item.type === "submenu") {
+29 -14
View File
@@ -2,11 +2,16 @@ import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -14,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -41,26 +47,36 @@ const DefaultCollectionInputSelect = ({
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
const options = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
type: "item",
label: collection.name,
label: (
<Flex align="center">
<IconWrapper>
<CollectionIcon collection={collection} />
</IconWrapper>
{collection.name}
</Flex>
),
value: collection.id,
icon: <CollectionIcon collection={collection} />,
},
],
[
{
type: "item",
label: t("Home"),
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
</IconWrapper>
{t("Home")}
</Flex>
),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
]
),
[collections.nonPrivate, t]
);
@@ -70,14 +86,13 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelectNew
options={options}
<InputSelect
value={defaultCollectionId ?? "home"}
options={options}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
{...rest}
/>
);
};
+1 -4
View File
@@ -23,10 +23,7 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
style={modal.style}
>
+23 -60
View File
@@ -3,28 +3,25 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import {
archivePath,
collectionPath,
settingsPath,
trashPath,
} from "~/utils/routeHelpers";
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,18 +58,16 @@ 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();
const { t } = useTranslation();
const category = useCategory(document);
const sidebarContext = useLocationSidebarContext();
const collection = document.collectionId
? 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 });
@@ -85,10 +80,7 @@ function DocumentBreadcrumb(
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: {
pathname: collection.path,
state: { sidebarContext },
},
to: collectionPath(collection.path),
};
} else if (document.isCollectionDeleted) {
collectionNode = {
@@ -99,76 +91,47 @@ 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) => {
const title = node.title || t("Untitled");
path.slice(0, -1).forEach((node: NavigationNode) => {
output.push({
type: "route",
title: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
<StyledIcon value={node.icon} color={node.color} /> {node.title}
</>
) : (
title
node.title
),
to: {
pathname: node.url,
state: { sidebarContext },
},
to: node.url,
});
});
return reverse
? depth !== undefined
? output.slice(-depth)
: output
: depth !== undefined
? output.slice(0, depth)
: output;
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
return output;
}, [path, category, 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 />}
{node.title || t("Untitled")}
{!showCollection && index !== slicedPath.length - 1 && (
<SmallSlash />
)}
<SmallSlash />
{node.title}
</React.Fragment>
))}
</>
+11 -32
View File
@@ -1,25 +1,24 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import Squircle from "@shared/components/Squircle";
import { s, hover, ellipsis } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -72,10 +71,6 @@ function DocumentCard(props: Props) {
[pin]
);
// If the document was updated within the last 7 days, show a timestamp instead of reading time
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
return (
<Reorderable
ref={setNodeRef}
@@ -148,14 +143,13 @@ function DocumentCard(props: Props) {
: document.titleWithDefault}
</Heading>
<DocumentMeta size="xsmall">
{isRecentlyUpdated ? (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
) : (
<ReadingTime document={document} />
)}
<Clock size={18} />
<Time
dateTime={document.updatedAt}
tooltipDelay={500}
addSuffix
shorten
/>
</DocumentMeta>
</div>
</Content>
@@ -176,21 +170,6 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
+5 -16
View File
@@ -14,18 +14,18 @@ import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { NavigationNode } from "@shared/types";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
import { ancestors, descendants } from "~/utils/tree";
type Props = {
@@ -78,10 +78,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const recentlyViewedItemIds = documents.recentlyViewed
.slice(0, 5)
.map((item) => item.id);
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
@@ -130,18 +126,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.filter((item) => item.type === "collection")
.flatMap(includeDescendants);
}
const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -315,7 +304,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={(node.depth ?? 0) - baseDepth}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+2 -2
View File
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
) {
const { t } = useTranslation();
const OFFSET = 12;
const DISCLOSURE = 20;
const ICON_SIZE = 24;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
return (
<Node
+8 -13
View File
@@ -9,22 +9,21 @@ import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
type Props = {
document: Document;
@@ -51,7 +50,6 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
@@ -80,12 +78,6 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
return (
<DocumentLink
ref={itemRef}
@@ -97,7 +89,6 @@ function DocumentListItem(
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
@@ -120,7 +111,11 @@ function DocumentListItem(
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Tooltip
content={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
+2 -6
View File
@@ -185,9 +185,9 @@ const DocumentMeta: React.FC<Props> = ({
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
<Strong>
<strong>
<DocumentBreadcrumb document={document} onlyText />
</Strong>
</strong>
</span>
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
@@ -210,10 +210,6 @@ const DocumentMeta: React.FC<Props> = ({
);
};
const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
+4 -10
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -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);
@@ -71,13 +71,7 @@ function DocumentViews({ document, isOpen }: Props) {
key={model.id}
title={model.name}
subtitle={subtitle}
image={
<Avatar
key={model.id}
model={model}
size={AvatarSize.Large}
/>
}
image={<Avatar key={model.id} model={model} size={32} />}
border={false}
small
/>
+100 -56
View File
@@ -1,4 +1,6 @@
import deburr from "lodash/deburr";
import difference from "lodash/difference";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
@@ -6,10 +8,11 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
@@ -19,8 +22,12 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
import Icon from "./Icon";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -43,23 +50,82 @@ export type Props = Optional<
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const { comments } = useStores();
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { comments, documents } = useStores();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousCommentIds = React.useRef<string[]>();
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = dateToRelative(Date.parse(document.updatedAt), {
addSuffix: true,
shorten: true,
locale,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
icon: document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
/>
) : undefined,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles({ query: term });
return sortBy(
results.map(({ document }) => ({
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : undefined,
})),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[locale, documents]
);
const handleUploadFile = React.useCallback(
async (file: File | string) => {
const options = {
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
preset: AttachmentPreset.DocumentAttachment,
};
const result =
file instanceof File
? await uploadFile(file, options)
: await uploadFileFromUrl(file, options);
});
return result.url;
},
[id]
@@ -185,54 +251,32 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[updateComments]
);
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
<p key={index} dir="auto">
{paragraph.content?.map((content) => content.text)}
</p>
))}
</div>
</EditorContainer>
) : (
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
)}
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
+5 -5
View File
@@ -1,9 +1,9 @@
import styled from "styled-components";
import Text from "~/components/Text";
import { s } from "@shared/styles";
const Empty = styled(Text).attrs({
type: "tertiary",
selectable: false,
})``;
const Empty = styled.p`
color: ${s("textTertiary")};
user-select: none;
`;
export default Empty;
+4 -5
View File
@@ -7,7 +7,6 @@ import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
@@ -78,9 +77,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Module failed to load")} />
<Heading>
<h1>
<Trans>Loading Failed</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
@@ -102,9 +101,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Something Unexpected Happened")} />
<Heading>
<h1>
<Trans>Something Unexpected Happened</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
+82 -158
View File
@@ -7,71 +7,38 @@ import {
PublishIcon,
MoveIcon,
UnpublishIcon,
RestoreIcon,
UserIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions } from "~/components/List/Item";
import Event from "~/models/Event";
import { Avatar } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { hover } from "~/styles";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
import Text from "./Text";
export type RevisionEvent = {
name: "revisions.create";
latest: boolean;
};
export type DocumentEvent = {
name:
| "documents.publish"
| "documents.unpublish"
| "documents.archive"
| "documents.unarchive"
| "documents.delete"
| "documents.restore"
| "documents.add_user"
| "documents.remove_user"
| "documents.move";
userId: string;
};
export type Event = { id: string; actorId: string; createdAt: string } & (
| RevisionEvent
| DocumentEvent
);
type Props = {
document: Document;
event: Event;
event: Event<Document>;
latest?: boolean;
};
const EventListItem = ({ event, document, ...rest }: Props) => {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions, users } = useStores();
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
const user = "userId" in event ? users.get(event.userId) : undefined;
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = React.useRef(false);
const opts = {
userName: actor?.name,
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = React.useRef<HTMLAnchorElement>(null);
@@ -82,85 +49,69 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
};
const prefetchRevision = async () => {
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
if (event.name === "revisions.create" && event.modelId) {
await revisions.fetch(event.modelId);
}
};
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = event.latest ? (
meta = latest ? (
<>
{t("Current version")} &middot; {actor?.name}
{t("Current version")} &middot; {event.actor.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
pathname: documentHistoryPath(document, event.modelId || "latest"),
state: { retainScrollPosition: true },
};
break;
case "documents.archive":
icon = <ArchiveIcon />;
icon = <ArchiveIcon size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
icon = <RestoreIcon />;
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon />;
icon = <TrashIcon size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.add_user":
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: user?.name ?? t("a user"),
addedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: user?.name ?? t("a user"),
removedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.restore":
icon = <RestoreIcon />;
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon />;
icon = <PublishIcon size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon />;
icon = <UnpublishIcon size={16} />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon />;
icon = <MoveIcon size={16} />;
meta = t("{{userName}} moved", opts);
break;
@@ -181,14 +132,15 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
to = undefined;
}
return event.name === "revisions.create" ? (
<RevisionItem
return (
<BaseItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={500}
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
@@ -198,12 +150,17 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
image={<Avatar model={event.actor} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision && isActive && !event.latest ? (
isRevision && isActive && event.modelId && !latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.id} />
<RevisionMenu document={document} revisionId={event.modelId} />
</StyledEventBoundary>
) : undefined
}
@@ -211,100 +168,63 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
ref={ref}
{...rest}
/>
) : (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={event.createdAt} relative shorten addSuffix />
</Text>
</EventItem>
);
};
const lineStyle = css`
&::before {
content: "";
display: block;
position: absolute;
top: -8px;
left: 22px;
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
&:first-child::before {
display: none;
}
&:nth-child(2)::before {
display: none;
}
&::after {
content: "";
display: block;
position: absolute;
top: calc(50% + 14px);
left: 22px;
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
&:last-child::after {
display: none;
}
h3 + &::before {
display: none;
}
`;
const IconWrapper = styled(Text)`
height: 24px;
`;
const EventItem = styled.li`
display: flex;
align-items: center;
gap: 8px;
list-style: none;
margin: 8px 0;
padding: 4px 10px;
white-space: nowrap;
position: relative;
time {
white-space: nowrap;
}
svg {
flex-shrink: 0;
}
${lineStyle}
`;
const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
return <ListItem to={to} ref={ref} {...rest} />;
});
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const RevisionItem = styled(Item)`
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
${lineStyle}
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${s("textSecondary")};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: auto;
bottom: -4px;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.5;
@@ -315,4 +235,8 @@ const RevisionItem = styled(Item)`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
export default observer(EventListItem);
+38 -62
View File
@@ -1,26 +1,17 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Initials from "./Avatar/Initials";
type Props = {
/** The users to display */
users: User[];
/** The size of the avatars, defaults to AvatarSize.Large */
size?: number;
/** A number to show as the number of additional users */
overflow?: number;
/** The maximum number of users to display, defaults to 8 */
limit?: number;
/** A component to render the avatar, defaults to Avatar. */
renderAvatar?: React.ComponentType<
React.ComponentProps<typeof Avatar> & {
model: User;
}
>;
renderAvatar?: (user: User) => React.ReactNode;
};
function Facepile({
@@ -28,70 +19,55 @@ function Facepile({
overflow = 0,
size = AvatarSize.Large,
limit = 8,
renderAvatar = Avatar,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
const filtered = users.filter(Boolean).slice(-limit);
const Component = renderAvatar;
return (
<Avatars {...rest}>
{overflow > 0 && (
<Initials size={size} content={String(overflow)}>
{users.length ? "+" : ""}
{overflow}
</Initials>
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
</More>
)}
{filtered.map((model, index) => {
const lastChild = index === 0 && overflow <= 0;
return (
<Component
key={model.id}
{...{
model,
size,
style: {
marginRight: lastChild ? 0 : -4,
...(lastChild || filtered.length === 1
? {}
: { clipPath: `url(#${clipPathId(size)})` }),
},
}}
/>
);
})}
<FacepileClip size={size} />
{users
.filter(Boolean)
.slice(0, limit)
.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
function FacepileClip({ size }: { size: number }) {
return (
<SVG
width="25"
height="28"
viewBox="0 0 25 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath id={clipPathId(size)}>
<path
transform={size !== 28 ? `scale(${size / 28})` : ""}
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
/>
</clipPath>
</SVG>
);
function DefaultAvatar(user: User) {
return <Avatar model={user} size={AvatarSize.Large} />;
}
function clipPathId(size: number) {
return `facepile-${size}`;
}
const AvatarWrapper = styled.div`
margin-right: -8px;
const SVG = styled.svg`
position: absolute;
top: 0;
left: 0;
&:first-child {
margin-right: 0;
}
`;
const More = styled.div<{ size: number }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 100%;
background: ${(props) => props.theme.textTertiary};
color: ${s("white")};
border: 2px solid ${s("background")};
text-align: center;
font-size: 12px;
font-weight: 600;
`;
const Avatars = styled(Flex)`
+8
View File
@@ -0,0 +1,8 @@
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
export default Fade;
-28
View File
@@ -1,28 +0,0 @@
import React from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
/**
* Fade in animation for a component.
*
* @param timing - The duration of the fade in animation, default is 250ms.
*/
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
type Props = {
children?: JSX.Element | null;
/** If true, children will be animated. */
animate: boolean;
};
/**
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
export default Fade;
+20 -17
View File
@@ -23,6 +23,7 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -34,6 +35,7 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -44,7 +46,7 @@ const FilterOptions = ({
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: false,
modal: true,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
@@ -52,11 +54,13 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems.map((selected) => selected.label).join(", ")
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
: "";
const renderItem = React.useCallback(
(option) => (
(option: TFilterOption) => (
<MenuItem
key={option.key}
onClick={() => {
@@ -66,7 +70,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon}
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -159,22 +163,16 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<>
<div>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
<StyledButton {...props} className={className} neutral disclosure>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList<TFilterOption>
<PaginatedList
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
@@ -195,7 +193,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</>
</div>
);
};
@@ -231,9 +229,8 @@ const SearchInput = styled(Input)`
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid rgb(34 40 52);
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -270,9 +267,15 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 28px;
line-height: 24px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+89
View File
@@ -0,0 +1,89 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s } from "@shared/styles";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupMembers from "~/scenes/GroupMembers";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
type Props = {
group: Group;
membership?: GroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
};
function GroupListItem({ group, showFacepile, renderActions }: Props) {
const { t } = useTranslation();
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
useBoolean();
const memberCount = group.memberCount;
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
const overflow = memberCount - users.length;
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
subtitle={t("{{ count }} member", { count: memberCount })}
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<NudeButton
width="auto"
height="auto"
onClick={setMembersModalOpen}
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
)}
{renderActions({
openMembersModal: setMembersModalOpen,
})}
</Flex>
}
/>
<Modal
title={t("Group members")}
onRequestClose={setMembersModalClosed}
isOpen={membersModalOpen}
>
<GroupMembers group={group} />
</Modal>
</>
);
}
const Image = styled(Flex)`
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: ${s("backgroundSecondary")};
border-radius: 32px;
`;
const Title = styled.span`
&: ${hover} {
text-decoration: underline;
cursor: var(--pointer);
}
`;
export default observer(GroupListItem);
+1 -1
View File
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${s("backdrop")} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
+34 -37
View File
@@ -6,18 +6,17 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { TooltipProvider } from "./TooltipContext";
export const HEADER_HEIGHT = 64;
@@ -62,8 +61,8 @@ function Header(
});
}, []);
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement) => {
breadcrumbsRef.current = node.firstElementChild as HTMLDivElement;
}, []);
const size = useComponentSize(internalRef);
@@ -72,40 +71,38 @@ function Header(
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
return (
<TooltipProvider>
<Wrapper
ref={mergeRefs([ref, internalRef])}
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
/>
)}
{left}
</Breadcrumbs>
) : null}
<Wrapper
ref={mergeRefs([ref, internalRef])}
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
/>
)}
{left}
</Breadcrumbs>
) : null}
{isScrolled && !isCompact ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
<Actions align="center" justify="flex-end">
{typeof actions === "function" ? actions({ isCompact }) : actions}
</Actions>
</Wrapper>
</TooltipProvider>
{isScrolled && !isCompact ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
<Actions align="center" justify="flex-end">
{typeof actions === "function" ? actions({ isCompact }) : actions}
</Actions>
</Wrapper>
);
}
+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;
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
<Card>
<CardContent>
<Flex column>
@@ -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;
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
import { getLuminance } from "polished";
import * as React from "react";
import styled from "styled-components";
import useStores from "../hooks/useStores";
import { IconType } from "../types";
import { IconLibrary } from "../utils/IconLibrary";
import { colorPalette } from "../utils/collections";
import { determineIconType } from "../utils/icon";
import EmojiIcon from "./EmojiIcon";
// import Logger from "~/utils/Logger";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { determineIconType } from "@shared/utils/icon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import Flex from "./Flex";
export type Props = {
@@ -40,9 +40,9 @@ const Icon = ({
const iconType = determineIconType(icon);
if (!iconType) {
// Logger.warn("Failed to determine icon type", {
// icon,
// });
Logger.warn("Failed to determine icon type", {
icon,
});
return null;
}
@@ -62,9 +62,9 @@ const Icon = ({
return <EmojiIcon emoji={icon} size={size} className={className} />;
} catch (err) {
// Logger.warn("Failed to render icon", {
// icon,
// });
Logger.warn("Failed to render icon", {
icon,
});
}
return null;
@@ -80,6 +80,7 @@ const SVGIcon = observer(
forceColor,
}: Props) => {
const { ui } = useStores();
let color = inputColor ?? colorPalette[0];
// If the chosen icon color is very dark then we invert it in dark mode
@@ -1,12 +1,13 @@
import { BackIcon } from "outline-icons";
import React from "react";
import styled from "styled-components";
import { breakpoints, s, hover } from "@shared/styles";
import { breakpoints, s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import { hover } from "~/styles";
enum Panel {
Builtin,
@@ -1,6 +1,7 @@
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
@@ -1,6 +1,7 @@
import styled, { css } from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
&: ${hover},
@@ -2,12 +2,13 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
import styled from "styled-components";
import { depths, s, hover } from "@shared/styles";
import { depths, s } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
+3 -2
View File
@@ -10,18 +10,19 @@ import {
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePrevious from "~/hooks/usePrevious";
import useWindowSize from "~/hooks/useWindowSize";
import { hover } from "~/styles";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
+1 -1
View File
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
type Props = {
@@ -1,6 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "../styles";
import { s } from "@shared/styles";
type Props = {
/** The emoji to render */
@@ -33,7 +33,7 @@ const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
<text
x="50%"
y="55%"
y={"55%"}
dominantBaseline="middle"
textAnchor="middle"
fontSize={size * 0.7}
+74
View File
@@ -0,0 +1,74 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
*/
export function IssueStatusIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
</svg>
);
case "canceled":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
</svg>
);
default:
return null;
}
}
+72
View File
@@ -0,0 +1,72 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub pull requests, but can be used for any git-style integration.
*/
export function PullRequestIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
case "merged":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
</svg>
);
default:
return null;
}
}
+3 -11
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 */
@@ -181,7 +176,6 @@ function Input(
if (ev.key === "Enter" && ev.metaKey) {
if (props.onRequestSubmit) {
props.onRequestSubmit(ev);
return;
}
}
@@ -236,11 +230,10 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
) : (
<NativeInput
@@ -250,12 +243,11 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
)}
{children}
+2 -3
View File
@@ -4,9 +4,9 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { isModKey } from "@shared/utils/keyboard";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
@@ -60,8 +60,7 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchPath({
query: ev.currentTarget.value,
searchPath(ev.currentTarget.value, {
collectionId,
ref: source,
})
+2 -12
View File
@@ -48,8 +48,7 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
/** Callback function that is called when the value changes. Return false to cancel the change. */
onChange?: (value: string | null) => void | Promise<boolean | void>;
onChange?: (value: string | null) => void;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
@@ -166,18 +165,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
if (previousValue.current === select.selectedValue) {
return;
}
const previous = previousValue.current;
previousValue.current = select.selectedValue;
const response = onChange?.(select.selectedValue);
if (response && response instanceof Promise) {
void response.then((success) => {
if (success === false) {
select.selectedValue = previous;
select.setSelectedValue(previous);
}
});
}
onChange?.(select.selectedValue);
}, [onChange, select.selectedValue]);
React.useLayoutEffect(() => {
-354
View File
@@ -1,354 +0,0 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import Scrollable from "./Scrollable";
import { IconWrapper } from "./Sidebar/components/SidebarLink";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import {
InputSelectRoot,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./primitives/components/InputSelect";
type Separator = {
/* Denotes a horizontal divider line to be rendered in the menu, */
type: "separator";
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
/* Representative text shown in the menu for this option. */
label: string;
/* Actual value of this option. */
value: string;
/* Additional info shown alongside the label. */
description?: string;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
};
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
value?: string;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* ARIA label for accessibility. */
ariaLabel: string;
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
} & TriggerButtonProps;
export function InputSelectNew(props: Props) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const triggerRef =
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile();
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return (
<MobileSelect
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={triggerRef}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
};
function MobileSelect(props: MobileSelectProps) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <Separator />;
}
const isSelected = option === selectedOption;
return (
<SelectItemWrapper
key={option.value}
onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
function Label({ text, hidden }: { text: string; hidden: boolean }) {
const labelText = <LabelText>{text}</LabelText>;
return hidden ? (
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
) : (
labelText
);
}
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
return (
<OptionContainer align="center">
{icon}
{option.label}
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</>
)}
</OptionContainer>
);
}
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const OptionContainer = styled(Flex)`
min-height: 24px;
`;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
+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);
+1 -1
View File
@@ -4,7 +4,6 @@ import { Helmet } from "react-helmet-async";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { isModKey } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
@@ -14,6 +13,7 @@ import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
type Props = {
children?: React.ReactNode;
-47
View File
@@ -1,47 +0,0 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
interface LazyLoadOptions {
retries?: number;
interval?: number;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
*
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
*
* @example
* ```typescript
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval } = options;
return {
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
};
}
+11 -12
View File
@@ -6,9 +6,10 @@ import { LocationDescriptor } from "history";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import { s, hover, ellipsis } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */
@@ -33,7 +34,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
ellipsis?: boolean;
};
const ListItem = (
@@ -46,7 +46,6 @@ const ListItem = (
border,
to,
keyboardNavigation,
ellipsis,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -85,9 +84,7 @@ const ListItem = (
column={!compact}
$selected={selected}
>
<Heading $small={small} $ellipsis={ellipsis}>
{title}
</Heading>
<Heading $small={small}>{title}</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
@@ -109,7 +106,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.sidebarActiveBackground,
background: theme.accent,
}}
{...rest}
{...rovingTabIndex}
@@ -212,10 +209,10 @@ const Image = styled(Flex)`
color: ${s("text")};
`;
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
const Heading = styled.p<{ $small?: boolean }>`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
${ellipsis()}
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
@@ -223,13 +220,14 @@ const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
const Content = styled(Flex)<{ $selected: boolean }>`
flex-direction: column;
flex-grow: 1;
color: ${s("text")};
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${s("textTertiary")};
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
@@ -237,7 +235,8 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${s("textSecondary")};
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default React.forwardRef(ListItem);
+72 -6
View File
@@ -1,23 +1,89 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { locales } from "@shared/utils/date";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import { useLocaleTime } from "~/hooks/useLocaleTime";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
children?: React.ReactNode;
dateTime: string;
tooltipDelay?: number;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
const { tooltipContent, content } = useLocaleTime(rest);
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
shorten,
format,
relative,
tooltipDelay,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return (
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={rest.dateTime}>{children || content}</time>
<Tooltip content={tooltipContent} delay={tooltipDelay} placement="bottom">
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
};
+1 -4
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
>
@@ -149,7 +147,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
props.$fullscreen
? transparentize(0.25, props.theme.background)
: props.theme.modalBackdrop} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -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;
@@ -4,10 +4,11 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import { s } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { hover, truncateMultiline } from "~/styles";
import { Avatar, AvatarSize } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
@@ -51,7 +52,11 @@ function NotificationListItem({ notification, onNavigate }: Props) {
<Text weight="bold">{notification.subject}</Text>
</Text>
<Text type="tertiary" size="xsmall">
<Time dateTime={notification.createdAt} addSuffix />{" "}
<Time
dateTime={notification.createdAt}
tooltipDelay={1000}
addSuffix
/>{" "}
{collection && <>&middot; {collection.name}</>}
</Text>
{notification.comment && (
+5 -13
View File
@@ -3,12 +3,13 @@ import { MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import Notification from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import { hover } from "~/styles";
import Desktop from "~/utils/Desktop";
import Empty from "../Empty";
import ErrorBoundary from "../ErrorBoundary";
@@ -48,15 +49,6 @@ function Notifications(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
return (
@@ -68,7 +60,7 @@ function Notifications(
</Text>
<Flex gap={8}>
{notifications.approximateUnreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Tooltip delay={500} content={t("Mark all as read")}>
<Button action={markNotificationsAsRead} context={context}>
<MarkAsReadIcon />
</Button>
@@ -79,11 +71,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}
@@ -1,142 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { OAuthClientValidation } from "@shared/validations";
import OAuthClient from "~/models/oauth/OAuthClient";
import ImageInput from "~/scenes/Settings/components/ImageInput";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import isCloudHosted from "~/utils/isCloudHosted";
import Switch from "../Switch";
export interface FormData {
name: string;
developerName: string;
developerUrl: string;
description: string;
avatarUrl: string;
redirectUris: string[];
published: boolean;
}
export const OAuthClientForm = observer(function OAuthClientForm_({
handleSubmit,
oauthClient,
}: {
handleSubmit: (data: FormData) => void;
oauthClient?: OAuthClient;
}) {
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
getValues,
setFocus,
setError,
control,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: oauthClient?.name ?? "",
description: oauthClient?.description ?? "",
avatarUrl: oauthClient?.avatarUrl ?? "",
redirectUris: oauthClient?.redirectUris ?? [],
published: oauthClient?.published ?? false,
},
});
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<>
<label style={{ marginBottom: "1em" }}>
<LabelText>{t("Icon")}</LabelText>
<Controller
control={control}
name="avatarUrl"
render={({ field }) => (
<ImageInput
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient?.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
)}
/>
</label>
<Input
type="text"
label={t("Name")}
placeholder={t("My App")}
{...register("name", {
required: true,
maxLength: OAuthClientValidation.maxNameLength,
})}
autoComplete="off"
autoFocus
flex
/>
<Input
type="text"
label={t("Tagline")}
placeholder={t("A short description")}
{...register("description", {
maxLength: OAuthClientValidation.maxDescriptionLength,
})}
flex
/>
<Controller
control={control}
name="redirectUris"
render={({ field }) => (
<Input
type="textarea"
label={t("Callback URLs")}
placeholder="https://example.com/callback"
ref={field.ref}
value={field.value.join("\n")}
rows={Math.max(2, field.value.length + 1)}
onChange={(event) => {
field.onChange(event.target.value.split("\n"));
}}
required
/>
)}
/>
{isCloudHosted && (
<Switch
{...register("published")}
label={t("Published")}
note={t("Allow this app to be installed by other workspaces")}
/>
)}
</>
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{oauthClient
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
@@ -1,33 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import { OAuthClientForm, FormData } from "./OAuthClientForm";
type Props = {
onSubmit: () => void;
};
export const OAuthClientNew = observer(function OAuthClientNew_({
onSubmit,
}: Props) {
const { oauthClients } = useStores();
const history = useHistory();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const oauthClient = await oauthClients.save(data);
onSubmit?.();
history.push(settingsPath("applications", oauthClient.id));
} catch (error) {
toast.error(error.message);
}
},
[oauthClients, history, onSubmit]
);
return <OAuthClientForm handleSubmit={handleSubmit} />;
});
+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}
+14 -6
View File
@@ -1,16 +1,19 @@
import * as React from "react";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import PaginatedList from "~/components/PaginatedList";
import EventListItem, { type Event } from "./EventListItem";
import EventListItem from "./EventListItem";
type Props = {
events: Event[];
events: Event<Document>[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
empty?: React.ReactNode;
};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
@@ -29,8 +32,13 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
+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,
+192 -247
View File
@@ -1,315 +1,260 @@
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 = 15;
/**
* 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 (results && (results.length === 0 || results.length < limit)) {
setAllowLoadMore(false);
this.allowLoadMore = false;
} else {
setOffset((prevOffset) => prevOffset + limit);
this.offset += limit;
}
setIsFetchingInitial(false);
this.renderCount += limit;
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} />
</div>
</DelayedMount>
)
<>
{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 && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
);
}
}
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));
+1 -15
View File
@@ -10,23 +10,13 @@ import { fadeAndScaleIn } from "~/styles/animations";
type Props = PopoverProps & {
children: React.ReactNode;
/** The width of the popover, defaults to 380px. */
width?: number;
/** The minimum width of the popover, use instead of width if contents adjusts size. */
minWidth?: number;
/** Shrink the padding of the popover */
shrink?: boolean;
/** Make the popover flex */
flex?: boolean;
/** The tab index of the popover */
tabIndex?: number;
/** Whether the popover should be scrollable, defaults to true. */
scrollable?: boolean;
/** The position of the popover on mobile, defaults to "top". */
mobilePosition?: "top" | "bottom";
/** Function to show the popover */
show: () => void;
/** Function to hide the popover */
hide: () => void;
};
@@ -35,7 +25,6 @@ const Popover = (
children,
shrink,
width = 380,
minWidth,
scrollable = true,
flex,
mobilePosition,
@@ -82,7 +71,6 @@ const Popover = (
ref={ref}
$shrink={shrink}
$width={width}
$minWidth={minWidth}
$scrollable={scrollable}
$flex={flex}
>
@@ -95,7 +83,6 @@ const Popover = (
type ContentsProps = {
$shrink?: boolean;
$width?: number;
$minWidth?: number;
$flex?: boolean;
$scrollable: boolean;
$mobilePosition?: "top" | "bottom";
@@ -114,8 +101,7 @@ const Contents = styled.div<ContentsProps>`
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 75vh;
box-shadow: ${s("menuShadow")};
${(props) => props.$width && `width: ${props.$width}px`};
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
width: ${(props) => props.$width}px;
${(props) =>
props.$scrollable
+3 -2
View File
@@ -3,7 +3,7 @@ import { transparentize } from "polished";
import React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import type { ReactionSummary } from "@shared/types";
import { getEmojiId } from "@shared/utils/emoji";
import User from "~/models/User";
@@ -13,6 +13,7 @@ import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import { hover } from "~/styles";
type Props = {
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
@@ -127,7 +128,7 @@ const Reaction: React.FC<Props> = ({
);
return tooltipContent ? (
<Tooltip content={tooltipContent} placement="bottom">
<Tooltip content={tooltipContent} delay={250} placement="bottom">
{DisplayedEmoji}
</Tooltip>
) : (
+8 -5
View File
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import { createLazyComponent } from "~/components/LazyLoad";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
@@ -13,7 +12,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = createLazyComponent(
const EmojiPanel = React.lazy(
() => import("~/components/IconPicker/components/EmojiPanel")
);
@@ -99,13 +98,17 @@ const ReactionPicker: React.FC<Props> = ({
<>
<PopoverDisclosure {...popover}>
{(props) => (
<Tooltip content={t("Add reaction")} placement="top" hideOnClick>
<Tooltip
content={t("Add reaction")}
placement="top"
delay={500}
hideOnClick
>
<NudeButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
onMouseEnter={() => EmojiPanel.preload()}
size={size}
>
<ReactionIcon size={22} />
@@ -125,7 +128,7 @@ const ReactionPicker: React.FC<Props> = ({
{popover.visible && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel.Component
<EmojiPanel
height={300}
panelWidth={panelWidth}
query={query}
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Tab, TabPanel, useTabState } from "reakit";
import { toast } from "sonner";
import styled, { css } from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import Comment from "~/models/Comment";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Emoji } from "~/components/Emoji";
@@ -13,6 +13,7 @@ import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
type Props = {
/** Model for which to show the reactions. */

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