Sfoglia il codice sorgente

Merge branch 'master' into imprv/85046-modify-null-tag-part

yuto-oweseek 4 anni fa
parent
commit
1d3d649262
66 ha cambiato i file con 1278 aggiunte e 939 eliminazioni
  1. 1 0
      .stylelintrc.json
  2. 67 1
      CHANGELOG.md
  3. 1 1
      lerna.json
  4. 5 1
      package.json
  5. 1 0
      packages/app/.stylelintrc.json
  6. 3 3
      packages/app/config/webpack.prod.js
  7. 41 36
      packages/app/docker/Dockerfile
  8. 5 6
      packages/app/docker/Dockerfile.dockerignore
  9. 2 2
      packages/app/docker/README.md
  10. 16 19
      packages/app/package.json
  11. 13 1
      packages/app/resource/locales/en_US/translation.json
  12. 12 0
      packages/app/resource/locales/ja_JP/translation.json
  13. 12 0
      packages/app/resource/locales/zh_CN/translation.json
  14. 0 27
      packages/app/src/client/legacy/crowi.js
  15. 1 0
      packages/app/src/client/services/EditorContainer.js
  16. 30 22
      packages/app/src/client/services/PageContainer.js
  17. 6 2
      packages/app/src/client/util/apiv1-client.ts
  18. 3 3
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  19. 0 85
      packages/app/src/components/BookmarkButton.jsx
  20. 83 0
      packages/app/src/components/BookmarkButtons.tsx
  21. 9 10
      packages/app/src/components/EventListeneres/HashChanged.tsx
  22. 0 33
      packages/app/src/components/ExpandOrContractButton.jsx
  23. 37 0
      packages/app/src/components/ExpandOrContractButton.tsx
  24. 49 15
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  25. 5 3
      packages/app/src/components/Navbar/SubNavButtons.jsx
  26. 35 45
      packages/app/src/components/PageEditor/AbstractEditor.tsx
  27. 4 3
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  28. 282 0
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  29. 100 83
      packages/app/src/components/PageEditor/Editor.jsx
  30. 52 1
      packages/app/src/components/PageStatusAlert.jsx
  31. 8 0
      packages/app/src/components/SavePageControls.jsx
  32. 1 3
      packages/app/src/components/SubscribeButton.tsx
  33. 58 0
      packages/app/src/components/UncontrolledCodeMirror.tsx
  34. 7 0
      packages/app/src/interfaces/bookmarks.ts
  35. 7 0
      packages/app/src/interfaces/revision.ts
  36. 3 0
      packages/app/src/server/crowi/express-init.js
  37. 0 15
      packages/app/src/server/models/user.js
  38. 5 1
      packages/app/src/server/models/vo/s2c-message.js
  39. 8 2
      packages/app/src/server/routes/apiv3/bookmarks.js
  40. 4 4
      packages/app/src/server/routes/apiv3/forgot-password.js
  41. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  42. 2 2
      packages/app/src/server/routes/index.js
  43. 10 3
      packages/app/src/server/routes/page.js
  44. 12 0
      packages/app/src/server/service/config-loader.ts
  45. 16 1
      packages/app/src/server/service/page.js
  46. 17 6
      packages/app/src/server/service/passport.ts
  47. 2 3
      packages/app/src/server/service/search.ts
  48. 2 1
      packages/app/src/server/util/apiResponse.js
  49. 2 0
      packages/app/src/server/util/swigFunctions.js
  50. 1 1
      packages/app/src/server/views/widget/page_content.html
  51. 21 0
      packages/app/src/stores/bookmarks.tsx
  52. 2 2
      packages/app/src/stores/context.tsx
  53. 4 2
      packages/app/src/stores/page.tsx
  54. 33 22
      packages/app/src/stores/ui.tsx
  55. 1 1
      packages/app/src/styles/_sidebar.scss
  56. 1 1
      packages/codemirror-textlint/package.json
  57. 1 2
      packages/core/package.json
  58. 2 3
      packages/plugin-attachment-refs/package.json
  59. 2 2
      packages/plugin-lsx/package.json
  60. 1 3
      packages/plugin-pukiwiki-like-linker/package.json
  61. 1 1
      packages/slack/package.json
  62. 49 36
      packages/slackbot-proxy/docker/Dockerfile
  63. 5 6
      packages/slackbot-proxy/docker/Dockerfile.dockerignore
  64. 2 2
      packages/slackbot-proxy/package.json
  65. 1 2
      packages/ui/package.json
  66. 110 408
      yarn.lock

+ 1 - 0
.stylelintrc.json

@@ -2,6 +2,7 @@
   "extends": [
     "stylelint-config-recess-order"
   ],
