Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2024-09-05 09:37:16 +02:00
commit e189dc965d
24 changed files with 603 additions and 374 deletions

View file

@ -83,6 +83,7 @@
### :bug: Bugs fixed ### :bug: Bugs fixed
- Fix webhook checkbox position [Taiga #8634](https://tree.taiga.io/project/penpot/issue/8634)
- Fix wrong props on padding input [Taiga #8254](https://tree.taiga.io/project/penpot/issue/8254) - Fix wrong props on padding input [Taiga #8254](https://tree.taiga.io/project/penpot/issue/8254)
- Fix fill collapsed options [Taiga #8351](https://tree.taiga.io/project/penpot/issue/8351) - Fix fill collapsed options [Taiga #8351](https://tree.taiga.io/project/penpot/issue/8351)
- Fix scroll on color picker modal [Taiga #8353](https://tree.taiga.io/project/penpot/issue/8353) - Fix scroll on color picker modal [Taiga #8353](https://tree.taiga.io/project/penpot/issue/8353)

View file

@ -36,4 +36,7 @@
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Open-Color-Scheme.penpot"} :file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Open-Color-Scheme.penpot"}
{:id "flex-layout-playground" {:id "flex-layout-playground"
:name "Flex Layout Playground" :name "Flex Layout Playground"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Flex-Layout-Playground.penpot"}] :file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Flex-Layout-Playground.penpot"}
{:id "welcome"
:name "Welcome"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/welcome.penpot"}]

View file

@ -24,7 +24,7 @@ export PENPOT_FLAGS="\
enable-rpc-climit \ enable-rpc-climit \
enable-rpc-rlimit \ enable-rpc-rlimit \
enable-soft-rpc-rlimit \ enable-soft-rpc-rlimit \
enable-file-snapshot \ enable-auto-file-snapshot \
enable-webhooks \ enable-webhooks \
enable-access-tokens \ enable-access-tokens \
enable-tiered-file-data-storage \ enable-tiered-file-data-storage \

View file

@ -27,9 +27,11 @@
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph] [app.rpc.helpers :as rph]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.setup.welcome-file :refer [create-welcome-file]]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(def schema:password (def schema:password
@ -241,6 +243,7 @@
params (d/without-nils params) params (d/without-nils params)
token (tokens/generate (::setup/props cfg) params)] token (tokens/generate (::setup/props cfg) params)]
(with-meta {:token token} (with-meta {:token token}
{::audit/profile-id uuid/zero}))) {::audit/profile-id uuid/zero})))
@ -350,7 +353,7 @@
:extra-data ptoken}))) :extra-data ptoken})))
(defn register-profile (defn register-profile
[{:keys [::db/conn] :as cfg} {:keys [token fullname theme] :as params}] [{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token fullname theme] :as params}]
(let [theme (when (= theme "light") theme) (let [theme (when (= theme "light") theme)
claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register}) claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register})
params (-> claims params (-> claims
@ -380,8 +383,13 @@
invitation (when-let [token (:invitation-token params)] invitation (when-let [token (:invitation-token params)]
(tokens/verify (::setup/props cfg) {:token token :iss :team-invitation})) (tokens/verify (::setup/props cfg) {:token token :iss :team-invitation}))
props (audit/profile->props profile)] props (audit/profile->props profile)
create-welcome-file-when-needed
(fn []
(when (:create-welcome-file params)
(let [cfg (dissoc cfg ::db/conn)]
(wrk/submit! executor (create-welcome-file cfg profile)))))]
(cond (cond
;; When profile is blocked, we just ignore it and return plain data ;; When profile is blocked, we just ignore it and return plain data
(:is-blocked profile) (:is-blocked profile)
@ -418,6 +426,7 @@
(if (:is-active profile) (if (:is-active profile)
(-> (profile/strip-private-attrs profile) (-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile))) (rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-defer create-welcome-file-when-needed)
(rph/with-meta (rph/with-meta
{::audit/replace-props props {::audit/replace-props props
::audit/context {:action "login"} ::audit/context {:action "login"}
@ -427,10 +436,12 @@
(when-not (eml/has-reports? conn (:email profile)) (when-not (eml/has-reports? conn (:email profile))
(send-email-verification! cfg profile)) (send-email-verification! cfg profile))
(rph/with-meta {:email (:email profile)} (-> {:email (:email profile)}
{::audit/replace-props props (rph/with-defer create-welcome-file-when-needed)
::audit/context {:action "email-verification"} (rph/with-meta
::audit/profile-id (:id profile)}))) {::audit/replace-props props
::audit/context {:action "email-verification"}
::audit/profile-id (:id profile)}))))
:else :else
(let [elapsed? (elapsed-verify-threshold? profile) (let [elapsed? (elapsed-verify-threshold? profile)
@ -462,7 +473,8 @@
[:map {:title "register-profile"} [:map {:title "register-profile"}
[:token schema:token] [:token schema:token]
[:fullname [::sm/word-string {:max 100}]] [:fullname [::sm/word-string {:max 100}]]
[:theme {:optional true} [:string {:max 10}]]]) [:theme {:optional true} [:string {:max 10}]]
[:create-welcome-file {:optional true} :boolean]])
(sv/defmethod ::register-profile (sv/defmethod ::register-profile
{::rpc/auth false {::rpc/auth false

View file

@ -38,6 +38,20 @@
[clojure.set :as set] [clojure.set :as set]
[promesa.exec :as px])) [promesa.exec :as px]))
(declare ^:private get-lagged-changes)
(declare ^:private send-notifications!)
(declare ^:private update-file)
(declare ^:private update-file*)
(declare ^:private process-changes-and-validate)
(declare ^:private take-snapshot?)
(declare ^:private delete-old-snapshots!)
;; PUBLIC API; intended to be used outside of this module
(declare update-file!)
(declare update-file-data!)
(declare persist-file!)
(declare get-file)
;; --- SCHEMA ;; --- SCHEMA
(def ^:private (def ^:private
@ -97,41 +111,6 @@
(or (contains? library-change-types type) (or (contains? library-change-types type)
(contains? file-change-types type))) (contains? file-change-types type)))
(def ^:private sql:get-file
"SELECT f.*, p.team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE f.id = ?
AND (f.deleted_at IS NULL OR
f.deleted_at > now())
FOR KEY SHARE")
(defn get-file
[conn id]
(let [file (db/exec-one! conn [sql:get-file id])]
(when-not file
(ex/raise :type :not-found
:code :object-not-found
:hint (format "file with id '%s' does not exists" id)))
(update file :features db/decode-pgarray #{})))
(defn- wrap-with-pointer-map-context
[f]
(fn [cfg {:keys [id] :as file}]
(binding [pmap/*tracked* (pmap/create-tracked)
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
(let [result (f cfg file)]
(feat.fdata/persist-pointers! cfg id)
result))))
(declare ^:private delete-old-snapshots!)
(declare ^:private get-lagged-changes)
(declare ^:private send-notifications!)
(declare ^:private take-snapshot?)
(declare ^:private update-file)
(declare ^:private update-file*)
(declare ^:private update-file-data)
;; If features are specified from params and the final feature ;; If features are specified from params and the final feature
;; set is different than the persisted one, update it on the ;; set is different than the persisted one, update it on the
;; database. ;; database.
@ -147,7 +126,8 @@
::sm/result schema:update-file-result ::sm/result schema:update-file-result
::doc/module :files ::doc/module :files
::doc/added "1.17"} ::doc/added "1.17"}
[cfg {:keys [::rpc/profile-id id] :as params}] [{:keys [::mtx/metrics] :as cfg}
{:keys [::rpc/profile-id id changes changes-with-metadata] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id id) (files/check-edition-permissions! conn profile-id id)
(db/xact-lock! conn id) (db/xact-lock! conn id)
@ -161,14 +141,30 @@
(cfeat/check-client-features! (:features params)) (cfeat/check-client-features! (:features params))
(cfeat/check-file-features! (:features file) (:features params))) (cfeat/check-file-features! (:features file) (:features params)))
params (assoc params changes (if changes-with-metadata
:profile-id profile-id (->> changes-with-metadata (mapcat :changes) vec)
:features features (vec changes))
:team team
:file file) params (-> params
(assoc :profile-id profile-id)
(assoc :features features)
(assoc :team team)
(assoc :file file)
(assoc :changes changes))
cfg (assoc cfg ::timestamp (dt/now))
tpoint (dt/tpoint)] tpoint (dt/tpoint)]
(when (> (:revn params)
(:revn file))
(ex/raise :type :validation
:code :revn-conflict
:hint "The incoming revision number is greater that stored version."
:context {:incoming-revn (:revn params)
:stored-revn (:revn file)}))
;; When newly computed features does not match exactly with ;; When newly computed features does not match exactly with
;; the features defined on team row, we update it. ;; the features defined on team row, we update it.
(when (not= features (:features team)) (when (not= features (:features team))
@ -177,90 +173,126 @@
{:features features} {:features features}
{:id (:id team)}))) {:id (:id team)})))
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
(binding [l/*context* (some-> (meta params) (binding [l/*context* (some-> (meta params)
(get :app.http/request) (get :app.http/request)
(errors/request->context))] (errors/request->context))]
(-> (update-file cfg params) (-> (update-file* cfg params)
(rph/with-defer #(let [elapsed (tpoint)] (rph/with-defer #(let [elapsed (tpoint)]
(l/trace :hint "update-file" :time (dt/format-duration elapsed)))))))))) (l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))))
(defn update-file
[{:keys [::mtx/metrics] :as cfg}
{:keys [file features changes changes-with-metadata] :as params}]
(let [features (-> features
(set/difference cfeat/frontend-only-features)
(set/union (:features file)))
update-fn (cond-> update-file*
(contains? features "fdata/pointer-map")
(wrap-with-pointer-map-context))
changes (if changes-with-metadata
(->> changes-with-metadata (mapcat :changes) vec)
(vec changes))]
(when (> (:revn params)
(:revn file))
(ex/raise :type :validation
:code :revn-conflict
:hint "The incoming revision number is greater that stored version."
:context {:incoming-revn (:revn params)
:stored-revn (:revn file)}))
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
(binding [cfeat/*current* features
cfeat/*previous* (:features file)]
(let [file (assoc file :features features)
params (-> params
(assoc :file file)
(assoc :changes changes)
(assoc ::created-at (dt/now)))]
(-> (update-fn cfg params)
(vary-meta assoc ::audit/replace-props
{:id (:id file)
:name (:name file)
:features (:features file)
:project-id (:project-id file)
:team-id (:team-id file)}))))))
(defn- update-file* (defn- update-file*
[{:keys [::db/conn ::wrk/executor] :as cfg} "Internal function, part of the update-file process, that encapsulates
{:keys [profile-id file changes session-id ::created-at skip-validate] :as params}] the changes application offload to a separated thread and emit all
corresponding notifications.
Follow the inner implementation to `update-file-data!` function.
Only intended for internal use on this module."
[{:keys [::db/conn ::wrk/executor ::timestamp] :as cfg}
{:keys [profile-id file features changes session-id skip-validate] :as params}]
(let [;; Retrieve the file data (let [;; Retrieve the file data
file (feat.fdata/resolve-file-data cfg file) file (feat.fdata/resolve-file-data cfg file)
file (assoc file :features
(-> features
(set/difference cfeat/frontend-only-features)
(set/union (:features file))))
;; Process the file data on separated thread for avoid to do ;; Process the file data on separated thread for avoid to do
;; the CPU intensive operation on vthread. ;; the CPU intensive operation on vthread.
file (px/invoke! executor
(fn []
(binding [cfeat/*current* features
cfeat/*previous* (:features file)]
(update-file-data! cfg file
process-changes-and-validate
changes skip-validate))))]
file (px/invoke! executor (partial update-file-data cfg file changes skip-validate)) (when (feat.fdata/offloaded? file)
features (db/create-array conn "text" (:features file))]
;; NOTE: if file was offloaded, we need to touch the referenced
;; storage object because on this update operation the data will
;; be overwritted.
(when (= "objects-storage" (:data-backend file))
(let [storage (sto/resolve cfg ::db/reuse-conn true)] (let [storage (sto/resolve cfg ::db/reuse-conn true)]
(sto/touch-object! storage (:data-ref-id file)))) (some->> (:data-ref-id file) (sto/touch-object! storage))))
(db/insert! conn :file-change
{:id (uuid/next)
:session-id session-id
:profile-id profile-id
:created-at created-at
:file-id (:id file)
:revn (:revn file)
:version (:version file)
:label (::snapshot-label file)
:data (::snapshot-data file)
:features (db/create-array conn "text" (:features file))
:changes (blob/encode changes)}
{::db/return-keys false})
;; TODO: move this to asynchronous task
(when (::snapshot-data file) (when (::snapshot-data file)
(delete-old-snapshots! cfg file)) (delete-old-snapshots! cfg file))
(persist-file! cfg file)
(let [params (assoc params :file file)
response {:revn (:revn file)
:lagged (get-lagged-changes conn params)}
features (db/create-array conn "text" (:features file))]
;; Insert change (xlog)
(db/insert! conn :file-change
{:id (uuid/next)
:session-id session-id
:profile-id profile-id
:created-at timestamp
:file-id (:id file)
:revn (:revn file)
:version (:version file)
:features features
:label (::snapshot-label file)
:data (::snapshot-data file)
:changes (blob/encode changes)}
{::db/return-keys false})
;; Send asynchronous notifications
(send-notifications! cfg params)
(vary-meta response assoc ::audit/replace-props
{:id (:id file)
:name (:name file)
:features (:features file)
:project-id (:project-id file)
:team-id (:team-id file)}))))
(defn update-file!
"A public api that allows apply a transformation to a file with all context setup."
[cfg file-id update-fn & args]
(let [file (get-file cfg file-id)
file (apply update-file-data! cfg file update-fn args)]
(persist-file! cfg file)))
(def ^:private sql:get-file
"SELECT f.*, p.team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE f.id = ?
AND (f.deleted_at IS NULL OR
f.deleted_at > now())
FOR KEY SHARE")
(defn get-file
"Get not-decoded file, only decodes the features set."
[conn id]
(let [file (db/exec-one! conn [sql:get-file id])]
(when-not file
(ex/raise :type :not-found
:code :object-not-found
:hint (format "file with id '%s' does not exists" id)))
(update file :features db/decode-pgarray #{})))
(defn persist-file!
"Function responsible of persisting already encoded file. Should be
used together with `get-file` and `update-file-data!`.
It also updates the project modified-at attr."
[{:keys [::db/conn ::timestamp]} file]
(let [features (db/create-array conn "text" (:features file))
;; The timestamp can be nil because this function is also
;; intended to be used outside of this module
modified-at (or timestamp (dt/now))]
(db/update! conn :project
{:modified-at modified-at}
{:id (:project-id file)}
{::db/return-keys false})
(db/update! conn :file (db/update! conn :file
{:revn (:revn file) {:revn (:revn file)
:data (:data file) :data (:data file)
@ -268,20 +300,95 @@
:features features :features features
:data-backend nil :data-backend nil
:data-ref-id nil :data-ref-id nil
:modified-at created-at :modified-at modified-at
:has-media-trimmed false} :has-media-trimmed false}
{:id (:id file)}) {:id (:id file)}
{::db/return-keys false})))
(db/update! conn :project (defn- update-file-data!
{:modified-at created-at} "Perform a file data transformation in with all update context setup.
{:id (:project-id file)})
(let [params (assoc params :file file)] This function expected not-decoded file and transformation function. Returns
;; Send asynchronous notifications an encoded file.
(send-notifications! cfg params)
{:revn (:revn file) This function is not responsible of saving the file. It only saves
:lagged (get-lagged-changes conn params)}))) fdata/pointer-map modified fragments."
[cfg {:keys [id] :as file} update-fn & args]
(binding [pmap/*tracked* (pmap/create-tracked)
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
(let [file (update file :data (fn [data]
(-> data
(blob/decode)
(assoc :id (:id file)))))
;; For avoid unnecesary overhead of creating multiple pointers
;; and handly internally with objects map in their worst
;; case (when probably all shapes and all pointers will be
;; readed in any case), we just realize/resolve them before
;; applying the migration to the file
file (if (fmg/need-migration? file)
(-> file
(update :data feat.fdata/process-pointers deref)
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))
file)
file (apply update-fn cfg file args)
;; TODO: reuse operations if file is migrated
;; TODO: move encoding to a separated thread
file (if (take-snapshot? file)
(let [tpoint (dt/tpoint)
snapshot (-> (:data file)
(feat.fdata/process-pointers deref)
(feat.fdata/process-objects (partial into {}))
(blob/encode))
elapsed (tpoint)
label (str "internal/snapshot/" (:revn file))]
(l/trc :hint "take snapshot"
:file-id (str (:id file))
:revn (:revn file)
:label label
:elapsed (dt/format-duration elapsed))
(-> file
(assoc ::snapshot-data snapshot)
(assoc ::snapshot-label label)))
file)
file (cond-> file
(contains? cfeat/*current* "fdata/objects-map")
(feat.fdata/enable-objects-map)
(contains? cfeat/*current* "fdata/pointer-map")
(feat.fdata/enable-pointer-map)
:always
(update :data blob/encode))]
(feat.fdata/persist-pointers! cfg id)
file)))
(defn- get-file-libraries
"A helper for preload file libraries, mainly used for perform file
semantical and structural validation"
[{:keys [::db/conn] :as cfg} file]
(->> (files/get-file-libraries conn (:id file))
(into [file] (map (fn [{:keys [id]}]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
pmap/*tracked* nil]
;; We do not resolve the objects maps here
;; because there is a lower probability that all
;; shapes needed to be loded into memory, so we
;; leeave it on lazy status
(-> (files/get-file cfg id :migrate? false)
(update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))))))
(d/index-by :id)))
(defn- soft-validate-file-schema! (defn- soft-validate-file-schema!
[file] [file]
@ -298,68 +405,19 @@
(l/error :hint "file validation error" (l/error :hint "file validation error"
:cause cause)))) :cause cause))))
(defn- update-file-data (defn- process-changes-and-validate
[{:keys [::db/conn] :as cfg} file changes skip-validate] [cfg file changes skip-validate]
(let [file (update file :data (fn [data] (let [;; WARNING: this ruins performance; maybe we need to find
(-> data
(blob/decode)
(assoc :id (:id file)))))
;; For avoid unnecesary overhead of creating multiple pointers
;; and handly internally with objects map in their worst
;; case (when probably all shapes and all pointers will be
;; readed in any case), we just realize/resolve them before
;; applying the migration to the file
file (if (fmg/need-migration? file)
(-> file
(update :data feat.fdata/process-pointers deref)
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))
file)
;; WARNING: this ruins performance; maybe we need to find
;; some other way to do general validation ;; some other way to do general validation
libs (when (and (or (contains? cf/flags :file-validation) libs (when (and (or (contains? cf/flags :file-validation)
(contains? cf/flags :soft-file-validation)) (contains? cf/flags :soft-file-validation))
(not skip-validate)) (not skip-validate))
(->> (files/get-file-libraries conn (:id file)) (get-file-libraries cfg file))
(into [file] (map (fn [{:keys [id]}]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
pmap/*tracked* nil]
;; We do not resolve the objects maps here
;; because there is a lower probability that all
;; shapes needed to be loded into memory, so we
;; leeave it on lazy status
(-> (files/get-file cfg id :migrate? false)
(update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))))))
(d/index-by :id)))
file (-> (files/check-version! file) file (-> (files/check-version! file)
(update :revn inc) (update :revn inc)
(update :data cpc/process-changes changes) (update :data cpc/process-changes changes)
(update :data d/without-nils)) (update :data d/without-nils))]
file (if (take-snapshot? file)
(let [tpoint (dt/tpoint)
snapshot (-> (:data file)
(feat.fdata/process-pointers deref)
(feat.fdata/process-objects (partial into {}))
(blob/encode))
elapsed (tpoint)
label (str "internal/snapshot/" (:revn file))]
(l/trc :hint "take snapshot"
:file-id (str (:id file))
:revn (:revn file)
:label label
:elapsed (dt/format-duration elapsed))
(-> file
(assoc ::snapshot-data snapshot)
(assoc ::snapshot-label label)))
file)]
(binding [pmap/*tracked* nil] (binding [pmap/*tracked* nil]
(when (contains? cf/flags :soft-file-validation) (when (contains? cf/flags :soft-file-validation)
@ -376,15 +434,7 @@
(not skip-validate)) (not skip-validate))
(val/validate-file-schema! file))) (val/validate-file-schema! file)))
(cond-> file file))
(contains? cfeat/*current* "fdata/objects-map")
(feat.fdata/enable-objects-map)
(contains? cfeat/*current* "fdata/pointer-map")
(feat.fdata/enable-pointer-map)
:always
(update :data blob/encode))))
(defn- take-snapshot? (defn- take-snapshot?
"Defines the rule when file `data` snapshot should be saved." "Defines the rule when file `data` snapshot should be saved."
@ -426,8 +476,7 @@
result (db/exec-one! conn [sql:delete-snapshots id last-date])] result (db/exec-one! conn [sql:delete-snapshots id last-date])]
(l/trc :hint "delete old snapshots" :file-id (str id) :total (db/get-update-count result))))) (l/trc :hint "delete old snapshots" :file-id (str id) :total (db/get-update-count result)))))
(def ^:private (def ^:private sql:lagged-changes
sql:lagged-changes
"select s.id, s.revn, s.file_id, "select s.id, s.revn, s.file_id,
s.session_id, s.changes s.session_id, s.changes
from file_change as s from file_change as s

View file

@ -396,8 +396,8 @@
;; --- COMMAND: Clone Template ;; --- COMMAND: Clone Template
(defn- clone-template (defn clone-template
[cfg {:keys [project-id ::rpc/profile-id] :as params} template] [cfg {:keys [project-id profile-id] :as params} template]
(db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}] (db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}]
;; NOTE: the importation process performs some operations that ;; NOTE: the importation process performs some operations that
;; are not very friendly with virtual threads, and for avoid ;; are not very friendly with virtual threads, and for avoid
@ -416,6 +416,7 @@
(doseq [file-id result] (doseq [file-id result]
(let [props (assoc props :id file-id) (let [props (assoc props :id file-id)
event (-> (audit/event-from-rpc-params params) event (-> (audit/event-from-rpc-params params)
(assoc ::audit/profile-id profile-id)
(assoc ::audit/name "create-file") (assoc ::audit/name "create-file")
(assoc ::audit/props props))] (assoc ::audit/props props))]
(audit/submit! cfg event)))) (audit/submit! cfg event))))
@ -437,7 +438,8 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}]
(let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]}) (let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]})
_ (teams/check-edition-permissions! pool profile-id (:team-id project)) _ (teams/check-edition-permissions! pool profile-id (:team-id project))
template (tmpl/get-template-stream cfg template-id)] template (tmpl/get-template-stream cfg template-id)
params (assoc params :profile-id profile-id)]
(when-not template (when-not template
(ex/raise :type :not-found (ex/raise :type :not-found

View file

@ -360,27 +360,31 @@
[:map {:title "update-profile-props"} [:map {:title "update-profile-props"}
[:props [:map-of :keyword :any]]])) [:props [:map-of :keyword :any]]]))
(defn update-profile-props
[{:keys [::db/conn] :as cfg} profile-id props]
(let [profile (get-profile conn profile-id ::sql/for-update true)
props (reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
props)]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id})
(filter-props props)))
(sv/defmethod ::update-profile-props (sv/defmethod ::update-profile-props
{::doc/added "1.0" {::doc/added "1.0"
::sm/params schema:update-profile-props} ::sm/params schema:update-profile-props}
[{:keys [::db/pool]} {:keys [::rpc/profile-id props]}] [cfg {:keys [::rpc/profile-id props]}]
(db/with-atomic [conn pool] (db/tx-run! cfg (fn [cfg]
(let [profile (get-profile conn profile-id ::sql/for-update true) (update-profile-props cfg profile-id props))))
props (reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
props)]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id})
(filter-props props))))
;; --- MUTATION: Delete Profile ;; --- MUTATION: Delete Profile

View file

@ -0,0 +1,64 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.setup.welcome-file
(:require
[app.common.logging :as l]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files-update :as fupdate]
[app.rpc.commands.management :as management]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.setup :as-alias setup]
[app.setup.templates :as tmpl]
[app.worker :as-alias wrk]))
(def ^:private page-id #uuid "2c6952ee-d00e-8160-8004-d2250b7210cb")
(def ^:private shape-id #uuid "765e9f82-c44e-802e-8004-d72a10b7b445")
(def ^:private update-path
[:data :pages-index page-id :objects shape-id
:content :children 0 :children 0 :children 0])
(def ^:private sql:mark-file-object-thumbnails-deleted
"UPDATE file_tagged_object_thumbnail
SET deleted_at = now()
WHERE file_id = ?")
(def ^:private sql:mark-file-thumbnail-deleted
"UPDATE file_thumbnail
SET deleted_at = now()
WHERE file_id = ?")
(defn- update-welcome-shape
[_ file name]
(let [text (str "Welcome to Penpot, " name "!")]
(-> file
(update-in update-path assoc :text text)
(update-in [:data :pages-index page-id :objects shape-id] assoc :name "Welcome to Penpot!")
(update-in [:data :pages-index page-id :objects shape-id] dissoc :position-data))))
(defn create-welcome-file
[cfg {:keys [id fullname] :as profile}]
(try
(let [cfg (dissoc cfg ::db/conn)
params {:profile-id (:id profile)
:project-id (:default-project-id profile)}
template-stream (tmpl/get-template-stream cfg "welcome")
file-id (-> (management/clone-template cfg params template-stream)
first)]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(fupdate/update-file! cfg file-id update-welcome-shape fullname)
(profile/update-profile-props cfg id {:welcome-file-id file-id})
(db/exec-one! conn [sql:mark-file-object-thumbnails-deleted file-id])
(db/exec-one! conn [sql:mark-file-thumbnail-deleted file-id]))))
(catch Throwable cause
(l/error :hint "unexpected error on create welcome file " :cause cause))))

View file

@ -76,7 +76,7 @@
:enable-feature-fdata-pointer-map :enable-feature-fdata-pointer-map
:enable-feature-fdata-objets-map :enable-feature-fdata-objets-map
:enable-feature-components-v2 :enable-feature-components-v2
:enable-file-snapshot :enable-auto-file-snapshot
:disable-file-validation]) :disable-file-validation])
(defn state-init (defn state-init
@ -304,16 +304,18 @@
([params] (update-file* *system* params)) ([params] (update-file* *system* params))
([system {:keys [file-id changes session-id profile-id revn] ([system {:keys [file-id changes session-id profile-id revn]
:or {session-id (uuid/next) revn 0}}] :or {session-id (uuid/next) revn 0}}]
(db/tx-run! system (fn [{:keys [::db/conn] :as system}] (-> system
(let [file (files.update/get-file conn file-id)] (assoc ::files.update/timestamp (dt/now))
(files.update/update-file system (db/tx-run! (fn [{:keys [::db/conn] :as system}]
(let [file (files.update/get-file conn file-id)]
(#'files.update/update-file* system
{:id file-id {:id file-id
:revn revn :revn revn
:file file :file file
:features (:features file) :features (:features file)
:changes changes :changes changes
:session-id session-id :session-id session-id
:profile-id profile-id})))))) :profile-id profile-id})))))))
(declare command!) (declare command!)

View file

@ -190,10 +190,9 @@
[:type [:= :del-color]] [:type [:= :del-color]]
[:id ::sm/uuid]]] [:id ::sm/uuid]]]
;; DEPRECATED: remove before 2.3
[:add-recent-color [:add-recent-color
[:map {:title "AddRecentColorChange"} [:map {:title "AddRecentColorChange"}]]
[:type [:= :add-recent-color]]
[:color ::ctc/recent-color]]]
[:add-media [:add-media
[:map {:title "AddMediaChange"} [:map {:title "AddMediaChange"}
@ -656,18 +655,10 @@
[data {:keys [id]}] [data {:keys [id]}]
(ctcl/delete-color data id)) (ctcl/delete-color data id))
;; DEPRECATED: remove before 2.3
(defmethod process-change :add-recent-color (defmethod process-change :add-recent-color
[data {:keys [color]}] [data _]
;; Moves the color to the top of the list and then truncates up to 15 data)
(update
data
:recent-colors
(fn [rc]
(let [rc (->> rc (d/removev (partial ctc/eq-recent-color? color)))
rc (-> rc (conj color))]
(cond-> rc
(> (count rc) 15)
(subvec 1))))))
;; -- Media ;; -- Media

View file

@ -607,13 +607,6 @@
(reduce resize-parent changes all-parents))) (reduce resize-parent changes all-parents)))
;; Library changes ;; Library changes
(defn add-recent-color
[changes color]
(-> changes
(update :redo-changes conj {:type :add-recent-color :color color})
(apply-changes-local)))
(defn add-color (defn add-color
[changes color] [changes color]
(-> changes (-> changes

View file

@ -107,17 +107,16 @@
[::sm/contains-any {:strict true} [:color :gradient :image]]]) [::sm/contains-any {:strict true} [:color :gradient :image]]])
(sm/register! ::rgb-color type:rgb-color) (sm/register! ::rgb-color type:rgb-color)
(sm/register! ::color schema:color) (sm/register! ::color schema:color)
(sm/register! ::gradient schema:gradient) (sm/register! ::gradient schema:gradient)
(sm/register! ::image-color schema:image-color) (sm/register! ::image-color schema:image-color)
(sm/register! ::recent-color schema:recent-color) (sm/register! ::recent-color schema:recent-color)
(def check-color! (def valid-color?
(sm/check-fn schema:color)) (sm/lazy-validator schema:color))
(def check-recent-color! (def valid-recent-color?
(sm/check-fn schema:recent-color)) (sm/lazy-validator schema:recent-color))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS ;; HELPERS
@ -392,13 +391,22 @@
(process-shape-colors shape sync-color))) (process-shape-colors shape sync-color)))
(defn eq-recent-color? (defn- eq-recent-color?
[c1 c2] [c1 c2]
(or (= c1 c2) (or (= c1 c2)
(and (some? (:color c1)) (and (some? (:color c1))
(some? (:color c2)) (some? (:color c2))
(= (:color c1) (:color c2))))) (= (:color c1) (:color c2)))))
(defn add-recent-color
"Moves the color to the top of the list and then truncates up to 15"
[state file-id color]
(update state file-id (fn [colors]
(let [colors (d/removev (partial eq-recent-color? color) colors)
colors (conj colors color)]
(cond-> colors
(> (count colors) 15)
(subvec 1))))))
(defn stroke->color-att (defn stroke->color-att
[stroke file-id shared-libs] [stroke file-id shared-libs]

View file

@ -21,10 +21,12 @@
[app.main.repo :as rp] [app.main.repo :as rp]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt] [app.util.router :as rt]
[app.util.storage :refer [storage]] [app.util.storage :as s]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
(declare update-profile-props)
;; --- SCHEMAS ;; --- SCHEMAS
(def ^:private (def ^:private
@ -49,14 +51,14 @@
(defn get-current-team-id (defn get-current-team-id
[profile] [profile]
(let [team-id (::current-team-id @storage)] (let [team-id (::current-team-id @s/storage)]
(or team-id (:default-team-id profile)))) (or team-id (:default-team-id profile))))
(defn set-current-team! (defn set-current-team!
[team-id] [team-id]
(if (nil? team-id) (if (nil? team-id)
(swap! storage dissoc ::current-team-id) (swap! s/storage dissoc ::current-team-id)
(swap! storage assoc ::current-team-id team-id))) (swap! s/storage assoc ::current-team-id team-id)))
;; --- EVENT: fetch-teams ;; --- EVENT: fetch-teams
@ -76,9 +78,9 @@
;; if not, dissoc it from storage. ;; if not, dissoc it from storage.
(let [ids (into #{} (map :id) teams)] (let [ids (into #{} (map :id) teams)]
(when-let [ctid (::current-team-id @storage)] (when-let [ctid (::current-team-id @s/storage)]
(when-not (contains? ids ctid) (when-not (contains? ids ctid)
(swap! storage dissoc ::current-team-id))))))) (swap! s/storage dissoc ::current-team-id)))))))
(defn fetch-teams (defn fetch-teams
[] []
@ -129,10 +131,10 @@
(effect [_ state _] (effect [_ state _]
(let [profile (:profile state) (let [profile (:profile state)
email (:email profile) email (:email profile)
previous-profile (:profile @storage) previous-profile (:profile @s/storage)
previous-email (:email previous-profile)] previous-email (:email previous-profile)]
(when profile (when profile
(swap! storage assoc :profile profile) (swap! s/storage assoc :profile profile)
(i18n/set-locale! (:lang profile)) (i18n/set-locale! (:lang profile))
(when (not= previous-email email) (when (not= previous-email email)
(set-current-team! nil))))))) (set-current-team! nil)))))))
@ -152,9 +154,15 @@
profile. The profile can proceed from standard login or from profile. The profile can proceed from standard login or from
accepting invitation, or third party auth signup or singin." accepting invitation, or third party auth signup or singin."
[profile] [profile]
(letfn [(get-redirect-event [] (letfn [(get-redirect-events []
(let [team-id (get-current-team-id profile)] (let [team-id (get-current-team-id profile)
(rt/nav' :dashboard-projects {:team-id team-id})))] welcome-file-id (get-in profile [:props :welcome-file-id])]
(if (some? welcome-file-id)
(rx/of
(rt/nav' :workspace {:project-id (:default-project-id profile)
:file-id welcome-file-id})
(update-profile-props {:welcome-file-id nil}))
(rx/of (rt/nav' :dashboard-projects {:team-id team-id})))))]
(ptk/reify ::logged-in (ptk/reify ::logged-in
ev/Event ev/Event
@ -171,10 +179,11 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ _ _]
(when (is-authenticated? profile) (when (is-authenticated? profile)
(->> (rx/of (profile-fetched profile) (->> (rx/concat
(fetch-teams) (rx/of (profile-fetched profile)
(get-redirect-event) (fetch-teams)
(ws/initialize)) (ws/initialize))
(get-redirect-events))
(rx/observe-on :async))))))) (rx/observe-on :async)))))))
(declare login-from-register) (declare login-from-register)
@ -311,7 +320,7 @@
ptk/EffectEvent ptk/EffectEvent
(effect [_ _ _] (effect [_ _ _]
;; We prefer to keek some stuff in the storage like the current-team-id and the profile ;; We prefer to keek some stuff in the storage like the current-team-id and the profile
(set-current-team! nil))))) (swap! s/storage (constantly {}))))))
(defn logout (defn logout
([] (logout {})) ([] (logout {}))

View file

@ -79,6 +79,7 @@
[app.util.http :as http] [app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt] [app.util.router :as rt]
[app.util.storage :refer [storage]]
[app.util.timers :as tm] [app.util.timers :as tm]
[app.util.webapi :as wapi] [app.util.webapi :as wapi]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@ -335,6 +336,7 @@
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(assoc state (assoc state
:recent-colors (:recent-colors @storage)
:workspace-ready? false :workspace-ready? false
:current-file-id file-id :current-file-id file-id
:current-project-id project-id :current-project-id project-id

View file

@ -48,6 +48,7 @@
[app.util.color :as uc] [app.util.color :as uc]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[app.util.router :as rt] [app.util.router :as rt]
[app.util.storage :as s]
[app.util.time :as dt] [app.util.time :as dt]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cuerdas.core :as str] [cuerdas.core :as str]
@ -132,16 +133,21 @@
(defn add-recent-color (defn add-recent-color
[color] [color]
(dm/assert! (dm/assert!
"expected valid recent color map" "expected valid recent color map"
(ctc/check-recent-color! color)) (ctc/valid-recent-color? color))
(ptk/reify ::add-recent-color (ptk/reify ::add-recent-color
ptk/WatchEvent ptk/UpdateEvent
(watch [it _ _] (update [_ state]
(let [changes (-> (pcb/empty-changes it) (let [file-id (:current-file-id state)]
(pcb/add-recent-color color))] (update state :recent-colors ctc/add-recent-color file-id color)))
(rx/of (dch/commit-changes changes))))))
ptk/EffectEvent
(effect [_ state _]
(let [recent-colors (:recent-colors state)]
(swap! s/storage assoc :recent-colors recent-colors)))))
(def clear-color-for-rename (def clear-color-for-rename
(ptk/reify ::clear-color-for-rename (ptk/reify ::clear-color-for-rename
@ -168,8 +174,11 @@
(dm/assert! (dm/assert!
"expected valid parameters" "expected valid parameters"
(and (ctc/check-color! color) (ctc/valid-color? color))
(uuid? file-id)))
(dm/assert!
"expected file-id"
(uuid? file-id))
(ptk/reify ::update-color (ptk/reify ::update-color
ptk/WatchEvent ptk/WatchEvent

View file

@ -236,9 +236,10 @@
=)) =))
(def workspace-recent-colors (def workspace-recent-colors
(l/derived (fn [data] (l/derived (fn [state]
(get data :recent-colors [])) (when-let [file-id (:current-file-id state)]
workspace-data)) (dm/get-in state [:recent-colors file-id])))
st/state))
(def workspace-recent-fonts (def workspace-recent-fonts
(l/derived (fn [data] (l/derived (fn [data]

View file

@ -44,7 +44,30 @@
(mf/defc main-page (mf/defc main-page
{::mf/props :obj} {::mf/props :obj}
[{:keys [route profile]}] [{:keys [route profile]}]
(let [{:keys [data params]} route] (let [{:keys [data params]} route
props (get profile :props)
show-question-modal?
(and (contains? cf/flags :onboarding)
(not (:onboarding-viewed props))
(not (contains? props :onboarding-questions)))
show-newsletter-modal?
(and (contains? cf/flags :onboarding)
(not (:onboarding-viewed props))
(not (contains? props :newsletter-updates))
(contains? props :onboarding-questions))
show-team-modal?
(and (contains? cf/flags :onboarding)
(not (:onboarding-viewed props))
(not (contains? props :onboarding-team-id))
(contains? props :newsletter-updates))
show-release-modal?
(and (contains? cf/flags :onboarding)
(:onboarding-viewed props)
(not= (:release-notes-viewed props) (:main cf/version))
(not= "0.0" (:main cf/version)))]
[:& (mf/provider ctx/current-route) {:value route} [:& (mf/provider ctx/current-route) {:value route}
(case (:name data) (case (:name data)
(:auth-login (:auth-login
@ -84,42 +107,19 @@
#_[:& app.main.ui.onboarding/onboarding-templates-modal] #_[:& app.main.ui.onboarding/onboarding-templates-modal]
#_[:& app.main.ui.onboarding/onboarding-modal] #_[:& app.main.ui.onboarding/onboarding-modal]
#_[:& app.main.ui.onboarding.team-choice/onboarding-team-modal] #_[:& app.main.ui.onboarding.team-choice/onboarding-team-modal]
(when-let [props (get profile :props)]
(let [show-question-modal?
(and (contains? cf/flags :onboarding)
(not (:onboarding-viewed props))
(not (contains? props :onboarding-questions)))
show-newsletter-modal? (cond
(and (contains? cf/flags :onboarding) show-question-modal?
(not (:onboarding-viewed props)) [:& questions-modal]
(not (contains? props :newsletter-updates))
(contains? props :onboarding-questions))
show-team-modal? show-newsletter-modal?
(and (contains? cf/flags :onboarding) [:& onboarding-newsletter]
(not (:onboarding-viewed props))
(not (contains? props :onboarding-team-id))
(contains? props :newsletter-updates))
show-release-modal? show-team-modal?
(and (contains? cf/flags :onboarding) [:& onboarding-team-modal {:go-to-team? true}]
(:onboarding-viewed props)
(not= (:release-notes-viewed props) (:main cf/version))
(not= "0.0" (:main cf/version)))]
(cond show-release-modal?
show-question-modal? [:& release-notes-modal {:version (:main cf/version)}])
[:& questions-modal]
show-newsletter-modal?
[:& onboarding-newsletter]
show-team-modal?
[:& onboarding-team-modal]
show-release-modal?
[:& release-notes-modal {:version (:main cf/version)}])))
[:& dashboard-page {:route route :profile profile}]] [:& dashboard-page {:route route :profile profile}]]
:viewer :viewer
@ -154,6 +154,20 @@
page-id (some-> params :query :page-id uuid) page-id (some-> params :query :page-id uuid)
layout (some-> params :query :layout keyword)] layout (some-> params :query :layout keyword)]
[:? {} [:? {}
(when (cf/external-feature-flag "onboarding-03" "test")
(cond
show-question-modal?
[:& questions-modal]
show-newsletter-modal?
[:& onboarding-newsletter]
show-team-modal?
[:& onboarding-team-modal {:go-to-team? false}]
show-release-modal?
[:& release-notes-modal {:version (:main cf/version)}]))
[:& workspace-page {:project-id project-id [:& workspace-page {:project-id project-id
:file-id file-id :file-id file-id
:page-id page-id :page-id page-id

View file

@ -39,7 +39,8 @@
form (fm/use-form :schema schema:register-form form (fm/use-form :schema schema:register-form
:initial initial) :initial initial)
submitted? (mf/use-state false) submitted?
(mf/use-state false)
on-error on-error
(mf/use-fn (mf/use-fn
@ -176,7 +177,9 @@
::mf/private true} ::mf/private true}
[{:keys [params on-success-callback]}] [{:keys [params on-success-callback]}]
(let [form (fm/use-form :schema schema:register-validate-form :initial params) (let [form (fm/use-form :schema schema:register-validate-form :initial params)
submitted? (mf/use-state false)
submitted?
(mf/use-state false)
on-success on-success
(mf/use-fn (mf/use-fn
@ -208,7 +211,13 @@
(mf/deps on-success on-error) (mf/deps on-success on-error)
(fn [form _] (fn [form _]
(reset! submitted? true) (reset! submitted? true)
(let [params (:clean-data @form)] (let [create-welcome-file?
(cf/external-feature-flag "onboarding-03" "test")
params
(cond-> (:clean-data @form)
create-welcome-file? (assoc :create-welcome-file true))]
(->> (rp/cmd! :register-profile params) (->> (rp/cmd! :register-profile params)
(rx/finalize #(reset! submitted? false)) (rx/finalize #(reset! submitted? false))
(rx/subs! on-success on-error)))))] (rx/subs! on-success on-error)))))]

View file

@ -519,8 +519,10 @@
@include bodySmallTypography; @include bodySmallTypography;
color: var(--modal-title-foreground-color); color: var(--modal-title-foreground-color);
} }
// TODO: This fix is temporary, the error is caused by the
.custom-input-checkbox { // cascading order of the compiled css files.
// https://tree.taiga.io/project/penpot/task/8658
.custom-input-checkbox.custom-input-checkbox {
align-items: flex-start; align-items: flex-start;
} }

View file

@ -168,7 +168,9 @@
[{:keys [default-project-id profile project-id team-id]}] [{:keys [default-project-id profile project-id team-id]}]
(let [templates (mf/deref builtin-templates) (let [templates (mf/deref builtin-templates)
templates (mf/with-memo [templates] templates (mf/with-memo [templates]
(filterv #(not= (:id %) "tutorial-for-beginners") templates)) (filterv #(and
(not= (:id %) "welcome")
(not= (:id %) "tutorial-for-beginners")) templates))
route (mf/deref refs/route) route (mf/deref refs/route)
route-name (get-in route [:data :name]) route-name (get-in route [:data :name])

View file

@ -294,19 +294,21 @@
`key` for new values." `key` for new values."
[key default] [key default]
(let [id (mf/use-id) (let [id (mf/use-id)
state (mf/use-state (get @storage key default)) state* (mf/use-state #(get @storage key default))
state (deref state*)
stream (mf/with-memo [id] stream (mf/with-memo [id]
(->> mbc/stream (->> mbc/stream
(rx/filter #(not= (:id %) id)) (rx/filter #(not= (:id %) id))
(rx/filter #(= (:type %) key)) (rx/filter #(= (:type %) key))
(rx/map deref)))] (rx/map deref)))]
(mf/with-effect [@state key id] (mf/with-effect [state key id]
(mbc/emit! id key @state) (mbc/emit! id key state)
(swap! storage assoc key @state)) (swap! storage assoc key state))
(use-stream stream (partial reset! state)) (use-stream stream (partial reset! state*))
state))
state*))
(defonce ^:private intersection-subject (rx/subject)) (defonce ^:private intersection-subject (rx/subject))
(defonce ^:private intersection-observer (defonce ^:private intersection-observer

View file

@ -6,6 +6,7 @@
(ns app.main.ui.hooks.resize (ns app.main.ui.hooks.resize
(:require (:require
[app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.logging :as log] [app.common.logging :as log]
@ -20,6 +21,15 @@
(def last-resize-type nil) (def last-resize-type nil)
(defn- get-initial-state
[initial file-id key]
(let [saved (dm/get-in @storage [::state file-id key])]
(d/nilv saved initial)))
(defn- update-persistent-state
[data file-id key size]
(update-in data [::state file-id] assoc key size))
(defn set-resize-type! [type] (defn set-resize-type! [type]
(set! last-resize-type type)) (set! last-resize-type type))
@ -28,26 +38,28 @@
(use-resize-hook key initial min-val max-val axis negate? resize-type nil)) (use-resize-hook key initial min-val max-val axis negate? resize-type nil))
([key initial min-val max-val axis negate? resize-type on-change-size] ([key initial min-val max-val axis negate? resize-type on-change-size]
(let [current-file-id (mf/use-ctx ctx/current-file-id) (let [file-id (mf/use-ctx ctx/current-file-id)
size-state (mf/use-state (or (get-in @storage [::saved-resize current-file-id key]) initial))
parent-ref (mf/use-ref nil)
dragging-ref (mf/use-ref false) current-size* (mf/use-state #(get-initial-state initial file-id key))
current-size (deref current-size*)
parent-ref (mf/use-ref nil)
dragging-ref (mf/use-ref false)
start-size-ref (mf/use-ref nil) start-size-ref (mf/use-ref nil)
start-ref (mf/use-ref nil) start-ref (mf/use-ref nil)
on-pointer-down on-pointer-down
(mf/use-callback (mf/use-fn
(mf/deps @size-state) (mf/deps current-size)
(fn [event] (fn [event]
(dom/capture-pointer event) (dom/capture-pointer event)
(mf/set-ref-val! start-size-ref @size-state) (mf/set-ref-val! start-size-ref current-size)
(mf/set-ref-val! dragging-ref true) (mf/set-ref-val! dragging-ref true)
(mf/set-ref-val! start-ref (dom/get-client-position event)) (mf/set-ref-val! start-ref (dom/get-client-position event))
(set! last-resize-type resize-type))) (set! last-resize-type resize-type)))
on-lost-pointer-capture on-lost-pointer-capture
(mf/use-callback (mf/use-fn
(fn [event] (fn [event]
(dom/release-pointer event) (dom/release-pointer event)
(mf/set-ref-val! start-size-ref nil) (mf/set-ref-val! start-size-ref nil)
@ -56,40 +68,39 @@
(set! last-resize-type nil))) (set! last-resize-type nil)))
on-pointer-move on-pointer-move
(mf/use-callback (mf/use-fn
(mf/deps min-val max-val negate?) (mf/deps min-val max-val negate? file-id key)
(fn [event] (fn [event]
(when (mf/ref-val dragging-ref) (when (mf/ref-val dragging-ref)
(let [start (mf/ref-val start-ref) (let [start (mf/ref-val start-ref)
pos (dom/get-client-position event) pos (dom/get-client-position event)
delta (-> (gpt/to-vec start pos) delta (-> (gpt/to-vec start pos)
(cond-> negate? gpt/negate) (cond-> negate? gpt/negate)
(get axis)) (get axis))
start-size (mf/ref-val start-size-ref) start-size (mf/ref-val start-size-ref)
new-size (-> (+ start-size delta) (max min-val) (min max-val))] new-size (-> (+ start-size delta) (max min-val) (min max-val))]
(reset! size-state new-size) (reset! current-size* new-size)
(swap! storage assoc-in [::saved-resize current-file-id key] new-size) (swap! storage update-persistent-state file-id key new-size)))))
(when on-change-size (on-change-size new-size))))))
set-size set-size
(mf/use-callback (mf/use-fn
(mf/deps on-change-size) (mf/deps on-change-size file-id key)
(fn [new-size] (fn [new-size]
(let [new-size (mth/clamp new-size min-val max-val)] (let [new-size (mth/clamp new-size min-val max-val)]
(reset! size-state new-size) (reset! current-size* new-size)
(swap! storage assoc-in [::saved-resize current-file-id key] new-size) (swap! storage update-persistent-state file-id key new-size))))]
(when on-change-size (on-change-size new-size)))))]
(mf/use-effect (mf/with-effect [on-change-size current-size]
(fn [] (when on-change-size
(when on-change-size (on-change-size @size-state)))) (on-change-size current-size)))
{:on-pointer-down on-pointer-down {:on-pointer-down on-pointer-down
:on-lost-pointer-capture on-lost-pointer-capture :on-lost-pointer-capture on-lost-pointer-capture
:on-pointer-move on-pointer-move :on-pointer-move on-pointer-move
:parent-ref parent-ref :parent-ref parent-ref
:set-size set-size :set-size set-size
:size @size-state}))) :size current-size})))
(defn use-resize-observer (defn use-resize-observer
[callback] [callback]

View file

@ -66,7 +66,7 @@
(mf/defc team-form-step-2 (mf/defc team-form-step-2
{::mf/props :obj} {::mf/props :obj}
[{:keys [name on-back]}] [{:keys [name on-back go-to-team?]}]
(let [initial (mf/use-memo (let [initial (mf/use-memo
#(do {:role "editor" #(do {:role "editor"
:name name})) :name name}))
@ -85,7 +85,8 @@
(let [team-id (:id response)] (let [team-id (:id response)]
(st/emit! (du/update-profile-props {:onboarding-team-id team-id (st/emit! (du/update-profile-props {:onboarding-team-id team-id
:onboarding-viewed true}) :onboarding-viewed true})
(rt/nav :dashboard-projects {:team-id team-id}))))) (when go-to-team?
(rt/nav :dashboard-projects {:team-id team-id}))))))
on-error on-error
(mf/use-fn (mf/use-fn
@ -240,7 +241,7 @@
(mf/defc onboarding-team-modal (mf/defc onboarding-team-modal
{::mf/props :obj} {::mf/props :obj}
[] [{:keys [go-to-team?]}]
(let [name* (mf/use-state nil) (let [name* (mf/use-state nil)
name (deref name*) name (deref name*)
@ -262,6 +263,6 @@
[:& left-sidebar] [:& left-sidebar]
[:div {:class (stl/css :separator)}] [:div {:class (stl/css :separator)}]
(if name (if name
[:& team-form-step-2 {:name name :on-back on-back}] [:& team-form-step-2 {:name name :on-back on-back :go-to-team? go-to-team?}]
[:& team-form-step-1 {:on-submit on-submit}])]])) [:& team-form-step-1 {:on-submit on-submit}])]]))

View file

@ -6,42 +6,80 @@
(ns app.util.storage (ns app.util.storage
(:require (:require
["lodash/debounce" :as ldebounce]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.transit :as t] [app.common.transit :as t]
[app.util.globals :as g] [app.util.globals :as g]
[app.util.timers :as tm])) [cuerdas.core :as str]))
(defn- persist ;; Using ex/ignoring because can receive a DOMException like this when
[storage prev curr] ;; importing the code as a library: Failed to read the 'localStorage'
(run! (fn [key] ;; property from 'Window': Storage is disabled inside 'data:' URLs.
(let [prev* (get prev key) (defonce ^:private local-storage
curr* (get curr key)] (ex/ignoring (unchecked-get g/global "localStorage")))
(when (not= curr* prev*)
(tm/schedule-on-idle
#(if (some? curr*)
(.setItem ^js storage (t/encode-str key) (t/encode-str curr*))
(.removeItem ^js storage (t/encode-str key)))))))
(into #{} (concat (keys curr) (defn- encode-key
(keys prev))))) [k]
(assert (keyword? k) "key must be keyword")
(let [kns (namespace k)
kn (name k)]
(str "penpot:" kns "/" kn)))
(defn- decode-key
[k]
(when (str/starts-with? k "penpot:")
(let [k (subs k 7)]
(if (str/starts-with? k "/")
(keyword (subs k 1))
(let [[kns kn] (str/split k "/" 2)]
(keyword kns kn))))))
(defn- lookup-by-index
[result index]
(try
(let [key (.key ^js local-storage index)
key' (decode-key key)]
(if key'
(let [val (.getItem ^js local-storage key)]
(assoc! result key' (t/decode-str val)))
result))
(catch :default _
result)))
(defn- load (defn- load
[storage] []
(when storage (when (some? local-storage)
(let [len (.-length ^js storage)] (let [length (.-length ^js local-storage)]
(reduce (fn [res index] (loop [index 0
(let [key (.key ^js storage index) result (transient {})]
val (.getItem ^js storage key)] (if (< index length)
(try (recur (inc index)
(assoc res (t/decode-str key) (t/decode-str val)) (lookup-by-index result index))
(catch :default _e (persistent! result))))))
res))))
{}
(range len)))))
;; Using ex/ignoring because can receive a DOMException like this when importing the code as a library: (defonce ^:private latest-state (load))
;; Failed to read the 'localStorage' property from 'Window': Storage is disabled inside 'data:' URLs.
(defonce storage (atom (load (ex/ignoring (unchecked-get g/global "localStorage")))))
(add-watch storage :persistence #(persist js/localStorage %3 %4)) (defn- on-change*
[curr-state]
(let [prev-state latest-state]
(try
(run! (fn [key]
(let [prev-val (get prev-state key)
curr-val (get curr-state key)]
(when-not (identical? curr-val prev-val)
(if (some? curr-val)
(.setItem ^js local-storage (encode-key key) (t/encode-str curr-val))
(.removeItem ^js local-storage (encode-key key))))))
(into #{} (concat (keys curr-state)
(keys prev-state))))
(finally
(set! latest-state curr-state)))))
(defonce on-change
(ldebounce on-change* 2000 #js {:leading false :trailing true}))
(defonce storage (atom latest-state))
(add-watch storage :persistence
(fn [_ _ _ curr-state]
(on-change curr-state)))