From 73d85b988401604bb2f4a5e654dd3307c31744be Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Wed, 6 Nov 2024 10:55:35 +0100
Subject: [PATCH 1/6] :bug: Fix incorrect behavior of ::sm/vec and ::sm/set
 decoder

---
 common/src/app/common/schema.cljc         | 63 ++++++-----------------
 common/test/common_tests/schema_test.cljc | 60 +++++++++++++++++++++
 2 files changed, 77 insertions(+), 46 deletions(-)

diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc
index 5ffd035f6..9398f4986 100644
--- a/common/src/app/common/schema.cljc
+++ b/common/src/app/common/schema.cljc
@@ -275,7 +275,7 @@
               (= :set (:type s))
               (m/-collection-schema s)
 
-              (= :vec (:type s))
+              (= :vector (:type s))
               (m/-collection-schema s)
 
               :else
@@ -449,24 +449,12 @@
              (fn [value]
                (every? pred value)))
 
-
-           decode-string-child
-           (decoder kind string-transformer)
-
-           decode-string
+           decode
            (fn [v]
-             (let [v (if (string? v) (str/split v #"[\s,]+") v)
-                   x (comp xf:filter-word-strings (map decode-string-child))]
-               (into #{} x v)))
-
-           decode-json-child
-           (decoder kind json-transformer)
-
-           decode-json
-           (fn [v]
-             (let [v (if (string? v) (str/split v #"[\s,]+") v)
-                   x (comp xf:filter-word-strings (map decode-json-child))]
-               (into #{} x v)))
+             (if (string? v)
+               (let [v  (str/split v #"[\s,]+")]
+                 (into #{} xf:filter-word-strings v))
+               v))
 
            encode-string-child
            (encoder kind string-transformer)
@@ -475,15 +463,8 @@
            (fn [o]
              (if (set? o)
                (str/join ", " (map encode-string-child o))
-               o))
-
-           encode-json
-           (fn [o]
-             (if (set? o)
-               (vec o)
                o))]
 
-
        {:pred pred
         :empty #{}
         :type-properties
@@ -491,10 +472,10 @@
          :description "Set of Strings"
          :error/message "should be a set of strings"
          :gen/gen (-> kind sg/generator sg/set)
-         :decode/string decode-string
-         :decode/json decode-json
+         :decode/string decode
+         :decode/json decode
          :encode/string encode-string
-         :encode/json encode-json
+         :encode/json identity
          ::oapi/type "array"
          ::oapi/format "set"
          ::oapi/items {:type "string"}
@@ -542,23 +523,12 @@
              (fn [value]
                (every? pred value)))
 
-           decode-string-child
-           (decoder kind string-transformer)
-
-           decode-json-child
-           (decoder kind json-transformer)
-
-           decode-string
+           decode
            (fn [v]
-             (let [v (if (string? v) (str/split v #"[\s,]+") v)
-                   x (comp xf:filter-word-strings (map decode-string-child))]
-               (into #{} x v)))
-
-           decode-json
-           (fn [v]
-             (let [v (if (string? v) (str/split v #"[\s,]+") v)
-                   x (comp xf:filter-word-strings (map decode-json-child))]
-               (into #{} x v)))
+             (if (string? v)
+               (let [v (str/split v #"[\s,]+")]
+                 (into #{} xf:filter-word-strings v))
+               v))
 
            encode-string-child
            (encoder kind string-transformer)
@@ -575,9 +545,10 @@
          :description "Set of Strings"
          :error/message "should be a set of strings"
          :gen/gen (-> kind sg/generator sg/set)
-         :decode/string decode-string
-         :decode/json decode-json
+         :decode/string decode
+         :decode/json decode
          :encode/string encode-string
+         :encode/json identity
          ::oapi/type "array"
          ::oapi/format "set"
          ::oapi/items {:type "string"}
diff --git a/common/test/common_tests/schema_test.cljc b/common/test/common_tests/schema_test.cljc
index 05b2c2ae6..bbb97bbcd 100644
--- a/common/test/common_tests/schema_test.cljc
+++ b/common/test/common_tests/schema_test.cljc
@@ -39,3 +39,63 @@
     (let [schema    [::sm/set ::sm/email]
           value     (sg/generate schema)]
       (t/is (true? (sm/validate schema (sg/generate schema)))))))
+
+
+(t/deftest test-set-1
+  (let [candidate-1 "cff4b058-ca31-8197-8005-32aeb2377d83, cff4b058-ca31-8197-8005-32aeb2377d82"
+        candidate-2 ["cff4b058-ca31-8197-8005-32aeb2377d82",
+                     "cff4b058-ca31-8197-8005-32aeb2377d83"]
+        candidate-3 #{"cff4b058-ca31-8197-8005-32aeb2377d82", "cff4b058-ca31-8197-8005-32aeb2377d83"}
+        candidate-4 [#uuid "cff4b058-ca31-8197-8005-32aeb2377d82"
+                     #uuid "cff4b058-ca31-8197-8005-32aeb2377d83"]
+        candidate-5 #{#uuid "cff4b058-ca31-8197-8005-32aeb2377d82"
+                      #uuid "cff4b058-ca31-8197-8005-32aeb2377d83"}
+
+        expected    candidate-5
+
+        schema      [::sm/set ::sm/uuid]
+        decode-s    (sm/decoder schema sm/string-transformer)
+        decode-j    (sm/decoder schema sm/json-transformer)
+        encode-s    (sm/encoder schema sm/string-transformer)
+        encode-j    (sm/encoder schema sm/json-transformer)]
+
+
+    (t/is (= expected (decode-s candidate-1)))
+    (t/is (= expected (decode-s candidate-2)))
+    (t/is (= expected (decode-s candidate-3)))
+    (t/is (= expected (decode-s candidate-4)))
+    (t/is (= expected (decode-s candidate-5)))
+
+    (t/is (= candidate-1 (encode-s expected)))
+    (t/is (= candidate-3 (encode-j expected)))))
+
+
+(t/deftest test-vec-1
+  (let [candidate-1 "cff4b058-ca31-8197-8005-32aeb2377d83, cff4b058-ca31-8197-8005-32aeb2377d82"
+        candidate-2 ["cff4b058-ca31-8197-8005-32aeb2377d83",
+                     "cff4b058-ca31-8197-8005-32aeb2377d82"]
+        candidate-3 #{"cff4b058-ca31-8197-8005-32aeb2377d82", "cff4b058-ca31-8197-8005-32aeb2377d83"}
+        candidate-4 [#uuid "cff4b058-ca31-8197-8005-32aeb2377d83"
+                     #uuid "cff4b058-ca31-8197-8005-32aeb2377d82"]
+        candidate-5 #{#uuid "cff4b058-ca31-8197-8005-32aeb2377d82"
+                      #uuid "cff4b058-ca31-8197-8005-32aeb2377d83"}
+
+        expected    candidate-4
+
+        schema      [::sm/vec ::sm/uuid]
+        decode-s    (sm/decoder schema sm/string-transformer)
+        decode-j    (sm/decoder schema sm/json-transformer)
+        encode-s    (sm/encoder schema sm/string-transformer)
+        encode-j    (sm/encoder schema sm/json-transformer)]
+
+
+    (t/is (= expected (decode-s candidate-1)))
+    (t/is (= expected (decode-s candidate-2)))
+    (t/is (= expected (decode-s candidate-3)))
+    (t/is (= expected (decode-s candidate-4)))
+    (t/is (= expected (decode-s candidate-5)))
+
+    (t/is (= candidate-1 (encode-s expected)))
+    (t/is (= candidate-2 (encode-j expected)))))
+
+

From b27edb4259471b2cda1a90098b6a49e2a66c4af6 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Wed, 6 Nov 2024 10:56:39 +0100
Subject: [PATCH 2/6] :bug: Use proper schema for move-file rpc method

---
 backend/src/app/rpc/commands/management.clj | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj
index 223c5cb56..cad94b019 100644
--- a/backend/src/app/rpc/commands/management.clj
+++ b/backend/src/app/rpc/commands/management.clj
@@ -326,7 +326,7 @@
 (def ^:private
   schema:move-files
   [:map {:title "move-files"}
-   [:ids ::sm/set-of-uuid]
+   [:ids [::sm/set {:min 1} ::sm/uuid]]
    [:project-id ::sm/uuid]])
 
 (sv/defmethod ::move-files
@@ -335,7 +335,7 @@
    ::webhooks/event? true
    ::sm/params schema:move-files}
   [cfg {:keys [::rpc/profile-id] :as params}]
-  (db/tx-run! cfg #(move-files % (assoc params :profile-id profile-id))))
+  (db/tx-run! cfg move-files (assoc params :profile-id profile-id)))
 
 ;; --- COMMAND: Move project
 

From 23d3661ea59a23f5b586a310ff6b3a9ecbc48244 Mon Sep 17 00:00:00 2001
From: AzazelN28 <asakon28@gmail.com>
Date: Wed, 30 Oct 2024 15:37:15 +0100
Subject: [PATCH 3/6] :tada: Handle WebGL context state change

---
 frontend/src/app/main/data/render_wasm.cljs   | 17 +++++
 frontend/src/app/main/refs.cljs               |  6 ++
 .../app/main/ui/workspace/viewport_wasm.cljs  | 17 ++++-
 .../app/main/ui/workspace/viewport_wasm.scss  | 10 +++
 frontend/src/app/render_wasm.cljs             | 66 +++++++++++++++----
 frontend/src/app/util/debug.cljs              |  5 +-
 render-wasm/Cargo.lock                        | 18 +++++
 render-wasm/Cargo.toml                        |  2 +
 8 files changed, 125 insertions(+), 16 deletions(-)
 create mode 100644 frontend/src/app/main/data/render_wasm.cljs

diff --git a/frontend/src/app/main/data/render_wasm.cljs b/frontend/src/app/main/data/render_wasm.cljs
new file mode 100644
index 000000000..e55d98754
--- /dev/null
+++ b/frontend/src/app/main/data/render_wasm.cljs
@@ -0,0 +1,17 @@
+(ns app.main.data.render-wasm
+  (:require
+   [potok.v2.core :as ptk]))
+
+(defn context-lost
+  []
+  (ptk/reify ::context-lost
+    ptk/UpdateEvent
+    (update [_ state]
+      (update state :render-state #(assoc % :lost true)))))
+
+(defn context-restored
+  []
+  (ptk/reify ::context-restored
+    ptk/UpdateEvent
+    (update [_ state]
+      (update state :render-state #(dissoc % :lost)))))
diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs
index f0a8db187..9a8ac024a 100644
--- a/frontend/src/app/main/refs.cljs
+++ b/frontend/src/app/main/refs.cljs
@@ -114,6 +114,12 @@
 
 ;; ---- Workspace refs
 
+(def render-state
+  (l/derived :render-state st/state))
+
+(def render-context-lost?
+  (l/derived :lost render-state))
+
 (def workspace-local
   (l/derived :workspace-local st/state))
 
diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
index a6fd17f43..f5c6963cf 100644
--- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
@@ -111,6 +111,8 @@
         modifiers         (mf/deref refs/workspace-modifiers)
         text-modifiers    (mf/deref refs/workspace-text-modifier)
 
+        render-context-lost? (mf/deref refs/render-context-lost?)
+
         objects-modified  (mf/with-memo [base-objects text-modifiers modifiers]
                             (apply-modifiers-to-selected selected base-objects text-modifiers modifiers))
 
@@ -175,6 +177,8 @@
 
         mode-inspect?       (= options-mode :inspect)
 
+        on-render-restore-context #(.reload js/location)
+
         on-click          (actions/on-click hover selected edition drawing-path? drawing-tool space? selrect z?)
         on-context-menu   (actions/on-context-menu hover hover-ids read-only?)
         on-double-click   (actions/on-double-click hover hover-ids hover-top-frame-id drawing-path? base-objects edition drawing-tool z? read-only?)
@@ -277,9 +281,9 @@
              (p/fmap (fn [ready?]
                        (when ready?
                          (reset! canvas-init? true)
-                         (render.wasm/assign-canvas canvas)))))
+                         (render.wasm/setup-canvas canvas)))))
         (fn []
-          (render.wasm/clear-canvas))))
+          (render.wasm/dispose-canvas canvas))))
 
     (mf/with-effect [objects-modified canvas-init?]
       (when @canvas-init?
@@ -635,4 +639,11 @@
          {:objects base-objects
           :zoom zoom
           :vbox vbox
-          :bottom-padding (when palete-size (+ palete-size 8))}]]]]]))
+          :bottom-padding (when palete-size (+ palete-size 8))}]]]]
+
+     (when render-context-lost?
+       [:div {:id "context-lost" :class (stl/css :context-lost)}
+        [:h1 "GL Error Screen"]
+        [:button
+         {:on-click on-render-restore-context}
+         "Restore context"]])]))
diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.scss b/frontend/src/app/main/ui/workspace/viewport_wasm.scss
index 727a6c529..a83fde465 100644
--- a/frontend/src/app/main/ui/workspace/viewport_wasm.scss
+++ b/frontend/src/app/main/ui/workspace/viewport_wasm.scss
@@ -35,3 +35,13 @@
   right: 0;
   z-index: 10;
 }
+
+.context-lost {
+  position: fixed;
+  inset: 0;
+  z-index: 100;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: grid;
+  place-items: center;
+  cursor: default;
+}
diff --git a/frontend/src/app/render_wasm.cljs b/frontend/src/app/render_wasm.cljs
index 44086efd7..20c769d93 100644
--- a/frontend/src/app/render_wasm.cljs
+++ b/frontend/src/app/render_wasm.cljs
@@ -11,6 +11,10 @@
    [app.common.files.helpers :as cfh]
    [app.common.types.shape.impl]
    [app.config :as cf]
+   [app.main.data.render-wasm :as drw]
+   [app.main.store :as st]
+   [app.util.debug :as dbg]
+   [app.util.dom :as dom]
    [promesa.core :as p]))
 
 (def enabled?
@@ -82,29 +86,67 @@
        :stencil true
        :alpha true})
 
-(defn clear-canvas
-  []
-  ;; TODO: perform corresponding cleaning
-  )
+(defn init-skia
+  [canvas]
+  (let [init-fn (unchecked-get internal-module "_init")
+        state   (init-fn (.-width ^js canvas)
+                         (.-height ^js canvas))]
+    (set! internal-gpu-state state)))
 
-(defn assign-canvas
+;; NOTE: This function can be called externally
+;; by the button in the context lost component (shown
+;; in viewport-wasm) or called internally by
+;; on-webgl-context
+(defn restore-canvas
+  [canvas]
+  (st/emit! (drw/context-restored))
+  ;; We need to reinitialize skia when the
+  ;; context is restored.
+  (init-skia canvas))
+
+;; Handles both events: webglcontextlost and
+;; webglcontextrestored
+(defn on-webgl-context
+  [event]
+  (dom/prevent-default event)
+  (if (= (.-type event) "webglcontextlost")
+    (st/emit! (drw/context-lost))
+    (restore-canvas (dom/get-target event))))
+
+(defn dispose-canvas
+  [canvas]
+  ;; TODO: perform corresponding cleaning
+  (.removeEventListener canvas "webglcontextlost" on-webgl-context)
+  (.removeEventListener canvas "webglcontextrestored" on-webgl-context))
+
+(defn init-debug-webgl-context-state
+  [context]
+  (let [context-extension (.getExtension ^js context "WEBGL_lose_context")
+        info-extension (.getExtension ^js context "WEBGL_debug_renderer_info")]
+    (set! (.-penpotGL js/window) #js {:context context-extension
+                                      :renderer info-extension})
+    (js/console.log "WEBGL_lose_context" context-extension)
+    (js/console.log "WEBGL_debug_renderer_info" info-extension)))
+
+(defn setup-canvas
   [canvas]
   (let [gl      (unchecked-get internal-module "GL")
-        init-fn (unchecked-get internal-module "_init")
-
         context (.getContext ^js canvas "webgl2" canvas-options)
 
         ;; Register the context with emscripten
         handle  (.registerContext ^js gl context #js {"majorVersion" 2})
-        _       (.makeContextCurrent ^js gl handle)
+        _       (.makeContextCurrent ^js gl handle)]
 
-        ;; Initialize Skia
-        state   (init-fn (.-width ^js canvas)
-                         (.-height ^js canvas))]
+    (when (dbg/enabled? :gl-context)
+      (init-debug-webgl-context-state context))
+
+    (.addEventListener canvas "webglcontextlost" on-webgl-context)
+    (.addEventListener canvas "webglcontextrestored" on-webgl-context)
 
     (set! (.-width canvas) (.-clientWidth ^js canvas))
     (set! (.-height canvas) (.-clientHeight ^js canvas))
-    (set! internal-gpu-state state)))
+
+    (init-skia canvas)))
 
 (defonce module
   (->> (js/dynamicImport "/js/render_wasm.js")
diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs
index 094550cef..8342da71c 100644
--- a/frontend/src/app/util/debug.cljs
+++ b/frontend/src/app/util/debug.cljs
@@ -89,7 +89,10 @@
     :display-touched
 
     ;; Show some visual indicators for bool shape
-    :bool-shapes})
+    :bool-shapes
+
+    ;; Show some information about the WebGL context.
+    :gl-context})
 
 (defn enable!
   [option]
diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock
index f8079959d..7595770cd 100644
--- a/render-wasm/Cargo.lock
+++ b/render-wasm/Cargo.lock
@@ -96,6 +96,22 @@ version = "1.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
 
+[[package]]
+name = "emscripten-functions"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62c026cc030b24957ca45d9555f9fa241d6b3a01d725cd98a25924de249b840a"
+dependencies = [
+ "cc",
+ "emscripten-functions-sys",
+]
+
+[[package]]
+name = "emscripten-functions-sys"
+version = "4.1.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65715a5f07b03636d7cd5508a45d1b62486840cb7d91a66564a73f1d7aa70b79"
+
 [[package]]
 name = "equivalent"
 version = "1.0.1"
@@ -370,6 +386,8 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
 name = "render"
 version = "0.1.0"
 dependencies = [
+ "emscripten-functions",
+ "emscripten-functions-sys",
  "gl",
  "skia-safe",
 ]
diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml
index 66cff051d..a0de796ea 100644
--- a/render-wasm/Cargo.toml
+++ b/render-wasm/Cargo.toml
@@ -11,6 +11,8 @@ name = "render_wasm"
 path = "src/main.rs"
 
 [dependencies]
+emscripten-functions = "0.2.3"
+emscripten-functions-sys = "4.1.67"
 gl = "0.14.0"
 skia-safe = { version = "0.78.2", features = ["gl"] }
 

From 0c4b1cc4fc50787238990cbf675cdb4c26795fa9 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Thu, 7 Nov 2024 20:24:33 +0100
Subject: [PATCH 4/6] :paperclip: Update yarn.lock with text-editor dependency
 change

---
 frontend/yarn.lock | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 0255a0346..a4afad715 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1004,10 +1004,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@penpot/text-editor@penpot/penpot-text-editor#449e3322f3fa40b1318c9154afbbc7932a3cb766":
+"@penpot/text-editor@penpot/penpot-text-editor#a100aad8d0efcbb070bed9144dbd2782547e78ba":
   version: 0.0.0
-  resolution: "@penpot/text-editor@https://github.com/penpot/penpot-text-editor.git#commit=449e3322f3fa40b1318c9154afbbc7932a3cb766"
-  checksum: 10c0/377fbd1fccc91ce532356601a27fe11afe19f169748127884f39a6b231a037e7e1e8b401149062e39219715901933771b7d752accaa52682fb141889c22dd1d3
+  resolution: "@penpot/text-editor@https://github.com/penpot/penpot-text-editor.git#commit=a100aad8d0efcbb070bed9144dbd2782547e78ba"
+  checksum: 10c0/328c827cd740c5e05df678083cfb1d2b6d006b56523daa0bd2a3c2936a0490a2ae4d0e69a3aec428674609a22a5fafdd5600aae1399cb3f4ed5b80e497c74a5c
   languageName: node
   linkType: hard
 
@@ -4393,7 +4393,7 @@ __metadata:
     "@penpot/hljs": "portal:./vendor/hljs"
     "@penpot/mousetrap": "portal:./vendor/mousetrap"
     "@penpot/svgo": "penpot/svgo#c6fba7a4dcfbc27b643e7fc0c94fc98cf680b77b"
-    "@penpot/text-editor": "penpot/penpot-text-editor#449e3322f3fa40b1318c9154afbbc7932a3cb766"
+    "@penpot/text-editor": "penpot/penpot-text-editor#a100aad8d0efcbb070bed9144dbd2782547e78ba"
     "@playwright/test": "npm:1.48.1"
     "@storybook/addon-essentials": "npm:^8.3.6"
     "@storybook/addon-themes": "npm:^8.3.6"

From 9eaa55b7110b58eccc92234b930a83af69b18468 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Thu, 7 Nov 2024 20:50:01 +0100
Subject: [PATCH 5/6] :sparkles: Prevent logging EOF exceptions on SSE
 responses

They are not necessary and they are pretty common, because
the user can interrupt the connection at any time.
---
 backend/src/app/http/sse.clj | 10 ++++------
 1 file changed, 4 insertions(+), 6 deletions(-)

diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj
index fb7f75e2d..f00422a27 100644
--- a/backend/src/app/http/sse.clj
+++ b/backend/src/app/http/sse.clj
@@ -9,6 +9,7 @@
   (:refer-clojure :exclude [tap])
   (:require
    [app.common.data :as d]
+   [app.common.exceptions :as ex]
    [app.common.logging :as l]
    [app.common.transit :as t]
    [app.http.errors :as errors]
@@ -60,13 +61,10 @@
                         (try
                           (let [result (handler)]
                             (events/tap :end result))
-
-                          (catch java.io.EOFException cause
-                            (events/tap :error (errors/handle' cause request)))
                           (catch Throwable cause
-                            (l/err :hint "unexpected error on processing sse response"
-                                   :cause cause)
-                            (events/tap :error (errors/handle' cause request)))
+                            (events/tap :error (errors/handle' cause request))
+                            (when-not (ex/instance? java.io.EOFException cause)
+                              (l/err :hint "unexpected error on processing sse response" :cause cause)))
                           (finally
                             (sp/close! events/*channel*)
                             (px/await! listener)))))))}))

From d9eff00a719a78f5d8c17a2d317b31392da7d289 Mon Sep 17 00:00:00 2001
From: Pablo Alba <pablo.alba@kaleidos.net>
Date: Thu, 7 Nov 2024 13:42:32 +0100
Subject: [PATCH 6/6] :sparkles: Integrate viewer role with plugin menus and
 popup

---
 frontend/src/app/main/data/plugins.cljs       | 70 +++++++++++++++++--
 .../main/data/workspace/notifications.cljs    |  4 +-
 frontend/src/app/main/refs.cljs               |  5 ++
 frontend/src/app/main/ui/workspace.cljs       |  4 +-
 .../src/app/main/ui/workspace/main_menu.cljs  | 57 ++++++++++-----
 .../src/app/main/ui/workspace/main_menu.scss  | 18 +++++
 .../src/app/main/ui/workspace/plugins.cljs    | 33 +++++++--
 .../src/app/main/ui/workspace/plugins.scss    |  5 +-
 frontend/translations/en.po                   |  4 ++
 frontend/translations/es.po                   |  4 ++
 10 files changed, 168 insertions(+), 36 deletions(-)

diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs
index ba27e6a0a..0dd933651 100644
--- a/frontend/src/app/main/data/plugins.cljs
+++ b/frontend/src/app/main/data/plugins.cljs
@@ -8,13 +8,23 @@
   (:require
    [app.common.data.macros :as dm]
    [app.main.data.modal :as modal]
+   [app.main.data.notifications :as ntf]
    [app.main.store :as st]
    [app.plugins.register :as preg]
    [app.util.globals :as ug]
    [app.util.http :as http]
+   [app.util.i18n :as i18n :refer [tr]]
+   [app.util.time :as dt]
    [beicon.v2.core :as rx]
    [potok.v2.core :as ptk]))
 
+(defn save-plugin-permissions-peek
+  [id permissions]
+  (ptk/reify ::save-plugin-permissions-peek
+    ptk/UpdateEvent
+    (update [_ state]
+      (assoc-in state [:plugins-permissions-peek :data id] permissions))))
+
 (defn fetch-manifest
   [plugin-url]
   (->> (http/send! {:method :get
@@ -59,15 +69,21 @@
       (.error js/console "Error" e))))
 
 (defn open-plugin!
-  [{:keys [url] :as manifest}]
+  [{:keys [url] :as manifest} user-can-edit?]
   (if url
     ;; If the saved manifest has a URL we fetch the manifest to check
     ;; for updates
     (->> (fetch-manifest url)
          (rx/subs!
           (fn [new-manifest]
-            (let [new-manifest (merge new-manifest (select-keys manifest [:plugin-id]))]
+            (let [new-manifest       (merge new-manifest (select-keys manifest [:plugin-id]))
+                  permissions        (:permissions new-manifest)
+                  is-edition-plugin? (or (contains? permissions "content:write")
+                                         (contains? permissions "library:write"))]
+              (st/emit! (save-plugin-permissions-peek (:plugin-id new-manifest) permissions))
               (cond
+                (and is-edition-plugin? (not user-can-edit?))
+                (st/emit! (ntf/warn (tr "workspace.plugins.error.need-editor")))
                 (not= (:permissions new-manifest) (:permissions manifest))
                 (modal/show!
                  :plugin-permissions-update
@@ -96,13 +112,21 @@
       (.error js/console "Error" e))))
 
 (defn close-current-plugin
-  []
+  [& {:keys [close-only-edition-plugins?]}]
   (ptk/reify ::close-current-plugin
     ptk/EffectEvent
     (effect [_ state _]
       (let [ids (dm/get-in state [:workspace-local :open-plugins])]
         (doseq [id ids]
-          (close-plugin! (preg/get-plugin id)))))))
+          (let [plugin             (preg/get-plugin id)
+                permissions        (or (dm/get-in state [:plugins-permissions-peek :data id])
+                                       (:permissions plugin))
+                is-edition-plugin? (or (contains? permissions "content:write")
+                                       (contains? permissions "library:write"))]
+
+            (when (or (not close-only-edition-plugins?)
+                      is-edition-plugin?)
+              (close-plugin! plugin))))))))
 
 (defn delay-open-plugin
   [plugin]
@@ -116,6 +140,38 @@
   (ptk/reify ::check-open-plugin
     ptk/WatchEvent
     (watch [_ state _]
-      (when-let [pid (::open-plugin state)]
-        (open-plugin! (preg/get-plugin pid))
-        (rx/of #(dissoc % ::open-plugin))))))
+      (let [user-can-edit? (dm/get-in state [:permissions :can-edit])]
+        (when-let [pid (::open-plugin state)]
+          (open-plugin! (preg/get-plugin pid) user-can-edit?)
+          (rx/of #(dissoc % ::open-plugin)))))))
+
+(defn- update-plugin-permissions-peek
+  [{:keys [plugin-id url]}]
+  (when url
+      ;; If the saved manifest has a URL we fetch the manifest to check
+      ;; for updates
+    (->> (fetch-manifest url)
+         (rx/subs!
+          (fn [new-manifest]
+            (let [permissions  (:permissions new-manifest)]
+              (when permissions
+                (st/emit! (save-plugin-permissions-peek plugin-id permissions)))))))))
+
+(defn update-plugins-permissions-peek
+  []
+  (ptk/reify ::update-plugins-permissions-peek
+    ptk/UpdateEvent
+    (update [_ state]
+      (let [now        (dt/now)
+            expiration (dt/minus now (dt/duration {:days 1}))
+            updated-at (dm/get-in state [:plugins-permissions-peek :updated-at] 0)
+            expired?   (> expiration updated-at)]
+
+        (if expired?
+          (let [plugins (preg/plugins-list)]
+            (doseq [plugin plugins]
+              (update-plugin-permissions-peek plugin))
+            (-> state
+                (assoc-in [:plugins-permissions-peek :updated-at] now)))
+
+          state)))))
diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs
index fc49d273f..d4b6b7ffe 100644
--- a/frontend/src/app/main/data/workspace/notifications.cljs
+++ b/frontend/src/app/main/data/workspace/notifications.cljs
@@ -14,6 +14,7 @@
    [app.main.data.changes :as dch]
    [app.main.data.common :as dc]
    [app.main.data.modal :as modal]
+   [app.main.data.plugins :as dpl]
    [app.main.data.websocket :as dws]
    [app.main.data.workspace.common :as dwc]
    [app.main.data.workspace.edition :as dwe]
@@ -117,7 +118,8 @@
             (rx/delay 100))
        (if (= :viewer role)
          (rx/of (modal/hide)
-                (dwly/set-options-mode :inspect))
+                (dwly/set-options-mode :inspect)
+                (dpl/close-current-plugin {:close-only-edition-plugins? true}))
          (rx/of (dwly/set-options-mode :design)))))))
 
 (defn- process-message
diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs
index 6f73be8dc..063bb5b79 100644
--- a/frontend/src/app/main/refs.cljs
+++ b/frontend/src/app/main/refs.cljs
@@ -512,6 +512,11 @@
 (def workspace-selected-token-set-tokens
   (l/derived #(or (wtts/get-selected-token-set-tokens %) {}) st/state))
 
+(def plugins-permissions-peek
+  (l/derived (fn [state]
+               (dm/get-in state [:plugins-permissions-peek :data]))
+             st/state))
+
 ;; ---- Viewer refs
 
 (defn lookup-viewer-objects-by-id
diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs
index 4f6360733..760269254 100644
--- a/frontend/src/app/main/ui/workspace.cljs
+++ b/frontend/src/app/main/ui/workspace.cljs
@@ -11,6 +11,7 @@
    [app.main.data.modal :as modal]
    [app.main.data.notifications :as ntf]
    [app.main.data.persistence :as dps]
+   [app.main.data.plugins :as dpl]
    [app.main.data.workspace :as dw]
    [app.main.data.workspace.colors :as dc]
    [app.main.features :as features]
@@ -185,7 +186,8 @@
         background-color (:background-color wglobal)]
 
     (mf/with-effect []
-      (st/emit! (dps/initialize-persistence)))
+      (st/emit! (dps/initialize-persistence)
+                (dpl/update-plugins-permissions-peek)))
 
     ;; Setting the layout preset by its name
     (mf/with-effect [layout-name]
diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs
index 7fd4cddb5..d870e3ac5 100644
--- a/frontend/src/app/main/ui/workspace/main_menu.cljs
+++ b/frontend/src/app/main/ui/workspace/main_menu.cljs
@@ -632,7 +632,9 @@
    ::mf/wrap [mf/memo]}
   [{:keys [open-plugins on-close]}]
   (when (features/active-feature? @st/state "plugins/runtime")
-    (let [plugins (preg/plugins-list)]
+    (let [plugins                  (preg/plugins-list)
+          user-can-edit?           (:can-edit (deref refs/permissions))
+          permissions-peek         (deref refs/plugins-permissions-peek)]
       [:& dropdown-menu {:show true
                          :list-class (stl/css-case :sub-menu true :plugins true)
                          :on-close on-close}
@@ -653,24 +655,41 @@
        (when (d/not-empty? plugins)
          [:div {:class (stl/css :separator)}])
 
-       (for [[idx {:keys [name host] :as manifest}] (d/enumerate plugins)]
-         [:> dropdown-menu-item* {:key         (dm/str "plugins-menu-" idx)
-                                  :on-click    #(do
-                                                  (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin"
-                                                                                   ::ev/origin "workspace:menu"
-                                                                                   :name name
-                                                                                   :host host}))
-                                                  (dp/open-plugin! manifest))
-                                  :class       (stl/css :submenu-item)
-                                  :on-key-down (fn [event]
-                                                 (when (kbd/enter? event)
-                                                   #(do
-                                                      (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin"
-                                                                                       ::ev/origin "workspace:menu"
-                                                                                       :name name
-                                                                                       :host host}))
-                                                      (dp/open-plugin! manifest))))}
-          [:span {:class (stl/css :item-name)} name]])])))
+       (for [[idx {:keys [plugin-id name host permissions] :as manifest}] (d/enumerate plugins)]
+         (let [permissions        (or (get permissions-peek plugin-id) permissions)
+               is-edition-plugin? (or (contains? permissions "content:write")
+                                      (contains? permissions "library:write"))
+               can-open?          (or user-can-edit?
+                                      (not is-edition-plugin?))
+               on-click
+               (mf/use-fn
+                (mf/deps can-open? name host manifest user-can-edit?)
+                (fn [event]
+                  (if can-open?
+                    (do
+                      (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin"
+                                                       ::ev/origin "workspace:menu"
+                                                       :name name
+                                                       :host host}))
+                      (dp/open-plugin! manifest user-can-edit?))
+                    (dom/stop-propagation event))))
+               on-key-down
+               (mf/use-fn
+                (mf/deps can-open? name host manifest user-can-edit?)
+                (fn [event]
+                  (when can-open?
+                    (when (kbd/enter? event)
+                      (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin"
+                                                       ::ev/origin "workspace:menu"
+                                                       :name name
+                                                       :host host}))
+                      (dp/open-plugin! manifest user-can-edit?)))))]
+           [:> dropdown-menu-item* {:key         (dm/str "plugins-menu-" idx)
+                                    :on-click    on-click
+                                    :title       (when-not can-open? (tr "workspace.plugins.error.need-editor"))
+                                    :class       (stl/css-case :submenu-item true :menu-disabled (not can-open?))
+                                    :on-key-down on-key-down}
+            [:span {:class (stl/css :item-name)} name]]))])))
 
 (mf/defc menu
   {::mf/props :obj}
diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss
index e74ddcba6..56ae1cd19 100644
--- a/frontend/src/app/main/ui/workspace/main_menu.scss
+++ b/frontend/src/app/main/ui/workspace/main_menu.scss
@@ -17,20 +17,25 @@
 .menu-item {
   @extend .menu-item-base;
   cursor: pointer;
+
   .open-arrow {
     @include flexCenter;
+
     svg {
       @extend .button-icon;
       stroke: var(--icon-foreground);
     }
   }
+
   &:hover {
     color: var(--menu-foreground-color-hover);
+
     .open-arrow {
       svg {
         stroke: var(--menu-foreground-color-hover);
       }
     }
+
     .shortcut-key {
       color: var(--menu-shortcut-foreground-color-hover);
     }
@@ -46,6 +51,7 @@
 .shortcut {
   @extend .shortcut-base;
 }
+
 .shortcut-key {
   @extend .shortcut-key-base;
 }
@@ -59,14 +65,26 @@
 
   .submenu-item {
     @extend .menu-item-base;
+
     &:hover {
       color: var(--menu-foreground-color-hover);
+
       .shortcut-key {
         color: var(--menu-shortcut-foreground-color-hover);
       }
     }
   }
 
+  .menu-disabled {
+    color: var(--color-foreground-secondary);
+
+    &:hover {
+      cursor: default;
+      color: var(--color-foreground-secondary);
+      background-color: var(--menu-background-color);
+    }
+  }
+
   &.file {
     top: $s-48;
   }
diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs
index b6aee18d6..c0ad1c580 100644
--- a/frontend/src/app/main/ui/workspace/plugins.cljs
+++ b/frontend/src/app/main/ui/workspace/plugins.cljs
@@ -13,9 +13,11 @@
    [app.main.data.events :as ev]
    [app.main.data.modal :as modal]
    [app.main.data.plugins :as dp]
+   [app.main.refs :as refs]
    [app.main.store :as st]
    [app.main.ui.components.search-bar :refer [search-bar]]
    [app.main.ui.components.title-bar :refer [title-bar]]
+   [app.main.ui.ds.buttons.button :refer [button*]]
    [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
    [app.main.ui.icons :as i]
    [app.plugins.register :as preg]
@@ -40,14 +42,22 @@
           icon))
 
 (mf/defc plugin-entry
-  [{:keys [index manifest on-open-plugin on-remove-plugin]}]
+  [{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin]}]
+
+  (let [{:keys [plugin-id host icon name description permissions]} manifest
+        plugins-permissions-peek (deref refs/plugins-permissions-peek)
+        permissions              (or (get plugins-permissions-peek plugin-id)
+                                     permissions)
+        is-edition-plugin?       (or (contains? permissions "content:write")
+                                     (contains? permissions "library:write"))
+        can-open?                (or user-can-edit
+                                     (not is-edition-plugin?))
 
-  (let [{:keys [host icon name description]} manifest
         handle-open-click
         (mf/use-callback
-         (mf/deps index manifest on-open-plugin)
+         (mf/deps index manifest on-open-plugin can-open?)
          (fn []
-           (when on-open-plugin
+           (when (and can-open? on-open-plugin)
              (on-open-plugin manifest))))
 
         handle-delete-click
@@ -64,8 +74,14 @@
      [:div {:class (stl/css :plugin-description)}
       [:div {:class (stl/css :plugin-title)} name]
       [:div {:class (stl/css :plugin-summary)} (d/nilv description "")]]
-     [:button {:class (stl/css :open-button)
-               :on-click handle-open-click} (tr "workspace.plugins.button-open")]
+
+
+     [:> button* {:class (stl/css :open-button)
+                  :variant "secondary"
+                  :on-click handle-open-click
+                  :title (when-not can-open? (tr "workspace.plugins.error.need-editor"))
+                  :disabled (not can-open?)} (tr "workspace.plugins.button-open")]
+
      [:> icon-button* {:variant "ghost"
                        :aria-label (tr "workspace.plugins.remove-plugin")
                        :on-click handle-delete-click
@@ -91,6 +107,8 @@
         error-manifest? (= :error-manifest input-status)
         error? (or error-url? error-manifest?)
 
+        user-can-edit? (:can-edit (deref refs/permissions))
+
         handle-close-dialog
         (mf/use-callback
          (fn []
@@ -137,7 +155,7 @@
                                             ::ev/origin "workspace:plugins"
                                             :name (:name manifest)
                                             :host (:host manifest)}))
-           (dp/open-plugin! manifest)
+           (dp/open-plugin! manifest user-can-edit?)
            (modal/hide!)))
 
         handle-remove-plugin
@@ -204,6 +222,7 @@
              [:& plugin-entry {:key (dm/str "plugin-" idx)
                                :index idx
                                :manifest manifest
+                               :user-can-edit user-can-edit?
                                :on-open-plugin handle-open-plugin
                                :on-remove-plugin handle-remove-plugin}])]])]]]))
 
diff --git a/frontend/src/app/main/ui/workspace/plugins.scss b/frontend/src/app/main/ui/workspace/plugins.scss
index 96ad29243..82d0bb6cd 100644
--- a/frontend/src/app/main/ui/workspace/plugins.scss
+++ b/frontend/src/app/main/ui/workspace/plugins.scss
@@ -102,6 +102,7 @@
   @include flexCenter;
   width: $s-20;
   padding: 0 0 0 $s-8;
+
   svg {
     @extend .button-icon-small;
     stroke: var(--icon-foreground);
@@ -114,7 +115,9 @@
 }
 
 .open-button {
-  @extend .button-secondary;
+  display: flex;
+  justify-content: center;
+  align-items: center;
   width: $s-68;
   min-width: $s-68;
   height: $s-32;
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 96dc22c16..e44b01166 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -5859,6 +5859,10 @@ msgstr "Plugins"
 msgid "workspace.plugins.remove-plugin"
 msgstr "Remove plugin"
 
+#: src/app/main/data/plugins.cljs:78
+msgid "workspace.plugins.error.need-editor"
+msgstr "You need to be an editor to use this plugin"
+
 #: /src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1005
 msgid "workspace.shape.menu.add-layout"
 msgstr "Add layout"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 82864350b..d50e66603 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -5837,6 +5837,10 @@ msgstr "Extensiones"
 msgid "workspace.plugins.remove-plugin"
 msgstr "Eliminar extensión"
 
+#: src/app/main/data/plugins.cljs:78
+msgid "workspace.plugins.error.need-editor"
+msgstr "Debes ser un editor para usar este plugin"
+
 #: /src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1005
 msgid "workspace.shape.menu.add-layout"
 msgstr "Añadir layout"