+  "customSyntax": "postcss-scss",
   "rules": {
     "indentation": 2,
     "string-quotes": "single",

+ 67 - 1
CHANGELOG.md

@@ -1,9 +1,75 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.8...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.8](https://github.com/weseek/growi/compare/v4.5.7...v4.5.8) - 2022-01-12
+
+### 💎 Features
+
+- feat: Display a list of bookmarked users (#5044) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Built-in editor scroll position is reset after save (Introduced by v4.5.3) (#5074) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Bump y18n to v4.0.3 (#5071) @yuki-takei
+- support: Omit prettier-stylelint (#5070) @yuki-takei
+- support: Bump tar to 6.1.11 (#5069) @yuki-takei
+
+## [v4.5.7](https://github.com/weseek/growi/compare/v4.5.6...v4.5.7) - 2022-01-11
+
+### 🐛 Bug Fixes
+
+- fix: Subnavigation sticking initialization (#5062) @yuki-takei
+- fix: Built-in editor was broken (#5061) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Bump re2 to 1.17.2 (#5059) @yuki-takei
+
+## [v4.5.6](https://github.com/weseek/growi/compare/v4.5.5...v4.5.6) - 2022-01-07
+
+### 💎 Features
+
+- feat: Resolve conflict with 3-way merge like editor (#5012) @yuto-oweseek
+
+### 🚀 Improvement
+
+- imprv: Subnavigation behavior (#5047) @yuki-takei
+- imprv: Switching editor mode behavior (#5043) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- Bug: Error: The specified instance couldn't register because same id has already been registered (#5031) by #5043 @yuki-takei
+
+## [v4.5.5](https://github.com/weseek/growi/compare/v4.5.4...v4.5.5) - 2022-01-05
+
+### 💎 Features
+
+- feat: OIDC reconnection (#5016) @mudana-grune
+- feat: In-App Notification (#4792) @kaoritokashiki
+
+### 🚀 Improvement
+
+- imprv: Improve tags functions (#5001) @yuto-oweseek
+- imprv: Migrate editor container grant to SWR (#4957) @stevenfukase
+
+### 🐛 Bug Fixes
+
+- Bug: Error: The specified instance couldn't register because same id has already been registered (#5031) by 573216c @yuki-takei
+
+### 🧰 Maintenance
+
+- fix: dependabot alert trim-newlines (#4931) @mudana-grune
+- fix: dependabot alert dot-prop (#4921) @mudana-grune
+- ci(deps-dev): bump tsconfig-paths-webpack-plugin from 3.5.1 to 3.5.2 (#4852) @dependabot
+- ci(deps): bump ua-parser-js from 0.7.17 to 0.7.31 (#4895) @dependabot
+- support: dependabot alert ssri (#4973) @mudana-grune
+
 ## [v4.5.4](https://github.com/weseek/growi/compare/v4.5.3...v4.5.4) - 2021-12-23
 
 ### 💎 Features

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "packages": [
     "packages/*"
   ]

+ 5 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -67,8 +67,12 @@
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
+    "postcss": "^8.4.5",
+    "postcss-scss": "^4.0.3",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
+    "stylelint": "^14.2.0",
+    "stylelint-config-recess-order": "^3.0.0",
     "ts-jest": "^27.0.4",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",

+ 1 - 0
packages/app/.stylelintrc.json

@@ -2,6 +2,7 @@
   "extends": [
     "stylelint-config-recess-order"
   ],
+  "customSyntax": "postcss-scss",
   "ignoreFiles": [
     "src/styles/_override-bootstrap-variables.scss",
     "src/linter-checker/test.scss"

+ 3 - 3
packages/app/config/webpack.prod.js

@@ -35,10 +35,10 @@ module.exports = require('./webpack.common')({
             loader: 'postcss-loader',
             options: {
               sourceMap: false,
-              plugins: () => {
-                return [
+              postcssOptions: {
+                plugins: [
                   require('autoprefixer')(),
-                ];
+                ],
               },
             },
           },

+ 41 - 36
packages/app/docker/Dockerfile

@@ -1,36 +1,40 @@
-# syntax = docker/dockerfile:experimental
+# syntax = docker/dockerfile:1
 
 ARG flavor=default
 
 
+##
+## packages-json-picker
+##
+FROM node:14-slim AS packages-json-picker
+
+ENV optDir /opt
+
+WORKDIR ${optDir}
+COPY ["package.json", "yarn.lock", "lerna.json", "./"]
+COPY packages packages
+# Find and remove non-package.json files
+RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
+
 
 ##
 ## deps-resolver
 ##
 FROM node:14-slim AS deps-resolver
-LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
-WORKDIR ${appDir}
-COPY ./package.json .
-COPY ./yarn.lock .
-COPY ./lerna.json .
-COPY ./packages/app/package.json packages/app/
-COPY ./packages/core/package.json packages/core/
-COPY ./packages/codemirror-textlint/package.json packages/codemirror-textlint/
-COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
-COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
-COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
-COPY ./packages/slack/package.json packages/slack/
-COPY ./packages/ui/package.json packages/ui/
+WORKDIR ${optDir}
+
+# copy files
+COPY --from=packages-json-picker ${optDir} .
 
 # setup
 RUN yarn config set network-timeout 300000
-RUN npx lerna bootstrap
+RUN npx lerna bootstrap -- --frozen-lockfile
 
 # make artifacts
-RUN tar cf node_modules.tar \
+RUN tar -cf node_modules.tar \
   node_modules \
   packages/*/node_modules
 
@@ -40,9 +44,13 @@ RUN tar cf node_modules.tar \
 ## deps-resolver-prod
 ##
 FROM deps-resolver AS deps-resolver-prod
+
+# remove unnecessary packages
+RUN rm -rf packages/slackbot-proxy
+
 RUN npx lerna bootstrap -- --production
 # make artifacts
-RUN tar cf node_modules.tar \
+RUN tar -cf node_modules.tar \
   node_modules \
   packages/*/node_modules
 
@@ -53,16 +61,16 @@ RUN tar cf node_modules.tar \
 ##
 FROM node:14-slim AS prebuilder-default
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
-WORKDIR ${appDir}
+WORKDIR ${optDir}
 
 # copy dependent packages
 COPY --from=deps-resolver \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${optDir}/node_modules.tar ${optDir}/
 
 # extract node_modules.tar
-RUN tar xf node_modules.tar
+RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
 
@@ -73,7 +81,7 @@ RUN rm node_modules.tar
 FROM prebuilder-default AS prebuilder-nocdn
 
 # add dotenv file for NO_CDN
-COPY packages/app/docker/nocdn/.env.production.local ${appDir}/packages/app/
+COPY packages/app/docker/nocdn/.env.production.local ${optDir}/packages/app/
 
 
 
@@ -82,14 +90,11 @@ COPY packages/app/docker/nocdn/.env.production.local ${appDir}/packages/app/
 ##
 FROM prebuilder-${flavor} AS builder
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
-WORKDIR ${appDir}
+WORKDIR ${optDir}
 
-COPY ./package.json ./
-COPY ./yarn.lock ./
-COPY ./lerna.json ./
-COPY ./tsconfig.base.json ./
+COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
 COPY packages/app packages/app
 COPY packages/core packages/core
@@ -104,9 +109,8 @@ COPY packages/ui packages/ui
 RUN yarn lerna run build
 
 # make artifacts
-RUN tar cf packages.tar \
+RUN tar -cf packages.tar \
   package.json \
-  yarn.lock \
   tsconfig.base.json \
   packages/app/config \
   packages/app/public \
@@ -129,7 +133,8 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production
 
-ENV appDir /opt/growi
+ENV optDir /opt
+ENV appDir ${optDir}/growi
 
 # Add gosu
 # see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
@@ -141,15 +146,15 @@ RUN set -eux; \
 	gosu nobody true
 
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${optDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
-  ${appDir}/packages.tar ${appDir}/
+  ${optDir}/packages.tar ${appDir}/
 
 # extract artifacts as 'node' user
 USER node
 WORKDIR ${appDir}
-RUN tar xf node_modules.tar
-RUN tar xf packages.tar
+RUN tar -xf node_modules.tar
+RUN tar -xf packages.tar
 RUN rm node_modules.tar packages.tar
 
 USER root

+ 5 - 6
packages/app/docker/Dockerfile.dockerignore

@@ -1,6 +1,5 @@
-node_modules
-*/node_modules
-*/coverage
-*/dist
-*/Dockerfile
-*/*.dockerignore
+**/node_modules
+**/dist
+**/coverage
+**/Dockerfile
+**/*.dockerignore

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.5.4`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.4/docker/Dockerfile)
-* [`4.5.4-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.4/docker/Dockerfile)
+* [`4.5.8`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
+* [`4.5.8-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 16 - 19
packages/app/package.json

@@ -1,12 +1,12 @@
 {
   "name": "@growi/app",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
     "start": "yarn build && yarn server",
     "build": "run-p build:*",
-    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
+    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
@@ -33,7 +33,7 @@
     "predev:ci": "run-p resources:*",
     "lint:typecheck": "npx tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
-    "lint:styles": "stylelint src/**/*.scss --custom-syntax postcss-scss",
+    "lint:styles": "stylelint src/**/*.scss",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
@@ -51,18 +51,17 @@
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
-    "mongoose": "5.13.13 causes an error like 't.versions.node is undefined' about 'browser.umd.js' on browser",
     "string-width": "5.0.0 or above exports only ESM."
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.5.5-RC.0",
-    "@growi/plugin-attachment-refs": "^4.5.5-RC.0",
-    "@growi/plugin-lsx": "^4.5.5-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.5.5-RC.0",
-    "@growi/slack": "^4.5.5-RC.0",
+    "@growi/codemirror-textlint": "^4.5.9-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.9-RC.0",
+    "@growi/plugin-lsx": "^4.5.9-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.9-RC.0",
+    "@growi/slack": "^4.5.9-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -74,10 +73,12 @@
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.1044.0",
     "axios": "^0.24.0",
+    "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
+    "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
     "connect-redis": "^4.0.4",
@@ -86,6 +87,7 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "diff_match_patch": "^0.1.1",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
@@ -98,7 +100,6 @@
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
-    "got": "^8.3.2",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
     "http-errors": "~1.8.0",
@@ -122,6 +123,7 @@
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "=2.5.0",
+    "p-retry": "^4.0.0",
     "passport": "^0.5.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
@@ -130,9 +132,7 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
-    "p-retry": "^4.0.0",
     "prom-client": "^13.0.0",
-    "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "react-tagcloud": "^2.1.1",
@@ -159,7 +159,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^4.5.5-RC.0",
+    "@growi/ui": "^4.5.9-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -232,10 +232,8 @@
     "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
-    "stylelint": "^14.0.1",
-    "stylelint-config-recess-order": "^2.0.1",
     "swagger2openapi": "^5.3.1",
-    "swr": "^1.0.1",
+    "swr": "^1.1.2",
     "terser-webpack-plugin": "^4.1.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
@@ -244,10 +242,9 @@
     "tsc-alias": "^1.2.9",
     "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",
-    "webpack": "^4.39.3",
+    "webpack": "^4.46.0",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.9.0",
-    "webpack-cli": "^3.3.7",
-    "webpack-merge": "^4.2.2"
+    "webpack-cli": "^4.9.1"
   }
 }

+ 13 - 1
packages/app/resource/locales/en_US/translation.json

@@ -58,6 +58,7 @@
   "The end": "The end",
   "Not available for guest": "Not available for guest",
   "No users have liked this yet.": "No users have liked this yet.",
+  "No users have bookmarked yet": "No users have bookmarked yet",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",
   "Target page": "Target page",
@@ -193,7 +194,7 @@
     },
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
-      "password": "Your password must be at least 6 characters long.",
+      "password": "Your password must be at least 8 characters long.",
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
     }
   },
@@ -478,6 +479,17 @@
     "enable_textlint": "Enable Textlint",
     "dont_ask_again": "Don't ask again"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "This file is conflicting with newer remote file",
+    "resolve_conflict_message": "Please select page body",
+    "resolve_conflict": "Resolve Conflict",
+    "resolve_and_save" : "Resolve and save",
+    "select_revision" : "Select {{revision}}",
+    "requested_revision": "mine",
+    "origin_revision": "origin",
+    "latest_revision": "theirs",
+    "selected_editable_revision": "Selected Page Body (Editable)"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 12 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -58,6 +58,7 @@
   "Presentation Mode": "プレゼンテーション",
   "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",
   "File type": "ファイル形式",
@@ -478,6 +479,17 @@
     "enable_textlint": "Textlintを有効にする",
     "dont_ask_again": "常に許可する"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
+    "resolve_conflict_message": "ページ本文を選んでください",
+    "resolve_conflict": "衝突を解消",
+    "resolve_and_save" : "解消し保存する",
+    "select_revision" : "{{revision}}にする",
+    "requested_revision": "送信された本文",
+    "origin_revision": "送信する前の本文",
+    "latest_revision": "最新の本文",
+    "selected_editable_revision": "保存するページ本文(編集可能)"
+  },
   "link_edit": {
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",

+ 12 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -59,6 +59,7 @@
 	"Presentation Mode": "演示文稿",
   "The end": "结束",
   "Not available for guest": "Not available for guest",
+  "No users have bookmarked yet": "还没有用户加入书签",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",
   "Target page": "目标页面",
@@ -456,6 +457,17 @@
     "enable_textlint": "启用Textlint",
     "dont_ask_again": "不要再问"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
+    "resolve_conflict_message": "选择页面正文",
+    "resolve_conflict": "解决冲突",
+    "resolve_and_save" : "解决冲突并保存",
+    "select_revision" : "选择{{revision}}",
+    "requested_revision": "发送的页面正文",
+    "origin_revision": "发送前的页面正文",
+    "latest_revision": "最新页面正文",
+    "selected_editable_revision": "选定的可编辑页面正文"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 0 - 27
packages/app/src/client/legacy/crowi.js

@@ -112,33 +112,6 @@ Crowi.initClassesByOS = function() {
   });
 };
 
-// window.addEventListener('load', () => {
-//   const { appContainer } = window;
-//   const pageContainer = appContainer.getContainer('PageContainer');
-
-//   // Do nothing if the page does not exist
-//   // ex.) admin page,login page
-//   if (pageContainer == null) {
-//     return null;
-//   }
-//   const { isAbleToOpenPageEditor } = pageContainer;
-
-//   // hash on page
-//   if (window.location.hash) {
-//     const navigationContainer = appContainer.getContainer('NavigationContainer');
-
-//     if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
-//       navigationContainer.setEditorMode('edit');
-
-//       // focus
-//       Crowi.setCaretLineAndFocusToEditor();
-//     }
-//     else if (window.location.hash === '#hackmd') {
-//       navigationContainer.setEditorMode('hackmd');
-//     }
-//   }
-// });
-
 window.addEventListener('load', () => {
   const crowi = window.crowi;
   if (crowi && crowi.users && crowi.users.length !== 0) {

+ 1 - 0
packages/app/src/client/services/EditorContainer.js

@@ -41,6 +41,7 @@ export default class EditorContainer extends Container {
 
     this.initDrafts();
 
+    this.editorOptions = null;
     this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }

+ 30 - 22
packages/app/src/client/services/PageContainer.js

@@ -4,9 +4,11 @@ import { Container } from 'unstated';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 import { pagePathUtils } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
-import { toastError } from '../util/apiNotification';
+import { EditorMode } from '~/stores/ui';
 
+import { toastError } from '../util/apiNotification';
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
@@ -52,9 +54,6 @@ export default class PageContainer extends Container {
       path,
       tocHtml: '',
 
-      isBookmarked: false,
-      sumOfBookmarks: 0,
-
       seenUsers: [],
       seenUserIds: [],
       sumOfSeenUsers: [],
@@ -86,12 +85,15 @@ export default class PageContainer extends Container {
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
+      remoteRevisionBody: null,
+      remoteRevisionUpdateAt: null,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
       deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
+      isConflictDiffModalOpen: false,
     };
 
     // parse creator, lastUpdateUser and revisionAuthor
@@ -103,6 +105,7 @@ export default class PageContainer extends Container {
     }
     try {
       this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
+      this.state.lastUpdateUser = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
     }
     catch (e) {
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
@@ -126,7 +129,6 @@ export default class PageContainer extends Container {
       // as it is stored in a separate collection to like and seen user
       // data so it has a separate api endpoint.
       this.initialPageLoad();
-      this.retrieveBookmarkInfo();
     }
 
     this.setTocHtml = this.setTocHtml.bind(this);
@@ -314,20 +316,6 @@ export default class PageContainer extends Container {
     this.checkAndUpdateImageUrlCached(users);
   }
 
-  async retrieveBookmarkInfo() {
-    const response = await this.appContainer.apiv3Get('/bookmarks/info', { pageId: this.state.pageId });
-    this.setState({
-      sumOfBookmarks: response.data.sumOfBookmarks,
-      isBookmarked: response.data.isBookmarked,
-    });
-  }
-
-  async toggleBookmark() {
-    const bool = !this.state.isBookmarked;
-    await this.appContainer.apiv3Put('/bookmarks', { pageId: this.state.pageId, bool });
-    return this.retrieveBookmarkInfo();
-  }
-
   async checkAndUpdateImageUrlCached(users) {
     const noImageCacheUsers = users.filter((user) => { return user.imageUrlCached == null });
     if (noImageCacheUsers.length === 0) {
@@ -347,8 +335,12 @@ export default class PageContainer extends Container {
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionUpdateAt: s2cMessagePageUpdated.revisionUpdateAt,
       revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      // TODO // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
+      lastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
     };
 
     if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
@@ -392,7 +384,7 @@ export default class PageContainer extends Container {
     // PageEditor component
     const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     if (pageEditor != null) {
-      if (editorMode !== 'edit') {
+      if (editorMode !== EditorMode.Editor) {
         pageEditor.updateEditorValue(newState.markdown);
       }
     }
@@ -400,7 +392,7 @@ export default class PageContainer extends Container {
     const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
     if (pageEditorByHackmd != null) {
       // reset
-      if (editorMode !== 'hackmd') {
+      if (editorMode !== EditorMode.HackMD) {
         pageEditorByHackmd.reset();
       }
     }
@@ -441,7 +433,6 @@ export default class PageContainer extends Container {
   async save(markdown, editorMode, optionsToSave = {}) {
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
-
     const options = Object.assign({}, optionsToSave);
 
     if (editorMode === 'hackmd') {
@@ -663,4 +654,21 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   }
 
+  async resolveConflict(markdown, editorMode) {
+
+    const { pageId, remoteRevisionId, path } = this.state;
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const options = editorContainer.getCurrentOptionsToSave();
+    const optionsToSave = Object.assign({}, options);
+
+    const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
+
+    editorContainer.clearDraft(path);
+    this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+
+    editorContainer.setState({ tags: res.tags });
+
+    return res;
+  }
+
 }

+ 6 - 2
packages/app/src/client/util/apiv1-client.ts

@@ -17,11 +17,15 @@ class Apiv1ErrorHandler extends Error {
 
   code;
 
-  constructor(message = '', code = '') {
+  data;
+
+  constructor(message = '', code = '', data = '') {
     super();
 
     this.message = message;
     this.code = code;
+    this.data = data;
+
   }
 
 }
@@ -35,7 +39,7 @@ export async function apiRequest(method: string, path: string, params: unknown):
 
   // Return error code if code is exist
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
+    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
     throw error;
   }
 

+ 3 - 3
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -157,10 +157,10 @@ class SecuritySetting extends React.Component {
                 aria-expanded="true"
               >
                 <span className="float-left">
-                  {currentPageCompleteDeletionAuthority === 'anyOne' && t('security_setting.anyone')}
+                  {(currentPageCompleteDeletionAuthority === 'anyOne' || currentPageCompleteDeletionAuthority == null)
+                      && t('security_setting.anyone')}
                   {currentPageCompleteDeletionAuthority === 'adminOnly' && t('security_setting.admin_only')}
-                  {(currentPageCompleteDeletionAuthority === 'adminAndAuthor' || currentPageCompleteDeletionAuthority == null)
-                      && t('security_setting.admin_and_author')}
+                  {currentPageCompleteDeletionAuthority === 'adminAndAuthor' && t('security_setting.admin_and_author')}
                 </span>
               </button>
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">

+ 0 - 85
packages/app/src/components/BookmarkButton.jsx

@@ -1,85 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { UncontrolledTooltip } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import { toastError } from '~/client/util/apiNotification';
-import PageContainer from '~/client/services/PageContainer';
-import AppContainer from '~/client/services/AppContainer';
-
-class BookmarkButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.handleClick = this.handleClick.bind(this);
-  }
-
-  async handleClick() {
-    const { appContainer, pageContainer } = this.props;
-    const { isGuestUser } = appContainer;
-
-    if (isGuestUser) {
-      return;
-    }
-
-    try {
-      pageContainer.toggleBookmark();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  render() {
-    const { appContainer, pageContainer, t } = this.props;
-    const { isGuestUser } = appContainer;
-
-    return (
-      <div>
-        <button
-          type="button"
-          id="bookmark-button"
-          onClick={this.handleClick}
-          className={`btn btn-bookmark border-0
-          ${`btn-${this.props.size}`} ${pageContainer.state.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
-        >
-          <i className="icon-star mr-3"></i>
-          <span className="total-bookmarks">
-            {pageContainer.state.sumOfBookmarks}
-          </span>
-        </button>
-
-        {isGuestUser && (
-          <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
-            {t('Not available for guest')}
-          </UncontrolledTooltip>
-        )}
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer, PageContainer]);
-
-BookmarkButton.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  pageId: PropTypes.string,
-  t: PropTypes.func.isRequired,
-  size: PropTypes.string,
-};
-
-BookmarkButton.defaultProps = {
-  size: 'md',
-};
-
-export default withTranslation()(BookmarkButtonWrapper);

+ 83 - 0
packages/app/src/components/BookmarkButtons.tsx

@@ -0,0 +1,83 @@
+import React, { FC, useState } from 'react';
+
+import { Types } from 'mongoose';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import UserPictureList from './User/UserPictureList';
+import { toastError } from '~/client/util/apiNotification';
+import { useIsGuestUser } from '~/stores/context';
+import { useSWRxBookmarksInfo } from '~/stores/bookmarks';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
+interface Props {
+  pageId: Types.ObjectId
+}
+
+const BookmarkButton: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { pageId } = props;
+
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: bookmarksInfo, mutate } = useSWRxBookmarksInfo(pageId);
+
+  const isBookmarked = bookmarksInfo?.isBookmarked != null ? bookmarksInfo.isBookmarked : false;
+  const sumOfBookmarks = bookmarksInfo?.sumOfBookmarks != null ? bookmarksInfo.sumOfBookmarks : 0;
+  const bookmarkedUsers = bookmarksInfo?.bookmarkedUsers != null ? bookmarksInfo.bookmarkedUsers : [];
+
+  const togglePopover = () => {
+    setIsPopoverOpen(!isPopoverOpen);
+  };
+
+  const handleClick = async() => {
+    if (isGuestUser) {
+      return;
+    }
+
+    try {
+      const res = await apiv3Put('/bookmarks', { pageId, bool: !isBookmarked });
+      if (res) {
+        mutate();
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <div className="btn-group" role="group" aria-label="Bookmark buttons">
+      <button
+        type="button"
+        id="bookmark-button"
+        onClick={handleClick}
+        className={`btn btn-bookmark border-0
+          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+      >
+        <i className="icon-star"></i>
+      </button>
+
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${isBookmarked ? 'active' : ''}`}>
+        {sumOfBookmarks}
+      </button>
+
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            {bookmarkedUsers.length ? <UserPictureList users={bookmarkedUsers} /> : t('No users have bookmarked yet')}
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
+
+export default BookmarkButton;

+ 9 - 10
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,23 +1,22 @@
 import { FC, useCallback, useEffect } from 'react';
 
-import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
 
+/**
+ * Change editorMode by browser forward/back operation
+ */
 const HashChanged: FC<void> = () => {
   const { data: isEditable } = useIsEditable();
-  const { mutate: mutateEditorMode } = useEditorMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
   const hashchangeHandler = useCallback(() => {
-    const { hash } = window.location;
+    const newEditorMode = determineEditorModeByHash();
 
-    if (hash == null) {
-      return;
-    }
-
-    if (hash === '#edit') {
-      mutateEditorMode(EditorMode.Editor);
+    if (editorMode !== newEditorMode) {
+      mutateEditorMode(newEditorMode);
     }
-  }, [mutateEditorMode]);
+  }, [editorMode, mutateEditorMode]);
 
   // setup effect
   useEffect(() => {

+ 0 - 33
packages/app/src/components/ExpandOrContractButton.jsx

@@ -1,33 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-function ExpandOrContractButton(props) {
-  const { isWindowExpanded, contractWindow, expandWindow } = props;
-
-  const clickContractButtonHandler = () => {
-    if (contractWindow != null) {
-      contractWindow();
-    }
-  };
-
-  const clickExpandButtonHandler = () => {
-    if (expandWindow != null) {
-      expandWindow();
-    }
-  };
-
-  return (
-    <button type="button" className="close" onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}>
-      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
-    </button>
-  );
-}
-
-ExpandOrContractButton.propTypes = {
-  isWindowExpanded: PropTypes.bool,
-  contractWindow: PropTypes.func,
-  expandWindow: PropTypes.func,
-};
-
-
-export default ExpandOrContractButton;

+ 37 - 0
packages/app/src/components/ExpandOrContractButton.tsx

@@ -0,0 +1,37 @@
+import React, { FC } from 'react';
+
+
+type Props = {
+  isWindowExpanded: boolean,
+  contractWindow?: () => void,
+  expandWindow?: () => void,
+};
+
+const ExpandOrContractButton: FC<Props> = (props: Props) => {
+  const { isWindowExpanded, contractWindow, expandWindow } = props;
+
+  const clickContractButtonHandler = (): void => {
+    if (contractWindow != null) {
+      contractWindow();
+    }
+  };
+
+  const clickExpandButtonHandler = (): void => {
+    if (expandWindow != null) {
+      expandWindow();
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      className="close"
+      onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
+    >
+      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
+    </button>
+  );
+};
+
+
+export default ExpandOrContractButton;

+ 49 - 15
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,9 +1,12 @@
-import React, { useState, useEffect, useCallback } from 'react';
-// import PropTypes from 'prop-types';
+import React, {
+  useMemo, useState, useRef, useEffect, useCallback,
+} from 'react';
 
 import StickyEvents from 'sticky-events';
 import { debounce } from 'throttle-debounce';
+
 import loggerFactory from '~/utils/logger';
+import { useSidebarCollapsed } from '~/stores/ui';
 
 import GrowiSubNavigation from './GrowiSubNavigation';
 
@@ -21,24 +24,43 @@ const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
  */
 const GrowiSubNavigationSwitcher = (props) => {
 
+  const { data: isSidebarCollapsed } = useSidebarCollapsed();
+
   const [isVisible, setVisible] = useState(false);
+  const [width, setWidth] = useState(null);
+
+  const fixedContainerRef = useRef();
+  const stickyEvents = useMemo(() => new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' }), []);
 
-  const resetWidth = useCallback(() => {
-    const elem = document.getElementById('grw-subnav-fixed-container');
+  const initWidth = useCallback(() => {
+    const instance = fixedContainerRef.current;
 
-    if (elem == null || elem.parentNode == null) {
+    if (instance == null || instance.parentNode == null) {
       return;
     }
 
     // get parent width
-    const { clientWidth: width } = elem.parentNode;
+    const { clientWidth } = instance.parentNode;
     // update style
-    elem.style.width = `${width}px`;
+    setWidth(clientWidth);
   }, []);
 
+  const initVisible = useCallback(() => {
+    const elements = stickyEvents.stickyElements;
+
+    for (const elem of elements) {
+      const bool = stickyEvents.isSticking(elem);
+      if (bool) {
+        setVisible(bool);
+        break;
+      }
+    }
+
+  }, [stickyEvents]);
+
   // setup effect by resizing event
   useEffect(() => {
-    const resizeHandler = debounce(100, resetWidth);
+    const resizeHandler = debounce(100, initWidth);
 
     window.addEventListener('resize', resizeHandler);
 
@@ -46,7 +68,7 @@ const GrowiSubNavigationSwitcher = (props) => {
     return () => {
       window.removeEventListener('resize', resizeHandler);
     };
-  }, [resetWidth]);
+  }, [initWidth]);
 
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
@@ -57,7 +79,6 @@ const GrowiSubNavigationSwitcher = (props) => {
   useEffect(() => {
     // sticky
     // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' });
     const { stickySelector } = stickyEvents;
     const elem = document.querySelector(stickySelector);
     elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
@@ -66,16 +87,29 @@ const GrowiSubNavigationSwitcher = (props) => {
     return () => {
       elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
     };
-  }, [stickyChangeHandler]);
+  }, [stickyChangeHandler, stickyEvents]);
+
+  // update width when sidebar collapsing changed
+  useEffect(() => {
+    if (isSidebarCollapsed != null) {
+      setTimeout(initWidth, 300);
+    }
+  }, [isSidebarCollapsed, initWidth]);
 
-  // update width
+  // initialize
   useEffect(() => {
-    resetWidth();
-  });
+    initWidth();
+
+    // check sticky state several times
+    setTimeout(initVisible, 100);
+    setTimeout(initVisible, 300);
+    setTimeout(initVisible, 2000);
+
+  }, [initWidth, initVisible]);
 
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
-      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed">
+      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
         <GrowiSubNavigation isCompactMode />
       </div>
     </div>

+ 5 - 3
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -2,10 +2,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { usePageId } from '~/stores/context';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import BookmarkButton from '../BookmarkButton';
+import BookmarkButtons from '../BookmarkButtons';
 import LikeButtons from '../LikeButtons';
 import SubscribeButton from '../SubscribeButton';
 import PageManagement from '../Page/PageManagement';
@@ -15,6 +16,7 @@ const SubnavButtons = React.memo((props) => {
     appContainer, pageContainer, isCompactMode,
   } = props;
 
+  const { data: pageId } = usePageId();
   const { data: editorMode } = useEditorMode();
 
   /* eslint-disable react/prop-types */
@@ -23,7 +25,7 @@ const SubnavButtons = React.memo((props) => {
     return (
       <>
         <span>
-          <SubscribeButton pageId={pageContainer.state.pageId} />
+          <SubscribeButton pageId={pageId} />
         </span>
         {pageContainer.isAbleToShowLikeButtons && (
           <span>
@@ -31,7 +33,7 @@ const SubnavButtons = React.memo((props) => {
           </span>
         )}
         <span>
-          <BookmarkButton />
+          <BookmarkButtons pageId={pageId} />
         </span>
       </>
     );

+ 35 - 45
packages/app/src/components/PageEditor/AbstractEditor.jsx → packages/app/src/components/PageEditor/AbstractEditor.tsx

@@ -1,11 +1,20 @@
-/* eslint-disable react/no-unused-prop-types */
-
+/* eslint-disable @typescript-eslint/no-unused-vars */
 import React from 'react';
-import PropTypes from 'prop-types';
+import { ICodeMirror } from 'react-codemirror2';
+
+
+export interface AbstractEditorProps extends ICodeMirror {
+  value?: string;
+  isGfmMode?: boolean;
+  onScrollCursorIntoView?: (line: number) => void;
+  onSave?: () => Promise<void>;
+  onPasteFiles?: (event: Event) => void;
+  onCtrlEnter?: (event: Event) => void;
+}
 
-export default class AbstractEditor extends React.Component {
+export default class AbstractEditor<T extends AbstractEditorProps> extends React.Component<T, Record<string, unknown>> {
 
-  constructor(props) {
+  constructor(props: Readonly<T>) {
     super(props);
 
     this.forceToFocus = this.forceToFocus.bind(this);
@@ -20,91 +29,87 @@ export default class AbstractEditor extends React.Component {
     this.dispatchSave = this.dispatchSave.bind(this);
   }
 
-  forceToFocus() {
-  }
+  forceToFocus(): void {}
 
   /**
    * set new value
    */
-  setValue(newValue) {
-  }
+  setValue(_newValue: string): void {}
 
   /**
    * Enable/Disable GFM mode
-   * @param {bool} bool
+   * @param {bool} _bool
    */
-  setGfmMode(bool) {
-  }
+  setGfmMode(_bool: boolean): void {}
 
   /**
    * set caret position of codemirror
    * @param {string} number
    */
-  setCaretLine(line) {
-  }
+  setCaretLine(_line: number): void {}
 
   /**
    * scroll
-   * @param {number} line
+   * @param {number} _line
    */
-  setScrollTopByLine(line) {
-  }
+  setScrollTopByLine(_line: number): void {}
 
   /**
    * return strings from BOL(beginning of line) to current position
    */
-  getStrFromBol() {
+  getStrFromBol(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * return strings from current position to EOL(end of line)
    */
-  getStrToEol() {
+  getStrToEol(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * return strings from BOL(beginning of line) to current position
    */
-  getStrFromBolToSelectedUpperPos() {
+  getStrFromBolToSelectedUpperPos(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * replace Beggining Of Line to current position with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
-  replaceBolToCurrentPos(text) {
+  replaceBolToCurrentPos(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * replace the current line with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
-  replaceLine(text) {
+  replaceLine(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * insert text
-   * @param {string} text
+   * @param {string} _text
    */
-  insertText(text) {
+  insertText(_text: string): Error {
+    throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * insert line break to the current position
    */
-  insertLinebreak() {
+  insertLinebreak(): void {
     this.insertText('\n');
   }
 
   /**
    * dispatch onSave event
    */
-  dispatchSave() {
+  dispatchSave(): void {
     if (this.props.onSave != null) {
       this.props.onSave();
     }
@@ -114,7 +119,7 @@ export default class AbstractEditor extends React.Component {
    * dispatch onPasteFiles event
    * @param {object} event
    */
-  dispatchPasteFiles(event) {
+  dispatchPasteFiles(event: Event): void {
     if (this.props.onPasteFiles != null) {
       this.props.onPasteFiles(event);
     }
@@ -123,23 +128,8 @@ export default class AbstractEditor extends React.Component {
   /**
    * returns items(an array of react elements) in navigation bar for editor
    */
-  getNavbarItems() {
+  getNavbarItems(): null {
     return null;
   }
 
 }
-
-AbstractEditor.propTypes = {
-  value: PropTypes.string,
-  isGfmMode: PropTypes.bool,
-  onChange: PropTypes.func,
-  onScroll: PropTypes.func,
-  onScrollCursorIntoView: PropTypes.func,
-  onSave: PropTypes.func,
-  onPasteFiles: PropTypes.func,
-  onDragEnter: PropTypes.func,
-  onCtrlEnter: PropTypes.func,
-};
-AbstractEditor.defaultProps = {
-  isGfmMode: true,
-};

+ 4 - 3
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
 
 import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
+import { UnControlled as UncontrolledCodeMirror } from 'react-codemirror2';
 
 import { Button } from 'reactstrap';
-import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
 import { JSHINT } from 'jshint';
 
@@ -32,6 +32,7 @@ import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
+// import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 // Textlint
 window.JSHINT = JSHINT;
@@ -109,7 +110,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.state = {
       value: this.props.value,
-      isGfmMode: this.props.isGfmMode,
+      isGfmMode: this.props.isGfmMode ?? true,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
@@ -924,7 +925,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return (
       <React.Fragment>
 
-        <ReactCodeMirror
+        <UncontrolledCodeMirror
           ref={(c) => { this.cm = c }}
           className={additionalClasses}
           placeholder="search"

+ 282 - 0
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -0,0 +1,282 @@
+import React, {
+  useState, useEffect, FC, useRef,
+} from 'react';
+import PropTypes from 'prop-types';
+import { UserPicture } from '@growi/ui';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+import CodeMirror from 'codemirror/lib/codemirror';
+
+import PageContainer from '../../client/services/PageContainer';
+import AppContainer from '../../client/services/AppContainer';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import { useEditorMode } from '~/stores/ui';
+
+import { IRevisionOnConflict } from '../../interfaces/revision';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
+
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/merge/merge');
+require('codemirror/addon/merge/merge.css');
+const DMP = require('diff_match_patch');
+
+Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
+
+type ConflictDiffModalProps = {
+  isOpen: boolean | null;
+  onClose?: (() => void);
+  pageContainer: PageContainer;
+  appContainer: AppContainer;
+  markdownOnEdit: string;
+};
+
+type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
+  createdAt: string
+}
+
+export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
+  const { t } = useTranslation('');
+  const [resolvedRevision, setResolvedRevision] = useState<string>('');
+  const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
+  const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
+  const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
+
+  const { data: editorMode } = useEditorMode();
+
+  const uncontrolledRef = useRef<CodeMirror>(null);
+
+  const { pageContainer, appContainer } = props;
+
+  const currentTime: Date = new Date();
+
+  const request: IRevisionOnConflictWithStringDate = {
+    revisionId: '',
+    revisionBody: props.markdownOnEdit,
+    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
+    user: appContainer.currentUser,
+  };
+  const origin: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.revisionId || '',
+    revisionBody: pageContainer.state.markdown || '',
+    createdAt: pageContainer.state.updatedAt || '',
+    user: pageContainer.state.revisionAuthor,
+  };
+  const latest: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.remoteRevisionId || '',
+    revisionBody: pageContainer.state.remoteRevisionBody || '',
+    createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
+    user: pageContainer.state.lastUpdateUser,
+  };
+
+  useEffect(() => {
+    if (codeMirrorRef != null) {
+      CodeMirror.MergeView(codeMirrorRef, {
+        value: origin.revisionBody,
+        origLeft: request.revisionBody,
+        origRight: latest.revisionBody,
+        lineNumbers: true,
+        collapseIdentical: true,
+        showDifferences: true,
+        highlightDifferences: true,
+        connect: 'connect',
+        readOnly: true,
+        revertButtons: false,
+      });
+    }
+  }, [codeMirrorRef, origin.revisionBody, request.revisionBody, latest.revisionBody]);
+
+  const onClose = () => {
+    if (props.onClose != null) {
+      props.onClose();
+    }
+  };
+
+  const onResolveConflict = async() : Promise<void> => {
+    // disable button after clicked
+    setIsRevisionSelected(false);
+
+    const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
+
+    try {
+      await pageContainer.resolveConflict(codeMirrorVal, editorMode);
+      onClose();
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      pageContainer.showErrorToastr(error);
+    }
+
+  };
+
+  const onExpandModal = () => {
+    setIsModalExpanded(true);
+  };
+
+  const onContractModal = () => {
+    setIsModalExpanded(false);
+  };
+
+  const resizeAndCloseButtons = (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isModalExpanded}
+        expandWindow={onExpandModal}
+        contractWindow={onContractModal}
+      />
+      <button type="button" className="close text-white" onClick={onClose} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      isOpen={props.isOpen || false}
+      toggle={onClose}
+      backdrop="static"
+      className={`${isModalExpanded ? ' grw-modal-expanded' : ''}`}
+      size="xl"
+    >
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light align-items-center py-3" close={resizeAndCloseButtons}>
+        <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
+      </ModalHeader>
+      <ModalBody className="mx-4 my-1">
+        { props.isOpen
+        && (
+          <div className="row">
+            <div className="col-12 text-center mt-2 mb-4">
+              <h2 className="font-weight-bold">{t('modal_resolve_conflict.resolve_conflict_message')}</h2>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.requested_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={request.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {request.user.username}</p>
+                  <p className="my-0">{request.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.origin_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={origin.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {origin.user.username}</p>
+                  <p className="my-0">{origin.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.latest_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={latest.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {latest.user.username}</p>
+                  <p className="my-0">{latest.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-12" ref={(el) => { setCodeMirrorRef(el) }}></div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(request.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'mine' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(origin.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'origin' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(latest.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'theirs' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-12">
+              <div className="border border-dark">
+                <h3 className="font-weight-bold my-2 mx-2">{t('modal_resolve_conflict.selected_editable_revision')}</h3>
+                <UncontrolledCodeMirror
+                  ref={uncontrolledRef}
+                  value={resolvedRevision}
+                  options={{
+                    placeholder: t('modal_resolve_conflict.resolve_conflict_message'),
+                  }}
+                />
+              </div>
+            </div>
+          </div>
+        )}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={onResolveConflict}
+          disabled={!isRevisionselected}
+        >
+          {t('modal_resolve_conflict.resolve_and_save')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+ConflictDiffModal.propTypes = {
+  isOpen: PropTypes.bool,
+  onClose: PropTypes.func,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markdownOnEdit: PropTypes.string.isRequired,
+};
+
+ConflictDiffModal.defaultProps = {
+  isOpen: false,
+};

+ 100 - 83
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,6 +10,8 @@ import {
 import Dropzone from 'react-dropzone';
 
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import Cheatsheet from './Cheatsheet';
@@ -18,6 +20,7 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 import pasteHelper from './PasteHelper';
+import { ConflictDiffModal } from './ConflictDiffModal';
 
 class Editor extends AbstractEditor {
 
@@ -276,6 +279,7 @@ class Editor extends AbstractEditor {
     );
   }
 
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -286,88 +290,97 @@ class Editor extends AbstractEditor {
     const isMobile = this.props.isMobile;
 
     return (
-      <div style={flexContainer} className="editor-container">
-        <Dropzone
-          ref={(c) => { this.dropzone = c }}
-          accept={this.getAcceptableType()}
-          noClick
-          noKeyboard
-          multiple={false}
-          onDragLeave={this.dragLeaveHandler}
-          onDrop={this.dropHandler}
-        >
-          {({
-            getRootProps,
-            getInputProps,
-            isDragAccept,
-            isDragReject,
-          }) => {
-            return (
-              <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                { this.state.isComponentDidMount && this.renderNavbar() }
-
-                {/* for PC */}
-                { !isMobile && (
-                  <Subscribe to={[EditorContainer]}>
-                    { editorContainer => (
-                      // eslint-disable-next-line arrow-body-style
-                      <CodeMirrorEditor
-                        ref={(c) => { this.cmEditor = c }}
-                        indentSize={editorContainer.state.indentSize}
-                        editorOptions={editorContainer.state.editorOptions}
-                        isTextlintEnabled={editorContainer.state.isTextlintEnabled}
-                        textlintRules={editorContainer.state.textlintRules}
-                        onInitializeTextlint={editorContainer.retrieveEditorSettings}
-                        onPasteFiles={this.pasteFilesHandler}
-                        onDragEnter={this.dragEnterHandler}
-                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                        onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                        {...this.props}
-                      />
-                    )}
-                  </Subscribe>
-                )}
-
-                {/* for mobile */}
-                { isMobile && (
-                  <TextAreaEditor
-                    ref={(c) => { this.taEditor = c }}
-                    onPasteFiles={this.pasteFilesHandler}
-                    onDragEnter={this.dragEnterHandler}
-                    {...this.props}
-                  />
-                )}
-
-                <input {...getInputProps()} />
-              </div>
-            );
-          }}
-        </Dropzone>
-
-        { this.props.isUploadable
-          && (
-            <button
-              type="button"
-              className="btn btn-outline-secondary btn-block btn-open-dropzone"
-              onClick={this.addAttachmentHandler}
-            >
-              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-              Attach files
-              <span className="d-none d-sm-inline">
-              &nbsp;by dragging &amp; dropping,&nbsp;
-                <span className="btn-link">selecting them</span>,&nbsp;
-                or pasting from the clipboard.
-              </span>
-
-            </button>
-          )
-        }
-
-        { this.renderCheatsheetModal() }
-
-      </div>
+      <>
+        <div style={flexContainer} className="editor-container">
+          <Dropzone
+            ref={(c) => { this.dropzone = c }}
+            accept={this.getAcceptableType()}
+            noClick
+            noKeyboard
+            multiple={false}
+            onDragLeave={this.dragLeaveHandler}
+            onDrop={this.dropHandler}
+          >
+            {({
+              getRootProps,
+              getInputProps,
+              isDragAccept,
+              isDragReject,
+            }) => {
+              return (
+                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
+
+                  { this.state.isComponentDidMount && this.renderNavbar() }
+
+                  {/* for PC */}
+                  { !isMobile && (
+                    <Subscribe to={[EditorContainer]}>
+                      { editorContainer => (
+                        // eslint-disable-next-line arrow-body-style
+                        <CodeMirrorEditor
+                          ref={(c) => { this.cmEditor = c }}
+                          indentSize={editorContainer.state.indentSize}
+                          editorOptions={editorContainer.state.editorOptions}
+                          isTextlintEnabled={editorContainer.state.isTextlintEnabled}
+                          textlintRules={editorContainer.state.textlintRules}
+                          onInitializeTextlint={editorContainer.retrieveEditorSettings}
+                          onPasteFiles={this.pasteFilesHandler}
+                          onDragEnter={this.dragEnterHandler}
+                          onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                          onAddAttachmentButtonClicked={this.addAttachmentHandler}
+                          {...this.props}
+                        />
+                      )}
+                    </Subscribe>
+                  )}
+
+                  {/* for mobile */}
+                  { isMobile && (
+                    <TextAreaEditor
+                      ref={(c) => { this.taEditor = c }}
+                      onPasteFiles={this.pasteFilesHandler}
+                      onDragEnter={this.dragEnterHandler}
+                      {...this.props}
+                    />
+                  )}
+
+                  <input {...getInputProps()} />
+                </div>
+              );
+            }}
+          </Dropzone>
+
+          { this.props.isUploadable
+            && (
+              <button
+                type="button"
+                className="btn btn-outline-secondary btn-block btn-open-dropzone"
+                onClick={this.addAttachmentHandler}
+              >
+                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+                Attach files
+                <span className="d-none d-sm-inline">
+                &nbsp;by dragging &amp; dropping,&nbsp;
+                  <span className="btn-link">selecting them</span>,&nbsp;
+                  or pasting from the clipboard.
+                </span>
+
+              </button>
+            )
+          }
+
+          { this.renderCheatsheetModal() }
+
+        </div>
+        <ConflictDiffModal
+          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
+          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
+          appContainer={this.props.appContainer}
+          pageContainer={this.props.pageContainer}
+          markdownOnEdit={this.props.value}
+        />
+      </>
     );
   }
 
@@ -375,6 +388,8 @@ class Editor extends AbstractEditor {
 
 Editor.propTypes = Object.assign({
   noCdn: PropTypes.bool,
+  // this value is markdown
+  value: PropTypes.string,
   isMobile: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
@@ -382,6 +397,8 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 }, AbstractEditor.propTypes);
 
-export default withUnstatedContainers(Editor, [EditorContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, PageContainer, AppContainer]);

+ 52 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -26,14 +26,22 @@ class PageStatusAlert extends React.Component {
     };
 
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
+    this.getContentsForRevisionOutdated = this.getContentsForRevisionOutdated.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
+    this.onClickResolveConflict = this.onClickResolveConflict.bind(this);
   }
 
   refreshPage() {
     window.location.reload();
   }
 
+  onClickResolveConflict() {
+    this.props.pageContainer.setState({
+      isConflictDiffModalOpen: true,
+    });
+  }
+
   getContentsForSomeoneEditingAlert() {
     const { t } = this.props;
     return [
@@ -49,6 +57,45 @@ class PageStatusAlert extends React.Component {
     ];
   }
 
+  getContentsForRevisionOutdated() {
+    const { t, appContainer, pageContainer } = this.props;
+    const pageEditor = appContainer.getComponentInstance('PageEditor');
+
+    let markdownOnEdit = '';
+    let isConflictOnEdit = false;
+
+    if (pageEditor != null) {
+      markdownOnEdit = pageEditor.getMarkdown();
+      isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
+    }
+
+    return [
+      ['bg-warning', 'd-hackmd-none'],
+      <>
+        <i className="icon-fw icon-pencil"></i>
+        {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
+      </>,
+      <>
+        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
+          <i className="icon-fw icon-reload mr-1"></i>
+          {t('Load latest')}
+        </button>
+        {isConflictOnEdit
+          && (
+            <button
+              type="button"
+              onClick={this.onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <i className="fa fa-fw fa-file-text-o mr-1"></i>
+              {t('modal_resolve_conflict.resolve_conflict')}
+            </button>
+          )
+        }
+      </>,
+    ];
+  }
+
   getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
     return [
@@ -92,8 +139,12 @@ class PageStatusAlert extends React.Component {
 
     let getContentsFunc = null;
 
+    // when conflicting on save
+    if (isRevisionOutdated) {
+      getContentsFunc = this.getContentsForRevisionOutdated;
+    }
     // when remote revision is newer than both
-    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+    else if (isHackmdDocumentOutdated && isRevisionOutdated) {
       getContentsFunc = this.getContentsForUpdatedAlert;
     }
     // when someone editing with HackMD

+ 8 - 0
packages/app/src/components/SavePageControls.jsx

@@ -66,6 +66,14 @@ class SavePageControls extends React.Component {
     catch (error) {
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
+      if (error.code === 'conflict') {
+        pageContainer.setState({
+          remoteRevisionId: error.data.revisionId,
+          remoteRevisionBody: error.data.revisionBody,
+          remoteRevisionUpdateAt: error.data.createdAt,
+          lastUpdateUser: error.data.user,
+        });
+      }
     }
   }
 

+ 1 - 3
packages/app/src/components/SubscribeButton.tsx

@@ -1,7 +1,5 @@
 import React, { FC } from 'react';
 
-
-import { Types } from 'mongoose';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { useSWRxSubscriptionStatus } from '../stores/page';
@@ -12,7 +10,7 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import { useIsGuestUser } from '~/stores/context';
 
 type Props = {
-  pageId: Types.ObjectId,
+  pageId: string,
 };
 
 const SubscribeButton: FC<Props> = (props: Props) => {

+ 58 - 0
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -0,0 +1,58 @@
+import React, { forwardRef, ReactNode, Ref } from 'react';
+import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
+import { Container, Subscribe } from 'unstated';
+import EditorContainer from '~/client/services/EditorContainer';
+import AbstractEditor, { AbstractEditorProps } from '~/components/PageEditor/AbstractEditor';
+
+window.CodeMirror = require('codemirror');
+require('codemirror/addon/display/placeholder');
+require('~/client/util/codemirror/gfm-growi.mode');
+
+export interface UncontrolledCodeMirrorProps extends AbstractEditorProps {
+  value: string;
+  options?: ICodeMirror['options'];
+  isGfmMode?: boolean;
+  indentSize?: number;
+  lineNumbers?: boolean;
+}
+
+interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
+  editorContainer: Container<EditorContainer>;
+  forwardedRef: Ref<UncontrolledCodeMirrorCore>;
+}
+
+class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
+
+  render(): ReactNode {
+
+    const {
+      value, isGfmMode, indentSize, lineNumbers, editorContainer, options, forwardedRef, ...rest
+    } = this.props;
+
+    const { editorOptions } = editorContainer.state;
+
+    return (
+      <CodeMirror
+        ref={forwardedRef}
+        value={value}
+        options={{
+          lineNumbers: lineNumbers ?? true,
+          mode: isGfmMode ? 'gfm-growi' : undefined,
+          theme: editorOptions.theme,
+          styleActiveLine: editorOptions.styleActiveLine,
+          tabSize: 4,
+          indentUnit: indentSize,
+          ...options,
+        }}
+        {...rest}
+      />
+    );
+  }
+
+}
+
+export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, UncontrolledCodeMirrorProps>((props, ref) => (
+  <Subscribe to={[EditorContainer]}>
+    {(EditorContainer: Container<EditorContainer>) => <UncontrolledCodeMirrorCore {...props} forwardedRef={ref} editorContainer={EditorContainer} />}
+  </Subscribe>
+));

+ 7 - 0
packages/app/src/interfaces/bookmarks.ts

@@ -0,0 +1,7 @@
+import { IUser } from '~/interfaces/user';
+
+export interface IBookmarksInfo {
+  isBookmarked: boolean
+  sumOfBookmarks: number
+  bookmarkedUsers: IUser[]
+}

+ 7 - 0
packages/app/src/interfaces/revision.ts

@@ -7,3 +7,10 @@ export type IRevision = {
   createdAt: Date,
   updatedAt: Date,
 }
+
+export type IRevisionOnConflict = {
+  revisionId: string,
+  revisionBody: string,
+  createdAt: Date,
+  user: IUser
+}

+ 3 - 0
packages/app/src/server/crowi/express-init.js

@@ -4,6 +4,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const path = require('path');
   const express = require('express');
+  const compression = require('compression');
   const helmet = require('helmet');
   const bodyParser = require('body-parser');
   const cookieParser = require('cookie-parser');
@@ -53,6 +54,8 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
     });
 
+  app.use(compression());
+
   app.use(helmet({
     contentSecurityPolicy: false,
     expectCt: false,

+ 0 - 15
packages/app/src/server/models/user.js

@@ -187,21 +187,6 @@ module.exports = function(crowi) {
     return userData;
   };
 
-  userSchema.methods.canDeleteCompletely = function(creatorId) {
-    const pageCompleteDeletionAuthority = crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
-    if (this.admin) {
-      return true;
-    }
-    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
-      return true;
-    }
-    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
-      return (this._id.equals(creatorId));
-    }
-
-    return false;
-  };
-
   userSchema.methods.updateApiToken = async function() {
     const self = this;
 

+ 5 - 1
packages/app/src/server/models/vo/s2c-message.js

@@ -10,15 +10,19 @@ class S2cMessagePageUpdated {
     const serializedPage = serializePageSecurely(page);
 
     const {
-      _id, revision, revisionHackmdSynced, hasDraftOnHackmd,
+      _id, revision, updatedAt, revisionHackmdSynced, hasDraftOnHackmd,
     } = serializedPage;
 
     this.pageId = _id;
     this.revisionId = revision;
+    this.revisionBody = page.revision.body;
+    this.revisionUpdateAt = updatedAt;
     this.revisionIdHackmdSynced = revisionHackmdSynced;
     this.hasDraftOnHackmd = hasDraftOnHackmd;
 
     if (user != null) {
+      this.remoteLastUpdateUser = user;
+      // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       this.lastUpdateUsername = user.name;
     }
   }

+ 8 - 2
packages/app/src/server/routes/apiv3/bookmarks.js

@@ -113,10 +113,16 @@ module.exports = (crowi) => {
     const responsesParams = {};
 
     try {
-      responsesParams.sumOfBookmarks = await Bookmark.countByPageId(pageId);
+      const bookmarks = await Bookmark.find({ page: pageId }).populate('user');
+      let users = [];
+      if (bookmarks.length > 0) {
+        users = bookmarks.map(bookmark => serializeUserSecurely(bookmark.user));
+      }
+      responsesParams.sumOfBookmarks = bookmarks.length;
+      responsesParams.bookmarkedUsers = users;
     }
     catch (err) {
-      logger.error('get-bookmark-count-failed', err);
+      logger.error('get-bookmark-document-failed', err);
       return res.apiv3Err(err, 500);
     }
 

+ 4 - 4
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -23,8 +23,8 @@ module.exports = (crowi) => {
   const validator = {
     password: [
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 6 })
-        .withMessage('password must be at least 6 characters long'),
+        .isLength({ min: 8 })
+        .withMessage('password must be at least 8 characters long'),
       // checking if password confirmation matches password
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
@@ -35,7 +35,7 @@ module.exports = (crowi) => {
 
   const apiLimiter = rateLimit({
     windowMs: 15 * 60 * 1000, // 15 minutes
-    max: 5, // limit each IP to 5 requests per windowMs
+    max: 10, // limit each IP to 10 requests per windowMs
     message:
       'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
@@ -81,7 +81,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/', injectResetOrderByTokenMiddleware, async(req, res) => {
+  router.put('/', apiLimiter, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');

+ 2 - 2
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -86,8 +86,8 @@ module.exports = (crowi) => {
     password: [
       body('oldPassword').isString(),
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 6 })
-        .withMessage('password must be at least 6 characters long'),
+        .isLength({ min: 8 })
+        .withMessage('password must be at least 8 characters long'),
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
           return (value === req.body.newPassword);

+ 2 - 2
packages/app/src/server/routes/index.js

@@ -13,7 +13,7 @@ const rateLimit = require('express-rate-limit');
 
 const apiLimiter = rateLimit({
   windowMs: 15 * 60 * 1000, // 15 minutes
-  max: 5, // limit each IP to 5 requests per windowMs
+  max: 10, // limit each IP to 5 requests per windowMs
   message:
     'Too many requests sent from this IP, please try again after 15 minutes',
 });
@@ -61,7 +61,7 @@ module.exports = function(crowi, app) {
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
   app.get('/login/invited'            , applicationInstalled, login.invited);
   app.post('/login/activateInvited'   , applicationInstalled, form.invited                         , csrf, login.invited);
-  app.post('/login'                   , applicationInstalled, form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+  app.post('/login'                   , apiLimiter, applicationInstalled, form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
   app.post('/register'                , applicationInstalled, form.register                        , csrf, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);

+ 10 - 3
packages/app/src/server/routes/page.js

@@ -828,9 +828,17 @@ module.exports = function(crowi, app) {
     }
 
     // check revision
+    const Revision = crowi.model('Revision');
     let page = await Page.findByIdAndViewer(pageId, req.user);
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
+      const latestRevision = await Revision.findById(page.revision).populate('author');
+      const returnLatestRevision = {
+        revisionId: latestRevision._id.toString(),
+        revisionBody: xss.process(latestRevision.body),
+        createdAt: latestRevision.createdAt,
+        user: serializeUserSecurely(latestRevision.author),
+      };
+      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
 
     const options = { isSyncRevisionToHackmd };
@@ -839,7 +847,6 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    const Revision = crowi.model('Revision');
     const previousRevision = await Revision.findById(revisionId);
     try {
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
@@ -1087,7 +1094,7 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
-        if (!req.user.canDeleteCompletely(page.creator)) {
+        if (!crowi.pageService.canDeleteCompletely(page.creator, req.user)) {
           return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
         }
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);

+ 12 - 0
packages/app/src/server/service/config-loader.ts

@@ -403,6 +403,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.NUMBER,
     default: 3,
   },
+  OIDC_CLIENT_CLOCK_TOLERANCE: {
+    ns: 'crowi',
+    key: 'security:passport-oidc:oidcClientClockTolerance',
+    type: ValueType.NUMBER,
+    default: 60,
+  },
+  OIDC_ISSUER_TIMEOUT_OPTION: {
+    ns: 'crowi',
+    key: 'security:passport-oidc:oidcIssuerTimeoutOption',
+    type: ValueType.NUMBER,
+    default: 5000,
+  },
   S3_REFERENCE_FILE_WITH_RELAY_MODE: {
     ns:      'crowi',
     key:     'aws:referenceFileWithRelayMode',

+ 16 - 1
packages/app/src/server/service/page.js

@@ -100,6 +100,21 @@ class PageService {
     });
   }
 
+  canDeleteCompletely(creatorId, operator) {
+    const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
+    if (operator.admin) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
+      return (operator._id.equals(creatorId));
+    }
+
+    return false;
+  }
+
   async findPageAndMetaDataByViewer({ pageId, path, user }) {
 
     const Page = this.crowi.model('Page');
@@ -132,7 +147,7 @@ class PageService {
     result.isCreatable = false;
     result.isDeletable = isDeletablePage(path);
     result.isDeleted = page.isDeleted();
-    result.canDeleteCompletely = user != null && user.canDeleteCompletely(page.creator);
+    result.canDeleteCompletely = user != null && this.canDeleteCompletely(page.creator, user);
 
     return result;
   }

+ 17 - 6
packages/app/src/server/service/passport.ts

@@ -12,7 +12,7 @@ import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-sa
 import { BasicStrategy } from 'passport-http';
 
 import { IncomingMessage } from 'http';
-import got from 'got';
+import axiosRetry from 'axios-retry';
 import pRetry from 'p-retry';
 import loggerFactory from '~/utils/logger';
 
@@ -622,7 +622,8 @@ class PassportService implements S2sMessageHandlable {
 
     // setup client
     // extend oidc request timeouts
-    OIDCIssuer.defaultHttpOptions = { timeout: 5000 };
+    const OIDC_ISSUER_TIMEOUT_OPTION = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcIssuerTimeoutOption');
+    OIDCIssuer.defaultHttpOptions = { timeout: OIDC_ISSUER_TIMEOUT_OPTION };
     const issuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
     const clientId = configManager.getConfig('crowi', 'security:passport-oidc:clientId');
     const clientSecret = configManager.getConfig('crowi', 'security:passport-oidc:clientSecret');
@@ -677,7 +678,8 @@ class PassportService implements S2sMessageHandlable {
       });
       // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
       // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
-      client.CLOCK_TOLERANCE = 5;
+      const OIDC_CLIENT_CLOCK_TOLERANCE = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcClientClockTolerance');
+      client.CLOCK_TOLERANCE = OIDC_CLIENT_CLOCK_TOLERANCE;
       passport.use('oidc', new OidcStrategy(
         {
           client,
@@ -719,8 +721,17 @@ class PassportService implements S2sMessageHandlable {
  */
   async isOidcHostReachable(issuerHost) {
     try {
-      const response = await got(issuerHost, { retry: { limit: 3 } });
-      return response.statusCode === 200;
+      const client = require('axios').default;
+      axiosRetry(client, {
+        retries: 3,
+      });
+      const response = await client.get(`${issuerHost}/.well-known/openid-configuration`);
+      // Check for valid OIDC Issuer configuration
+      if (!response.data.issuer) {
+        logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
+        return false;
+      }
+      return true;
     }
     catch (err) {
       logger.error('OidcStrategy: issuer host unreachable:', err.code);
@@ -739,7 +750,7 @@ class PassportService implements S2sMessageHandlable {
     const OIDC_DISCOVERY_RETRIES = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:discoveryRetries');
     const oidcIssuerHostReady = await this.isOidcHostReachable(issuerHost);
     if (!oidcIssuerHostReady) {
-      logger.error('OidcStrategy: setup failed: OIDC Issur host unreachable');
+      logger.error('OidcStrategy: setup failed');
       return;
     }
     const oidcIssuer = await pRetry(async() => {

+ 2 - 3
packages/app/src/server/service/search.ts

@@ -1,4 +1,3 @@
-import RE2 from 're2';
 import xss from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
@@ -198,8 +197,8 @@ class SearchService implements SearchQueryParser, SearchResolver {
   }
 
   async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
-    const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
-    const replaceRegexp = new RE2(/\[nq:|\]/g);
+    const regexp = new RegExp(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
+    const replaceRegexp = new RegExp(/\[nq:|\]/g);
 
     const queryString = normalizeQueryString(_queryString);
 

+ 2 - 1
packages/app/src/server/util/apiResponse.js

@@ -1,11 +1,12 @@
 function ApiResponse() {
 }
 
-ApiResponse.error = function(err, code) {
+ApiResponse.error = function(err, code, data) {
   const result = {};
 
   result.ok = false;
   result.code = code;
+  result.data = data;
 
   if (err instanceof Error) {
     result.error = err.toString();

+ 2 - 0
packages/app/src/server/util/swigFunctions.js

@@ -13,6 +13,7 @@ module.exports = function(crowi, req, locals) {
     appService,
     aclService,
     customizeService,
+    pageService,
   } = crowi;
   debug('initializing swigFunctions');
 
@@ -70,6 +71,7 @@ module.exports = function(crowi, req, locals) {
   locals.aclService = aclService;
   locals.customizeService = customizeService;
   locals.passportService = passportService;
+  locals.pageService = pageService;
   locals.pathUtils = pathUtils;
 
   locals.noCdn = function() {

+ 1 - 1
packages/app/src/server/views/widget/page_content.html

@@ -15,7 +15,7 @@
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
-  data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
+  data-page-is-able-to-delete-completely="{% if pageService.canDeleteCompletely(page.creator._id, user) %}true{% else %}false{% endif %}"
   data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-created-at="{{ page.createdAt|datetz('Y/m/d H:i:s') }}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"

+ 21 - 0
packages/app/src/stores/bookmarks.tsx

@@ -0,0 +1,21 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { Types } from 'mongoose';
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IBookmarksInfo } from '~/interfaces/bookmarks';
+
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxBookmarksInfo = <Data, Error>(pageId: Types.ObjectId):SWRResponse<IBookmarksInfo, Error> => {
+  return useSWR(
+    ['/bookmarks/info', pageId],
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then((response) => {
+      return {
+        isBookmarked: response.data.isBookmarked,
+        sumOfBookmarks: response.data.sumOfBookmarks,
+        bookmarkedUsers: response.data.bookmarkedUsers,
+      };
+    }),
+  );
+};

+ 2 - 2
packages/app/src/stores/context.tsx

@@ -22,8 +22,8 @@ export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<
 };
 
 
-export const usePageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageId', initialData ?? null);
+export const usePageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('pageId', initialData ?? null);
 };
 
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {

+ 4 - 2
packages/app/src/stores/page.tsx

@@ -47,14 +47,16 @@ export const useSWRxPageList = (
   );
 };
 
+type GetSubscriptionStatusResult = { subscribing: boolean };
+
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxSubscriptionStatus = <Data, Error>(pageId: Types.ObjectId): SWRResponse<{status: boolean | null}, Error> => {
+export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRResponse<{status: boolean | null}, Error> => {
   const { data: isGuestUser } = useIsGuestUser();
 
   const key = isGuestUser === false ? ['/page/subscribe', pageId] : null;
   return useSWR(
     key,
-    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then((response) => {
+    (endpoint, pageId) => apiv3Get<GetSubscriptionStatusResult>(endpoint, { pageId }).then((response) => {
       return {
         status: response.data.subscribing,
       };

+ 33 - 22
packages/app/src/stores/ui.tsx

@@ -51,15 +51,13 @@ export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   return useStaticSWR(key, null, configuration);
 };
 
-
-const updateBodyClassesForEditorMode = (newEditorMode: EditorMode) => {
+const updateBodyClassesByEditorMode = (newEditorMode: EditorMode) => {
   switch (newEditorMode) {
     case EditorMode.View:
       $('body').removeClass('on-edit');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('hackmd');
       $('body').removeClass('pathname-sidebar');
-      window.history.replaceState(null, '', window.location.pathname);
       break;
     case EditorMode.Editor:
       $('body').addClass('on-edit');
@@ -69,38 +67,50 @@ const updateBodyClassesForEditorMode = (newEditorMode: EditorMode) => {
       if (window.location.pathname === '/Sidebar') {
         $('body').addClass('pathname-sidebar');
       }
-      window.location.hash = '#edit';
       break;
     case EditorMode.HackMD:
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('pathname-sidebar');
-      window.location.hash = '#hackmd';
       break;
   }
 };
 
-export const useEditorModeByHash = (): SWRResponse<EditorMode, Error> => {
-  return useSWRImmutable(
-    ['initialEditorMode', window.location.hash],
-    (key: Key, hash: string) => {
-      switch (hash) {
-        case '#edit':
-          return EditorMode.Editor;
-        case '#hackmd':
-          return EditorMode.HackMD;
-        default:
-          return EditorMode.View;
-      }
-    },
-  );
+const updateHashByEditorMode = (newEditorMode: EditorMode) => {
+  const { pathname } = window.location;
+
+  switch (newEditorMode) {
+    case EditorMode.View:
+      window.history.replaceState(null, '', pathname);
+      break;
+    case EditorMode.Editor:
+      window.history.replaceState(null, '', `${pathname}#edit`);
+      break;
+    case EditorMode.HackMD:
+      window.history.replaceState(null, '', `${pathname}#hackmd`);
+      break;
+  }
+};
+
+export const determineEditorModeByHash = (): EditorMode => {
+  const { hash } = window.location;
+
+  switch (hash) {
+    case '#edit':
+      return EditorMode.Editor;
+    case '#hackmd':
+      return EditorMode.HackMD;
+    default:
+      return EditorMode.View;
+  }
 };
 
 let isEditorModeLoaded = false;
 export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   const { data: _isEditable } = useIsEditable();
-  const { data: editorModeByHash } = useEditorModeByHash();
+
+  const editorModeByHash = determineEditorModeByHash();
 
   const isLoading = _isEditable === undefined;
   const isEditable = !isLoading && _isEditable;
@@ -115,7 +125,7 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   // initial updating
   if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
     if (isEditable) {
-      updateBodyClassesForEditorMode(swrResponse.data);
+      updateBodyClassesByEditorMode(swrResponse.data);
     }
     isEditorModeLoaded = true;
   }
@@ -128,7 +138,8 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
       if (!isEditable) {
         return Promise.resolve(EditorMode.View); // fixed if not editable
       }
-      updateBodyClassesForEditorMode(editorMode);
+      updateBodyClassesByEditorMode(editorMode);
+      updateHashByEditorMode(editorMode);
       return swrResponse.mutate(editorMode, shouldRevalidate);
     },
   };

+ 1 - 1
packages/app/src/styles/_sidebar.scss

@@ -104,7 +104,7 @@
         width: 240px;
         height: 100%;
         &:not(.dragging) {
-          transition: width 300ms cubic-bezier(0.2, 0, 0, 1) 0s;
+          transition: width 200ms cubic-bezier(0.2, 0, 0, 1) 0s;
         }
         will-change: width;
         .grw-contextual-navigation-child {

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [
@@ -16,7 +16,6 @@
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "lint:js": "eslint **/*.{js,ts}",
-    "lint:styles": "stylelint src/styles/scss/**/*.scss",
     "lint": "npm-run-all -p lint:*",
     "test": "jest --verbose"
   },

+ 2 - 3
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [
@@ -17,7 +17,7 @@
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/**/*.scss",
+    "lint:styles": "stylelint src/**/*.scss src/**/*.css",
     "lint": "run-p lint:*",
     "test": ""
   },
@@ -31,7 +31,6 @@
   },
   "devDependencies": {
     "npm-run-all": "^4.1.5",
-    "prettier-stylelint": "^0.4.2",
     "react": "^16.8.3",
     "react-dom": "^16.8.3"
   }

+ 2 - 2
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [
@@ -17,7 +17,7 @@
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/**/*.scss",
+    "lint:styles": "stylelint src/**/*.scss src/**/*.css",
     "lint": "run-p lint:*",
     "test": ""
   },

+ 1 - 3
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [
@@ -22,8 +22,6 @@
   },
   "devDependencies": {
     "browser-bunyan": "^1.6.3",
-    "stylelint": "^14.0.1",
-    "stylelint-config-recess-order": "^2.0.1",
     "tsc-alias": "^1.2.9"
   }
 }

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 49 - 36
packages/slackbot-proxy/docker/Dockerfile

@@ -1,46 +1,60 @@
-# syntax = docker/dockerfile:1.2
+# syntax = docker/dockerfile:1
 
 ##
-## deps-resolver-base
+## packages-json-picker
 ##
-FROM node:14-slim AS deps-resolver-base
+FROM node:14-slim AS packages-json-picker
 
-ENV appDir /opt
-
-WORKDIR ${appDir}
-COPY ./package.json ./
-COPY ./yarn.lock ./
-COPY ./lerna.json ./
-COPY ./packages/slack/package.json ./packages/slack/package.json
-COPY ./packages/slackbot-proxy/package.json ./packages/slackbot-proxy/package.json
-
-# setup
-RUN yarn config set network-timeout 300000
+ENV optDir /opt
 
+WORKDIR ${optDir}
+COPY ["package.json", "yarn.lock", "lerna.json", "./"]
+COPY packages packages
+# Find and remove non-package.json files
+RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
 
 
 ##
 ## deps-resolver-dev
 ##
-FROM deps-resolver-base AS deps-resolver-dev
-RUN npx lerna bootstrap
+FROM node:14-slim AS deps-resolver-dev
+
+ENV optDir /opt
+
+WORKDIR ${optDir}
+
+# copy files
+COPY --from=packages-json-picker ${optDir} .
+
+# setup
+RUN yarn config set network-timeout 300000
+RUN npx lerna bootstrap -- --frozen-lockfile
 
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/slackbot-proxy/node_modules \
-  packages/slack/node_modules
+RUN tar -cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
+
 
 
 ##
 ## deps-resolver-prod
 ##
-FROM deps-resolver-base AS deps-resolver-prod
+FROM node:14-slim AS deps-resolver-prod
+
+ENV optDir /opt
+
+WORKDIR ${optDir}
+COPY ["package.json", "yarn.lock", "lerna.json", "./"]
+COPY ./packages/slack/package.json ./packages/slack/package.json
+COPY ./packages/slackbot-proxy/package.json ./packages/slackbot-proxy/package.json
+
 RUN npx lerna bootstrap -- --production
 # make artifacts
-RUN tar cf dependencies.tar \
+RUN tar -cf dependencies.tar \
   node_modules \
-  packages/slack/node_modules \
-  packages/slackbot-proxy/node_modules
+  packages/*/node_modules
+
 
 
 ##
@@ -48,21 +62,19 @@ RUN tar cf dependencies.tar \
 ##
 FROM node:14-slim AS builder
 
-ENV appDir /opt
+ENV optDir /opt
 
-WORKDIR ${appDir}
+WORKDIR ${optDir}
 
 # copy dependent packages
 COPY --from=deps-resolver-dev \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${optDir}/node_modules.tar ${optDir}/
 
 # extract node_modules.tar
-RUN tar xf node_modules.tar
+RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
-COPY ./package.json ./
-COPY ./lerna.json ./
-COPY ./tsconfig.base.json ./
+COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
 COPY packages/slack packages/slack
 COPY packages/slackbot-proxy packages/slackbot-proxy
@@ -71,7 +83,7 @@ COPY packages/slackbot-proxy packages/slackbot-proxy
 RUN yarn lerna run build
 
 # make artifacts
-RUN tar cf packages.tar \
+RUN tar -cf packages.tar \
   packages/slack/package.json \
   packages/slack/dist \
   packages/slackbot-proxy/package.json \
@@ -88,13 +100,14 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production
 
-ENV appDir /opt
+ENV optDir /opt
+ENV appDir ${optDir}
 
 # copy artifacts
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${appDir}/dependencies.tar ${appDir}/
+  ${optDir}/dependencies.tar ${appDir}/
 COPY --from=builder --chown=node:node \
-  ${appDir}/packages.tar ${appDir}/
+  ${optDir}/packages.tar ${appDir}/
 
 RUN chown node:node ${appDir}
 
@@ -102,8 +115,8 @@ USER node
 
 # extract artifacts
 WORKDIR ${appDir}
-RUN tar xf dependencies.tar
-RUN tar xf packages.tar
+RUN tar -xf dependencies.tar
+RUN tar -xf packages.tar
 RUN rm dependencies.tar packages.tar
 
 WORKDIR ${appDir}/packages/slackbot-proxy

+ 5 - 6
packages/slackbot-proxy/docker/Dockerfile.dockerignore

@@ -1,6 +1,5 @@
-node_modules
-*/node_modules
-*/coverage
-*/dist
-*/Dockerfile
-*/*.dockerignore
+**/node_modules
+**/dist
+**/coverage
+**/Dockerfile
+**/*.dockerignore

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "4.5.5-slackbot-proxy.0",
+  "version": "4.5.9-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^4.5.5-RC.0",
+    "@growi/slack": "^4.5.9-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "4.5.5-RC.0",
+  "version": "4.5.9-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [
@@ -12,7 +12,6 @@
   ],
   "scripts": {
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/styles/scss/**/*.scss",
     "lint": "npm-run-all -p lint:*",
     "test": "jest --verbose"
   },

File diff suppressed because it is too large
+ 110 - 408
yarn.lock


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