diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_01.png b/desktop/public/app_icons_with_badge/app_icon_badge_01.png new file mode 100644 index 0000000000..5304235582 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_01.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_02.png b/desktop/public/app_icons_with_badge/app_icon_badge_02.png new file mode 100644 index 0000000000..4fc56cc3ae Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_02.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_03.png b/desktop/public/app_icons_with_badge/app_icon_badge_03.png new file mode 100644 index 0000000000..aca09d640e Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_03.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_04.png b/desktop/public/app_icons_with_badge/app_icon_badge_04.png new file mode 100644 index 0000000000..bb93313b3f Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_04.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_05.png b/desktop/public/app_icons_with_badge/app_icon_badge_05.png new file mode 100644 index 0000000000..ca6ce09bc2 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_05.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_06.png b/desktop/public/app_icons_with_badge/app_icon_badge_06.png new file mode 100644 index 0000000000..6905501f9a Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_06.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_07.png b/desktop/public/app_icons_with_badge/app_icon_badge_07.png new file mode 100644 index 0000000000..048811ef29 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_07.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_08.png b/desktop/public/app_icons_with_badge/app_icon_badge_08.png new file mode 100644 index 0000000000..b83f6d7d23 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_08.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_09.png b/desktop/public/app_icons_with_badge/app_icon_badge_09.png new file mode 100644 index 0000000000..2cadfd2b26 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_09.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_10.png b/desktop/public/app_icons_with_badge/app_icon_badge_10.png new file mode 100644 index 0000000000..e8fd39a95e Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_10.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_11.png b/desktop/public/app_icons_with_badge/app_icon_badge_11.png new file mode 100644 index 0000000000..d8e83ffe04 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_11.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_12.png b/desktop/public/app_icons_with_badge/app_icon_badge_12.png new file mode 100644 index 0000000000..8c5f930dd7 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_12.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_13.png b/desktop/public/app_icons_with_badge/app_icon_badge_13.png new file mode 100644 index 0000000000..bd6c69d088 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_13.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_14.png b/desktop/public/app_icons_with_badge/app_icon_badge_14.png new file mode 100644 index 0000000000..d3bab24989 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_14.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_15.png b/desktop/public/app_icons_with_badge/app_icon_badge_15.png new file mode 100644 index 0000000000..620bcbf1ec Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_15.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_16.png b/desktop/public/app_icons_with_badge/app_icon_badge_16.png new file mode 100644 index 0000000000..5de3e8620d Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_16.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_17.png b/desktop/public/app_icons_with_badge/app_icon_badge_17.png new file mode 100644 index 0000000000..a20e8a6a73 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_17.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_18.png b/desktop/public/app_icons_with_badge/app_icon_badge_18.png new file mode 100644 index 0000000000..5038b245a4 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_18.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_19.png b/desktop/public/app_icons_with_badge/app_icon_badge_19.png new file mode 100644 index 0000000000..ac921ddc30 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_19.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_20.png b/desktop/public/app_icons_with_badge/app_icon_badge_20.png new file mode 100644 index 0000000000..29f9a8d3e3 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_20.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_21.png b/desktop/public/app_icons_with_badge/app_icon_badge_21.png new file mode 100644 index 0000000000..27ea0faa95 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_21.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_22.png b/desktop/public/app_icons_with_badge/app_icon_badge_22.png new file mode 100644 index 0000000000..e763b08fe4 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_22.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_23.png b/desktop/public/app_icons_with_badge/app_icon_badge_23.png new file mode 100644 index 0000000000..ad41a91f1e Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_23.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_24.png b/desktop/public/app_icons_with_badge/app_icon_badge_24.png new file mode 100644 index 0000000000..84bde07a25 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_24.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_25.png b/desktop/public/app_icons_with_badge/app_icon_badge_25.png new file mode 100644 index 0000000000..b129002486 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_25.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_26.png b/desktop/public/app_icons_with_badge/app_icon_badge_26.png new file mode 100644 index 0000000000..93d96fb115 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_26.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_27.png b/desktop/public/app_icons_with_badge/app_icon_badge_27.png new file mode 100644 index 0000000000..26b44a8340 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_27.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_28.png b/desktop/public/app_icons_with_badge/app_icon_badge_28.png new file mode 100644 index 0000000000..a80446a556 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_28.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_29.png b/desktop/public/app_icons_with_badge/app_icon_badge_29.png new file mode 100644 index 0000000000..9bcd29bfd7 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_29.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_30.png b/desktop/public/app_icons_with_badge/app_icon_badge_30.png new file mode 100644 index 0000000000..217b853c6f Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_30.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_31.png b/desktop/public/app_icons_with_badge/app_icon_badge_31.png new file mode 100644 index 0000000000..38e6717279 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_31.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_32.png b/desktop/public/app_icons_with_badge/app_icon_badge_32.png new file mode 100644 index 0000000000..f01b90a1f2 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_32.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_33.png b/desktop/public/app_icons_with_badge/app_icon_badge_33.png new file mode 100644 index 0000000000..def34ddb7c Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_33.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_34.png b/desktop/public/app_icons_with_badge/app_icon_badge_34.png new file mode 100644 index 0000000000..4da76a59aa Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_34.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_35.png b/desktop/public/app_icons_with_badge/app_icon_badge_35.png new file mode 100644 index 0000000000..7c2cb7ae81 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_35.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_36.png b/desktop/public/app_icons_with_badge/app_icon_badge_36.png new file mode 100644 index 0000000000..35193e4f21 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_36.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_37.png b/desktop/public/app_icons_with_badge/app_icon_badge_37.png new file mode 100644 index 0000000000..cd01ef71be Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_37.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_38.png b/desktop/public/app_icons_with_badge/app_icon_badge_38.png new file mode 100644 index 0000000000..30f5dab0fa Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_38.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_39.png b/desktop/public/app_icons_with_badge/app_icon_badge_39.png new file mode 100644 index 0000000000..50810be147 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_39.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_40.png b/desktop/public/app_icons_with_badge/app_icon_badge_40.png new file mode 100644 index 0000000000..627a8f022a Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_40.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_41.png b/desktop/public/app_icons_with_badge/app_icon_badge_41.png new file mode 100644 index 0000000000..e46c922aff Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_41.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_42.png b/desktop/public/app_icons_with_badge/app_icon_badge_42.png new file mode 100644 index 0000000000..34c163ad70 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_42.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_43.png b/desktop/public/app_icons_with_badge/app_icon_badge_43.png new file mode 100644 index 0000000000..22aa9514c3 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_43.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_44.png b/desktop/public/app_icons_with_badge/app_icon_badge_44.png new file mode 100644 index 0000000000..41fee189d9 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_44.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_45.png b/desktop/public/app_icons_with_badge/app_icon_badge_45.png new file mode 100644 index 0000000000..0c1629fb65 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_45.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_46.png b/desktop/public/app_icons_with_badge/app_icon_badge_46.png new file mode 100644 index 0000000000..d283a9f799 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_46.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_47.png b/desktop/public/app_icons_with_badge/app_icon_badge_47.png new file mode 100644 index 0000000000..2bf653c004 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_47.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_48.png b/desktop/public/app_icons_with_badge/app_icon_badge_48.png new file mode 100644 index 0000000000..2d56a00e05 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_48.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_49.png b/desktop/public/app_icons_with_badge/app_icon_badge_49.png new file mode 100644 index 0000000000..bdb1641d25 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_49.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_50.png b/desktop/public/app_icons_with_badge/app_icon_badge_50.png new file mode 100644 index 0000000000..b171b97b03 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_50.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_51.png b/desktop/public/app_icons_with_badge/app_icon_badge_51.png new file mode 100644 index 0000000000..e92dd23f05 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_51.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_52.png b/desktop/public/app_icons_with_badge/app_icon_badge_52.png new file mode 100644 index 0000000000..1a9ca26dfe Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_52.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_53.png b/desktop/public/app_icons_with_badge/app_icon_badge_53.png new file mode 100644 index 0000000000..ec98cb477a Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_53.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_54.png b/desktop/public/app_icons_with_badge/app_icon_badge_54.png new file mode 100644 index 0000000000..c6092d5c37 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_54.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_55.png b/desktop/public/app_icons_with_badge/app_icon_badge_55.png new file mode 100644 index 0000000000..b562011b52 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_55.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_56.png b/desktop/public/app_icons_with_badge/app_icon_badge_56.png new file mode 100644 index 0000000000..e2e14dfdef Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_56.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_57.png b/desktop/public/app_icons_with_badge/app_icon_badge_57.png new file mode 100644 index 0000000000..ca14e061d6 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_57.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_58.png b/desktop/public/app_icons_with_badge/app_icon_badge_58.png new file mode 100644 index 0000000000..6ba64128eb Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_58.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_59.png b/desktop/public/app_icons_with_badge/app_icon_badge_59.png new file mode 100644 index 0000000000..0bc442c77d Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_59.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_60.png b/desktop/public/app_icons_with_badge/app_icon_badge_60.png new file mode 100644 index 0000000000..88ab1af0bb Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_60.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_61.png b/desktop/public/app_icons_with_badge/app_icon_badge_61.png new file mode 100644 index 0000000000..791c9dcf19 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_61.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_62.png b/desktop/public/app_icons_with_badge/app_icon_badge_62.png new file mode 100644 index 0000000000..d6b6a8da86 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_62.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_63.png b/desktop/public/app_icons_with_badge/app_icon_badge_63.png new file mode 100644 index 0000000000..24ac975b10 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_63.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_64.png b/desktop/public/app_icons_with_badge/app_icon_badge_64.png new file mode 100644 index 0000000000..c167cf7565 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_64.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_65.png b/desktop/public/app_icons_with_badge/app_icon_badge_65.png new file mode 100644 index 0000000000..fd439b653a Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_65.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_66.png b/desktop/public/app_icons_with_badge/app_icon_badge_66.png new file mode 100644 index 0000000000..a4b2512136 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_66.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_67.png b/desktop/public/app_icons_with_badge/app_icon_badge_67.png new file mode 100644 index 0000000000..4651eefa38 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_67.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_68.png b/desktop/public/app_icons_with_badge/app_icon_badge_68.png new file mode 100644 index 0000000000..99a61edd9c Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_68.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_69.png b/desktop/public/app_icons_with_badge/app_icon_badge_69.png new file mode 100644 index 0000000000..36ed9430b9 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_69.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_70.png b/desktop/public/app_icons_with_badge/app_icon_badge_70.png new file mode 100644 index 0000000000..8e9400f270 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_70.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_71.png b/desktop/public/app_icons_with_badge/app_icon_badge_71.png new file mode 100644 index 0000000000..bab31d71ee Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_71.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_72.png b/desktop/public/app_icons_with_badge/app_icon_badge_72.png new file mode 100644 index 0000000000..3db1ca0d9b Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_72.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_73.png b/desktop/public/app_icons_with_badge/app_icon_badge_73.png new file mode 100644 index 0000000000..7ba41d01b7 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_73.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_74.png b/desktop/public/app_icons_with_badge/app_icon_badge_74.png new file mode 100644 index 0000000000..7090412d73 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_74.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_75.png b/desktop/public/app_icons_with_badge/app_icon_badge_75.png new file mode 100644 index 0000000000..820a09d44b Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_75.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_76.png b/desktop/public/app_icons_with_badge/app_icon_badge_76.png new file mode 100644 index 0000000000..0cc6f5e080 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_76.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_77.png b/desktop/public/app_icons_with_badge/app_icon_badge_77.png new file mode 100644 index 0000000000..649dfe1e23 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_77.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_78.png b/desktop/public/app_icons_with_badge/app_icon_badge_78.png new file mode 100644 index 0000000000..89b117d45d Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_78.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_79.png b/desktop/public/app_icons_with_badge/app_icon_badge_79.png new file mode 100644 index 0000000000..f4c2e33c21 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_79.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_80.png b/desktop/public/app_icons_with_badge/app_icon_badge_80.png new file mode 100644 index 0000000000..1742c7200f Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_80.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_81.png b/desktop/public/app_icons_with_badge/app_icon_badge_81.png new file mode 100644 index 0000000000..2d1c3fecf8 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_81.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_82.png b/desktop/public/app_icons_with_badge/app_icon_badge_82.png new file mode 100644 index 0000000000..d1e5ca7087 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_82.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_83.png b/desktop/public/app_icons_with_badge/app_icon_badge_83.png new file mode 100644 index 0000000000..565a3661f6 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_83.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_84.png b/desktop/public/app_icons_with_badge/app_icon_badge_84.png new file mode 100644 index 0000000000..862d01e0b2 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_84.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_85.png b/desktop/public/app_icons_with_badge/app_icon_badge_85.png new file mode 100644 index 0000000000..7b8aaa596e Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_85.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_86.png b/desktop/public/app_icons_with_badge/app_icon_badge_86.png new file mode 100644 index 0000000000..92e1cae958 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_86.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_87.png b/desktop/public/app_icons_with_badge/app_icon_badge_87.png new file mode 100644 index 0000000000..886dec1ff9 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_87.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_88.png b/desktop/public/app_icons_with_badge/app_icon_badge_88.png new file mode 100644 index 0000000000..6028a3d19c Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_88.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_89.png b/desktop/public/app_icons_with_badge/app_icon_badge_89.png new file mode 100644 index 0000000000..5590f988a8 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_89.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_90.png b/desktop/public/app_icons_with_badge/app_icon_badge_90.png new file mode 100644 index 0000000000..3a4332d354 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_90.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_91.png b/desktop/public/app_icons_with_badge/app_icon_badge_91.png new file mode 100644 index 0000000000..3c73d40173 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_91.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_92.png b/desktop/public/app_icons_with_badge/app_icon_badge_92.png new file mode 100644 index 0000000000..6c90a210ae Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_92.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_93.png b/desktop/public/app_icons_with_badge/app_icon_badge_93.png new file mode 100644 index 0000000000..17104540ad Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_93.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_94.png b/desktop/public/app_icons_with_badge/app_icon_badge_94.png new file mode 100644 index 0000000000..0bfb379a53 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_94.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_95.png b/desktop/public/app_icons_with_badge/app_icon_badge_95.png new file mode 100644 index 0000000000..183206617c Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_95.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_96.png b/desktop/public/app_icons_with_badge/app_icon_badge_96.png new file mode 100644 index 0000000000..5d8ac8b121 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_96.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_97.png b/desktop/public/app_icons_with_badge/app_icon_badge_97.png new file mode 100644 index 0000000000..6e101b679a Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_97.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_98.png b/desktop/public/app_icons_with_badge/app_icon_badge_98.png new file mode 100644 index 0000000000..15c6cef52d Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_98.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_99.png b/desktop/public/app_icons_with_badge/app_icon_badge_99.png new file mode 100644 index 0000000000..b6b1b669f3 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_99.png differ diff --git a/desktop/public/app_icons_with_badge/app_icon_badge_99plus.png b/desktop/public/app_icons_with_badge/app_icon_badge_99plus.png new file mode 100644 index 0000000000..f2a2742984 Binary files /dev/null and b/desktop/public/app_icons_with_badge/app_icon_badge_99plus.png differ diff --git a/desktop/src/__test__/main/selfCheckingNode.test.ts b/desktop/src/__test__/main/selfCheckingNode.test.ts index b21a301256..e118730320 100644 --- a/desktop/src/__test__/main/selfCheckingNode.test.ts +++ b/desktop/src/__test__/main/selfCheckingNode.test.ts @@ -14,9 +14,8 @@ // describe('node environment self checking', () => { - test('check Jest\'s node environment', () => { - const isNodeAvailable = typeof process !== 'undefined'; - expect(isNodeAvailable).toBe(true); - }) - + test('check Jest\'s node environment', () => { + const isNodeAvailable = typeof process !== 'undefined' + expect(isNodeAvailable).toBe(true) + }) }) diff --git a/desktop/src/__test__/main/settings.test.ts b/desktop/src/__test__/main/settings.test.ts new file mode 100644 index 0000000000..3db1f225b9 --- /dev/null +++ b/desktop/src/__test__/main/settings.test.ts @@ -0,0 +1,198 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Settings } from '../../main/settings' +import { PackedConfig } from '../../main/config' + +const createMockStore = (): { get: jest.Mock, set: jest.Mock } => { + const store: Record = {} + return { + get: jest.fn((key: string) => store[key]), + set: jest.fn((key: string, value: any) => { store[key] = value }) + } +} + +jest.mock('electron-store', () => { + return jest.fn() +}) + +describe('Settings', () => { + let originalEnvironment: NodeJS.ProcessEnv + let stubStoreInstance: ReturnType + + beforeEach(() => { + originalEnvironment = process.env + process.env = { ...originalEnvironment } + stubStoreInstance = createMockStore() + jest.clearAllMocks() + }) + + afterEach(() => { + process.env = originalEnvironment + jest.clearAllMocks() + }) + + describe('readServerUrl', () => { + test('isDev is true', () => { + const systemUnderTest = new Settings(stubStoreInstance as any, true) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe('http://huly.local:8087') + }) + + test('isDev is true and FRONT_URL is set', () => { + process.env.FRONT_URL = 'http://custom-dev.local:3000' + const systemUnderTest = new Settings(stubStoreInstance as any, true) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe('http://custom-dev.local:3000') + }) + + test('isDev is false and server is in store', () => { + const expectedUrl = 'https://stored.server.com' + stubStoreInstance.get.mockReturnValue(expectedUrl) + const systemUnderTest = new Settings(stubStoreInstance as any, false) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe(expectedUrl) + }) + + test('store is empty and packed config exists', () => { + stubStoreInstance.get.mockReturnValue(undefined) + const expectedUrl = 'https://packed.server.com' + const packedConfig: PackedConfig = { server: expectedUrl } + const systemUnderTest = new Settings(stubStoreInstance as any, false, packedConfig) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe(expectedUrl) + }) + + test('store and packed config are empty', () => { + stubStoreInstance.get.mockReturnValue(undefined) + const expectedUrl = 'https://env.server.com' + process.env.FRONT_URL = expectedUrl + const systemUnderTest = new Settings(stubStoreInstance as any, false) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe(expectedUrl) + }) + + test('all options are unavailable', () => { + stubStoreInstance.get.mockReturnValue(undefined) + delete process.env.FRONT_URL + const systemUnderTest = new Settings(stubStoreInstance as any, false) + + const result = systemUnderTest.readServerUrl() + + expect(result).toBe('https://huly.app') + }) + + test('all (store, packed config, environment) options are available', () => { + const expectedUrl = 'https://store.priority.com' + stubStoreInstance.get.mockReturnValue(expectedUrl) + process.env.FRONT_URL = 'https://env.server.com' + const packedConfig: PackedConfig = { server: 'https://packed.server.com' } + const systemUnderTest = new Settings(stubStoreInstance as any, false, packedConfig) + + const actualUrl = systemUnderTest.readServerUrl() + + expect(actualUrl).toBe(expectedUrl) + }) + }) + + describe('read/write', () => { + let systemUnderTest: Settings + beforeEach(() => { + systemUnderTest = new Settings(stubStoreInstance as any, false) + }) + + describe('isMinimizeToTrayEnabled', () => { + test.each([ + { storedValue: true, expected: true, description: 'stored value is true' }, + { storedValue: false, expected: false, description: 'stored value is false' } + ])('$description', ({ storedValue, expected }) => { + stubStoreInstance.get.mockReturnValue(storedValue) + + const actualValue = systemUnderTest.isMinimizeToTrayEnabled() + + expect(actualValue).toBe(expected) + }) + + test.each([ + { storedValue: undefined, description: 'no value is stored (undefined)' }, + { storedValue: null, description: 'stored value is null' } + ])('$description', ({ storedValue }) => { + stubStoreInstance.get.mockReturnValue(storedValue) + + const actualValue = systemUnderTest.isMinimizeToTrayEnabled() + + expect(actualValue).toBe(false) + }) + }) + + describe('setMinimizeToTrayEnabled', () => { + test.each([ + { value: true, description: 'stored value is true' }, + { value: false, description: 'stored value is false' } + ])('$description', ({ value }) => { + systemUnderTest.setMinimizeToTrayEnabled(value) + + expect(systemUnderTest.isMinimizeToTrayEnabled()).toBe(value) + }) + }) + + describe('setServerUrl', () => { + test('write than read', () => { + const expectedUrl = 'https://new.server.com' + + systemUnderTest.setServerUrl(expectedUrl) + + expect(systemUnderTest.readServerUrl()).toBe(expectedUrl) + }) + }) + + describe('getWindowBounds', () => { + test('read', () => { + const expectedValue = { x: 100, y: 300, width: 500, height: 700 } + stubStoreInstance.get.mockReturnValue(expectedValue) + + const actulValue = systemUnderTest.getWindowBounds() + + expect(actulValue).toEqual(expectedValue) + }) + + test('no bounds are stored', () => { + stubStoreInstance.get.mockReturnValue(undefined) + + expect(systemUnderTest.getWindowBounds()).toBeUndefined() + }) + }) + + describe('setWindowBounds', () => { + test('write than read', () => { + const expectedValue = { x: 100, y: 200, width: 800, height: 600 } + + systemUnderTest.setWindowBounds(expectedValue) + + expect(systemUnderTest.getWindowBounds()).toBe(expectedValue) + }) + }) + }) +}) diff --git a/desktop/src/__test__/main/trayUtils.test.ts b/desktop/src/__test__/main/trayUtils.test.ts new file mode 100644 index 0000000000..3b1af9fe58 --- /dev/null +++ b/desktop/src/__test__/main/trayUtils.test.ts @@ -0,0 +1,67 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { getBadgeIconInfo } from '../../main/trayUtils' + +describe('getBadgeIconInfo', () => { + const baseTitle = 'Huly' + + it('count of 0', () => { + const result = getBadgeIconInfo(0, baseTitle) + expect(result).toEqual({ + fileName: '', + tooltip: baseTitle + }) + }) + + it('negative count', () => { + const result = getBadgeIconInfo(-5, baseTitle) + expect(result).toEqual({ + fileName: '', + tooltip: baseTitle + }) + }) + + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9])('count of %i', (count: number) => { + const result = getBadgeIconInfo(count, baseTitle) + expect(result).toEqual({ + fileName: `app_icon_badge_0${count}.png`, + tooltip: `${baseTitle}: ${count} unread` + }) + }) + + describe('badge count 10-98', () => { + const testCases: number[] = [] + for (let i = 10; i <= 98; i++) { + testCases.push(i) + } + + it.each(testCases)('count of %i', (count: number) => { + const result = getBadgeIconInfo(count, baseTitle) + expect(result).toEqual({ + fileName: `app_icon_badge_${count}.png`, + tooltip: `${baseTitle}: ${count} unread` + }) + }) + }) + + it.each([99, 100, 200, 301])('count of %i', (count: number) => { + const result = getBadgeIconInfo(count, baseTitle) + expect(result).toEqual({ + fileName: 'app_icon_badge_99plus.png', + tooltip: `${baseTitle}: ${count} unread` + }) + }) +}) diff --git a/desktop/src/main/config.ts b/desktop/src/main/config.ts index bd14c20136..ea6d211224 100644 --- a/desktop/src/main/config.ts +++ b/desktop/src/main/config.ts @@ -15,12 +15,12 @@ import * as path from 'path' import * as fs from 'fs' -interface PackedConfig { +export interface PackedConfig { server?: string updatesChannelKey?: string } -const configPath = path.join(process.resourcesPath, 'config/config.json') +const configPath = path.join(process.resourcesPath, 'config', 'config.json') export function readPackedConfig (): PackedConfig | undefined { if (fs.existsSync(configPath)) { diff --git a/desktop/src/main/customMenu.ts b/desktop/src/main/customMenu.ts index 915dea39ae..84b20eac2c 100644 --- a/desktop/src/main/customMenu.ts +++ b/desktop/src/main/customMenu.ts @@ -13,10 +13,11 @@ // limitations under the License. // -import { app, BrowserWindow } from 'electron' +import { BrowserWindow } from 'electron' import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } from '../ui/types' +import { TrayController } from './tray' -export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction): void { +export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction, tray: TrayController | undefined): void { if (mainWindow == null) { return } @@ -42,7 +43,7 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac mainWindow.webContents.send(CommandLogout) break case 'exit': - app.quit() + mainWindow.close() break case 'undo': mainWindow.webContents.undo() @@ -86,6 +87,13 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac case 'toggle-fullscreen': mainWindow.setFullScreen(!mainWindow.isFullScreen()) break + case 'toggle-minimize-to-tray': { + if (tray != null) { + const newSetting = tray.toggleMinimizeToTray() + mainWindow.webContents.send('minimize-to-tray-setting-changed', newSetting) + } + break + } default: console.log('unknown menu action:', action) } diff --git a/desktop/src/main/path.ts b/desktop/src/main/path.ts new file mode 100644 index 0000000000..5de505a45f --- /dev/null +++ b/desktop/src/main/path.ts @@ -0,0 +1,21 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { app } from 'electron' +import path from 'path' + +export function getFileInPublicBundledFolder (fileName: string): string { + return path.join(app.getAppPath(), 'dist', 'ui', 'public', fileName) +} diff --git a/desktop/src/main/settings.ts b/desktop/src/main/settings.ts new file mode 100644 index 0000000000..1996b23141 --- /dev/null +++ b/desktop/src/main/settings.ts @@ -0,0 +1,65 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Store from 'electron-store' +import { PackedConfig } from './config' + +export class Settings { + private static readonly SETTINGS_KEY_SERVER = 'server' + private static readonly SETTINGS_KEY_MINIMIZE_TO_TRAY = 'minimize-to-tray' + private static readonly SETTINGS_KEY_WINDOW_BOUNDS = 'windowBounds' + + private readonly store: Store + private readonly isDev: boolean + private readonly packedConfig?: PackedConfig + + constructor (store: Store, isDev: boolean, packedConfig?: PackedConfig) { + this.store = store + this.isDev = isDev + this.packedConfig = packedConfig + } + + readServerUrl (): string { + if (this.isDev) { + return process.env.FRONT_URL ?? 'http://huly.local:8087' + } + return ( + (this.store as any).get(Settings.SETTINGS_KEY_SERVER) as string ?? + this.packedConfig?.server ?? + process.env.FRONT_URL ?? + 'https://huly.app' + ) + } + + isMinimizeToTrayEnabled (): boolean { + return (this.store as any).get(Settings.SETTINGS_KEY_MINIMIZE_TO_TRAY) as boolean ?? false + } + + setMinimizeToTrayEnabled (enabled: boolean): void { + (this.store as any).set(Settings.SETTINGS_KEY_MINIMIZE_TO_TRAY, enabled) + } + + setServerUrl (serverUrl: string): void { + (this.store as any).set(Settings.SETTINGS_KEY_SERVER, serverUrl) + } + + getWindowBounds (): any { + return (this.store as any).get(Settings.SETTINGS_KEY_WINDOW_BOUNDS) + } + + setWindowBounds (bounds: any): void { + (this.store as any).set(Settings.SETTINGS_KEY_WINDOW_BOUNDS, bounds) + } +} diff --git a/desktop/src/main/start.ts b/desktop/src/main/start.ts index 021f96f71f..d915e0d9e5 100644 --- a/desktop/src/main/start.ts +++ b/desktop/src/main/start.ts @@ -14,7 +14,7 @@ // import { config as dotenvConfig } from 'dotenv' -import { BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences, nativeTheme } from 'electron' +import { Event, BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences, nativeTheme } from 'electron' import contextMenu from 'electron-context-menu' import log from 'electron-log' import Store from 'electron-store' @@ -32,8 +32,12 @@ import { generateId } from '@hcengineering/core' import { DownloadItem } from '@hcengineering/desktop-downloads' import { rebuildJumpList, setupWindowsSpecific } from './windowsSpecificSetup' import { readPackedConfig } from './config' +import { Settings } from './settings' +import { TrayController } from './tray' +import { getFileInPublicBundledFolder } from './path' let mainWindow: BrowserWindow | undefined +let isQuiting = false let winBadge: any const isMac = process.platform === 'darwin' @@ -41,7 +45,7 @@ const isWindows = process.platform === 'win32' const isDev = process.env.NODE_ENV === 'development' const sessionPartition = !isDev ? 'persist:huly' : 'persist:huly_dev' -const iconKey = path.join(app.getAppPath(), 'dist', 'ui', 'public', isWindows ? 'AppIcon.ico' : 'AppIcon.png') +const iconKey = getFileInPublicBundledFolder(isWindows ? 'AppIcon.ico' : 'AppIcon.png') const preloadScriptPath = path.join(app.getAppPath(), 'dist', 'main', 'preload.js') const defaultWidth = 1440 @@ -62,24 +66,16 @@ const containerPagePath = path.join('dist', 'ui', containerPageFileName) // Note: using electron-store here as local storage is not available in the main process // before the window is created -const settings = new Store() -const oldFront = readServerUrl() +const settings = new Settings(new Store(), isDev, packedConfig) +const oldFront = settings.readServerUrl() if (options.server !== undefined) { - ;(settings as any).set('server', options.server) + settings.setServerUrl(options.server) } -const FRONT_URL = readServerUrl() +const FRONT_URL = settings.readServerUrl() const serverChanged = oldFront !== FRONT_URL -function readServerUrl (): string { - if (isDev) { - return process.env.FRONT_URL ?? 'http://huly.local:8087' - } - - return ((settings as any).get('server') as string) ?? packedConfig?.server ?? process.env.FRONT_URL ?? 'https://huly.app' -} - // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (require('electron-squirrel-startup') === true) { app.quit() @@ -229,8 +225,7 @@ function handleWillDownload (window: BrowserWindow): void { } const createWindow = async (): Promise => { - // Restore window position if available - const restoredBounds: any = settings.get('windowBounds') + const restoredBounds: any = settings.getWindowBounds() const windowOptions: Electron.BrowserWindowConstructorOptions = { width: restoredBounds?.width ?? defaultWidth, height: restoredBounds?.height ?? defaultHeight, @@ -243,7 +238,6 @@ const createWindow = async (): Promise => { devTools: true, sandbox: false, nodeIntegration: true, - // backgroundThrottling: false, partition: sessionPartition, preload: preloadScriptPath } @@ -251,7 +245,6 @@ const createWindow = async (): Promise => { setupWindowTitleBar(windowOptions) mainWindow = new BrowserWindow(windowOptions) app.dock?.setIcon(nativeImage.createFromPath(iconKey)) - // await mainWindow.webContents.openDevTools() if (isDev) { mainWindow.webContents.openDevTools() } @@ -264,11 +257,27 @@ const createWindow = async (): Promise => { // All other urls will be blocked. hookOpenWindow(mainWindow) - // Save window position on close - mainWindow.on('close', () => { + function minimizeToTrayIsOn (): boolean { + return settings.isMinimizeToTrayEnabled() && trayController != null + } + + mainWindow.on('close', (event: Event) => { const bounds = mainWindow?.getBounds() - if (bounds !== undefined) { - settings.set('windowBounds', bounds) + if (bounds != null) { + settings.setWindowBounds(bounds) + } + + if (minimizeToTrayIsOn()) { + if (!isQuiting) { + event.preventDefault() + mainWindow?.hide() + return false + } + } + + if (isMac) { + event.preventDefault() + mainWindow?.hide() } }) @@ -284,6 +293,12 @@ const createWindow = async (): Promise => { sendWindowMaximizedMessage(true) }) + mainWindow.on('minimize', () => { + if (minimizeToTrayIsOn()) { + mainWindow?.hide() + } + }) + mainWindow.on('unmaximize', () => { sendWindowMaximizedMessage(false) }) @@ -298,14 +313,7 @@ const createWindow = async (): Promise => { } }) - if (isMac) { - mainWindow.on('close', (event) => { - // Prevent the default behavior (which would quit the app) - event.preventDefault() - // Hide the window - mainWindow?.hide() - }) - } else if (isWindows) { + if (isWindows) { winBadge = new WinBadge(mainWindow, { font: '14px arial' }) @@ -326,6 +334,17 @@ function activateWindow (): void { } } +function quitApplication (): void { + isQuiting = true + app.quit() +} + +let trayController: TrayController | undefined + +void app.whenReady().then(() => { + trayController = new TrayController(settings, activateWindow, quitApplication) +}) + if (isWindows) { setupWindowsSpecific(activateWindow, sendCommand) } else { @@ -345,6 +364,7 @@ ipcMain.on('set-badge', (_event: any, badge: number) => { if (isWindows && winBadge !== undefined) { winBadge.update(badge) } + trayController?.updateTrayBadge(badge) }) ipcMain.on('dock-bounce', (_event: any) => { @@ -455,7 +475,7 @@ ipcMain.handle('get-is-os-using-dark-theme', () => { }) ipcMain.handle('menu-action', async (_event: any, action: MenuBarAction) => { - dispatchMenuBarAction(mainWindow, action) + dispatchMenuBarAction(mainWindow, action, trayController) }) if (isWindows) { @@ -483,6 +503,10 @@ ipcMain.handle('get-screen-sources', () => { }) }) +ipcMain.handle('get-minimize-to-tray-enabled', () => { + return settings.isMinimizeToTrayEnabled() +}) + async function onReady (): Promise { await createWindow() @@ -509,12 +533,17 @@ if (isMac) { }) } app.on('before-quit', () => { + isQuiting = true + if (mainWindow !== undefined && !mainWindow.isDestroyed()) { const bounds = mainWindow.getBounds() - settings.set('windowBounds', bounds) + settings.setWindowBounds(bounds) } + // Note: in case the app is exited by auto-updater all windows will be destroyed at this point - if (mainWindow === undefined || mainWindow.isDestroyed()) return + if (mainWindow === undefined || mainWindow.isDestroyed()) { + return + } if (isMac) { mainWindow?.removeAllListeners('close') diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts new file mode 100644 index 0000000000..bd95493767 --- /dev/null +++ b/desktop/src/main/tray.ts @@ -0,0 +1,134 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Menu, NativeImage, nativeImage, Tray } from 'electron' +import { WindowAction } from '../ui/types' +import { Settings } from './settings' +import { getFileInPublicBundledFolder } from './path' +import { getBadgeIconInfo } from './trayUtils' +import path from 'path' + +export class TrayController { + private tray: Tray | undefined = undefined + private readonly defaultIcon: NativeImage + private readonly notificationIcon: NativeImage + private badgeCount: number = 0 + + constructor ( + private readonly settings: Settings, + private readonly activateWindow: WindowAction, + private readonly quitApplication: WindowAction) { + this.defaultIcon = nativeImage.createFromPath(getFileInPublicBundledFolder('AppIcon.ico')) + this.defaultIcon.setTemplateImage(true) + + this.notificationIcon = nativeImage.createFromPath(getFileInPublicBundledFolder('AppIconNotification.png')) + this.notificationIcon.setTemplateImage(true) + + if (this.settings.isMinimizeToTrayEnabled()) { + this.instantiateTray() + } + } + + public updateTrayBadge (count: number): void { + const countChanged = this.badgeCount !== count + this.badgeCount = count + if (!countChanged) { + return + } + + this.evaluateIcon(count) + } + + public setMinimizeToTrayEnabled (enabled: boolean): void { + this.settings.setMinimizeToTrayEnabled(enabled) + if (enabled) { + if (this.tray == null) { + this.instantiateTray() + } + } else { + if (this.tray != null) { + this.tray.destroy() + this.tray = undefined + } + } + } + + public toggleMinimizeToTray (): boolean { + const currentSetting = this.settings.isMinimizeToTrayEnabled() + const newSetting = !currentSetting + this.setMinimizeToTrayEnabled(newSetting) + return newSetting + } + + private instantiateTray (): void { + this.tray = createTray( + () => { this.activateWindow() }, + () => { this.quitApplication() }, + this.defaultIcon + ) + this.evaluateIcon(this.badgeCount) + } + + private evaluateIcon (badgeCount: number): void { + if (this.tray == null) { + return + } + + const iconInfo = getBadgeIconInfo(badgeCount, BASE_TITLE) + if (badgeCount > 0) { + const iconFilePath = getFileInPublicBundledFolder(path.join('app_icons_with_badge', iconInfo.fileName)) + const icon = nativeImage.createFromPath(iconFilePath) + this.tray.setImage(icon) + } else { + this.tray.setImage(this.defaultIcon) + } + + this.tray.setToolTip(iconInfo.tooltip) + } +} + +const BASE_TITLE = 'Huly' + +function createTray (activateWindow: WindowAction, quitApplication: WindowAction, icon: NativeImage): Tray { + const tray = new Tray(icon) + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show', + click: () => { + activateWindow() + } + }, + { + label: 'Exit', + click: () => { + quitApplication() + } + } + ]) + + tray.setContextMenu(contextMenu) + tray.setToolTip(BASE_TITLE) + + tray.on('double-click', () => { + activateWindow() + }) + + tray.on('click', (): void => { + activateWindow() + }) + + return tray +} diff --git a/desktop/src/main/trayUtils.ts b/desktop/src/main/trayUtils.ts new file mode 100644 index 0000000000..2868038e2e --- /dev/null +++ b/desktop/src/main/trayUtils.ts @@ -0,0 +1,47 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +const BADGE_ICON_COMMON_PREFIX = 'app_icon_badge_' +const BADGE_ICON_COMMON_EXTENSION = '.png' + +export interface BadgeIconInfo { + fileName: string + tooltip: string +} + +export function getBadgeIconInfo (badgeCount: number, baseTitle: string): BadgeIconInfo { + if (badgeCount <= 0) { + return { + fileName: '', + tooltip: baseTitle + } + } + + if (badgeCount >= 99) { + return { + fileName: `${BADGE_ICON_COMMON_PREFIX}99plus${BADGE_ICON_COMMON_EXTENSION}`, + tooltip: `${baseTitle}: ${badgeCount} unread` + } + } + + const fileName = badgeCount <= 9 + ? `${BADGE_ICON_COMMON_PREFIX}0${badgeCount}${BADGE_ICON_COMMON_EXTENSION}` + : `${BADGE_ICON_COMMON_PREFIX}${badgeCount}${BADGE_ICON_COMMON_EXTENSION}` + + return { + fileName, + tooltip: `${baseTitle}: ${badgeCount} unread` + } +} diff --git a/desktop/src/main/windowsSpecificSetup.ts b/desktop/src/main/windowsSpecificSetup.ts index feb8393425..0b40204b84 100644 --- a/desktop/src/main/windowsSpecificSetup.ts +++ b/desktop/src/main/windowsSpecificSetup.ts @@ -14,106 +14,103 @@ // import { app, JumpListItem, JumpListCategory } from 'electron' -import { Command, CommandOpenSettings, CommandOpenInbox, CommandOpenApplication, JumpListSpares } from '../ui/types' +import { Command, CommandOpenSettings, CommandOpenInbox, CommandOpenApplication, JumpListSpares, WindowAction, SendCommandDelegate } from '../ui/types' import * as path from 'path' const JUMP_COMMANDS = { INBOX: '--jump-to-inbox', - SETTINGS: '--jump-to-settings', + SETTINGS: '--jump-to-settings' } as const type JumpCommand = typeof JUMP_COMMANDS[keyof typeof JUMP_COMMANDS] const JUMP_TO_APP_COMMAND_PREFIX = '--jump-to-app=' -function getIconPath(iconName: string): string { - return path.join(process.resourcesPath, 'icons', iconName) +function getIconPath (iconName: string): string { + return path.join(process.resourcesPath, 'icons', iconName) } -function addFixedFunctionJumpListItems(spares: JumpListSpares, tasks: JumpListItem[]): void { +function addFixedFunctionJumpListItems (spares: JumpListSpares, tasks: JumpListItem[]): void { + tasks.push({ + type: 'task', + program: process.execPath, + args: JUMP_COMMANDS.SETTINGS, + iconPath: getIconPath('SettingsIcon.ico'), + title: spares.settingsLabel, + iconIndex: 0 + }) + tasks.push({ + type: 'task', + program: process.execPath, + args: JUMP_COMMANDS.INBOX, + iconPath: getIconPath('InboxIcon.ico'), + title: spares.inboxLabel, + iconIndex: 0 + }) +} + +export function rebuildJumpList (spares: JumpListSpares): void { + const tasks: JumpListItem[] = [] + + addFixedFunctionJumpListItems(spares, tasks) + + if (spares.applications.length > 0) { tasks.push({ - type: 'task', - program: process.execPath, - args: JUMP_COMMANDS.SETTINGS, - iconPath: getIconPath('SettingsIcon.ico'), - title: spares.settingsLabel, - iconIndex: 0, + type: 'separator' }) + } + + for (const application of spares.applications) { + const iconFileName = application.id.replaceAll(':', '_') + '.ico' + const cliArguments = `${JUMP_TO_APP_COMMAND_PREFIX}${application.alias}` tasks.push({ - type: 'task', - program: process.execPath, - args: JUMP_COMMANDS.INBOX, - iconPath: getIconPath('InboxIcon.ico'), - title: spares.inboxLabel, - iconIndex: 0, + type: 'task', + program: process.execPath, + args: cliArguments, + iconPath: getIconPath(iconFileName), + title: application.title, + iconIndex: 0 }) + } + + const category: JumpListCategory = { + type: 'tasks', + items: tasks + } + + app.setJumpList([category]) } -export function rebuildJumpList(spares: JumpListSpares): void { - const tasks: JumpListItem[] = []; +export function setupWindowsSpecific (activateWindow: WindowAction, sendCommand: SendCommandDelegate): void { + app.setAppUserModelId(app.getName()) - addFixedFunctionJumpListItems(spares, tasks) - - if (spares.applications.length > 0) { - tasks.push({ - type: 'separator' - }) - } - - for (const application of spares.applications) { - const iconFileName = application.id.replaceAll(":", "_") + '.ico' - const cliArguments = `${JUMP_TO_APP_COMMAND_PREFIX}${application.alias}` - tasks.push({ - type: 'task', - program: process.execPath, - args: cliArguments, - iconPath: getIconPath(iconFileName), - title: application.title, - iconIndex: 0, - }) - } - - const category: JumpListCategory = { - type: 'tasks', - items: tasks, - } - - app.setJumpList([category]) -} - -export function setupWindowsSpecific(activateWindow: () => void, sendCommand: (cmd: Command, ...args: any[]) => void): void { - - app.setAppUserModelId(app.getName()) - - app.on('second-instance', (_event: any, commandLine: any, _workingDirectory: any) => { - const commandArgument = commandLine[1] as string - if (typeof commandArgument === 'string' && commandArgument.startsWith(JUMP_TO_APP_COMMAND_PREFIX)) { - const applicationId = commandArgument.replace(JUMP_TO_APP_COMMAND_PREFIX, '') - sendCommand(CommandOpenApplication, [applicationId]) - - } else { - const jumpCommandCode = commandArgument as JumpCommand - let command: Command | undefined - switch (jumpCommandCode) { - case JUMP_COMMANDS.INBOX: - command = CommandOpenInbox - break; - case JUMP_COMMANDS.SETTINGS: - command = CommandOpenSettings - break; - default: { - // compile-time check: if jumpCommand is not a known value, this line errors - const _exhaustive: never = jumpCommandCode - return; // or handle error - } - } - - if (command) { - sendCommand(command) - } + app.on('second-instance', (_event: any, commandLine: any, _workingDirectory: any) => { + const commandArgument = commandLine[1] as string + if (typeof commandArgument === 'string' && commandArgument.startsWith(JUMP_TO_APP_COMMAND_PREFIX)) { + const applicationId = commandArgument.replace(JUMP_TO_APP_COMMAND_PREFIX, '') + sendCommand(CommandOpenApplication, [applicationId]) + } else { + const jumpCommandCode = commandArgument as JumpCommand + let command: Command | undefined + switch (jumpCommandCode) { + case JUMP_COMMANDS.INBOX: + command = CommandOpenInbox + break + case JUMP_COMMANDS.SETTINGS: + command = CommandOpenSettings + break + default: { + // compile-time check: if jumpCommand is not a known value, this line errors + const _exhaustive: never = jumpCommandCode + return // or handle error } + } - activateWindow() - }) - -} \ No newline at end of file + if (command !== undefined) { + sendCommand(command) + } + } + + activateWindow() + }) +} diff --git a/desktop/src/ui/index.ts b/desktop/src/ui/index.ts index 9a51e98867..04cba0fc72 100644 --- a/desktop/src/ui/index.ts +++ b/desktop/src/ui/index.ts @@ -46,18 +46,18 @@ window.addEventListener('DOMContentLoaded', () => { if (currentOsIsWindows()) { const titleBarRoot = document.getElementById('desktop-app-titlebar-root') if (titleBarRoot != null) { - const menuBar = setupTitleBarMenu(ipcMain, titleBarRoot) + void setupTitleBarMenu(ipcMain, titleBarRoot).then((menuBar) => { + themeStore.subscribe((themeOptions) => { + if (themeOptions != null) { + menuBar.setTheme(themeOptions.variant) + } + }) - themeStore.subscribe((themeOptions) => { - if (themeOptions != null) { - menuBar.setTheme(themeOptions.variant) - } - }) - - void ipcMain.isOsUsingDarkTheme().then((isDarkTheme) => { - menuBar.setTheme(isDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light) - }).catch(() => { - menuBar.setTheme(ThemeVariant.Light) // fallback + void ipcMain.isOsUsingDarkTheme().then((isDarkTheme) => { + menuBar.setTheme(isDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light) + }).catch(() => { + menuBar.setTheme(ThemeVariant.Light) // fallback + }) }) } } diff --git a/desktop/src/ui/preload.ts b/desktop/src/ui/preload.ts index d5ea5f61af..052b84ba92 100644 --- a/desktop/src/ui/preload.ts +++ b/desktop/src/ui/preload.ts @@ -181,6 +181,13 @@ const expose: IPCMainExposed = { cancelBackup: () => { ipcRenderer.send('cancel-backup') }, startBackup: (token, endpoint, wsIds) => { ipcRenderer.send('start-backup', token, endpoint, wsIds) }, - rebuildJumpList: (spares: JumpListSpares) => { ipcRenderer.send('rebuild-user-jump-list', spares) } + rebuildJumpList: (spares: JumpListSpares) => { ipcRenderer.send('rebuild-user-jump-list', spares) }, + + isMinimizeToTrayEnabled: async () => { + return await ipcRenderer.invoke('get-minimize-to-tray-enabled') + }, + onMinimizeToTraySettingChanged: (callback: (enabled: boolean) => void) => { + ipcRenderer.on('minimize-to-tray-setting-changed', (_event, enabled: boolean) => { callback(enabled) }) + } } contextBridge.exposeInMainWorld('electron', expose) diff --git a/desktop/src/ui/titleBarMenu.ts b/desktop/src/ui/titleBarMenu.ts index f0c5c1987f..6121d38224 100644 --- a/desktop/src/ui/titleBarMenu.ts +++ b/desktop/src/ui/titleBarMenu.ts @@ -18,9 +18,14 @@ import { isMenuBarAction } from './typesUtils' import { TitleBarMenuState } from './titleBarMenuState' import { ThemeVariant, type ThemeVariantType } from '@hcengineering/theme' -export function setupTitleBarMenu (ipcMain: IPCMainExposed, root: HTMLElement): MenuBar { +const ToggleMinimizeToTrayAction: MenuBarAction = 'toggle-minimize-to-tray' + +const LabelMinimizeToTrayEnabled = '☑ Minimize to tray' +const LabelMinimizeToTrayDisabled = '☐ Minimize to tray' + +export async function setupTitleBarMenu (ipcMain: IPCMainExposed, root: HTMLElement): Promise { const themeManager = new ThemeManager(ThemeVariant.Light) - const menuManager = new MenuBarManager(root) + const menuManager = new MenuBarManager(root, await ipcMain.isMinimizeToTrayEnabled()) const menuBar = menuManager.getView() @@ -35,6 +40,13 @@ export function setupTitleBarMenu (ipcMain: IPCMainExposed, root: HTMLElement): menuManager.attachEventListeners(ipcMain) + ipcMain.onMinimizeToTraySettingChanged((enabled) => { + const toggle = root.querySelector(`[data-action="${ToggleMinimizeToTrayAction}"]`) + if (toggle != null) { + toggle.textContent = enabled ? LabelMinimizeToTrayEnabled : LabelMinimizeToTrayDisabled + } + }) + ipcMain.onWindowStateChange((_event, state) => { const maximizeButton = root.querySelector('#maximize-button') if (maximizeButton != null) { @@ -58,7 +70,7 @@ export class MenuBar { } } -export function buildHulyApplicationMenu (): HTMLElement { +export function buildHulyApplicationMenu (minimizeToTrayEnabled: boolean): HTMLElement { const menuBuilder = new MenuBuilder() const MenuFileIndex = 0 @@ -93,6 +105,11 @@ export function buildHulyApplicationMenu (): HTMLElement { .addSeparator(MenuViewIndex) .addMenuItem(MenuViewIndex, 'Toggle Fullscreen', 'toggle-fullscreen', 'F11', 'l') + const MenuWindowIndex = 3 + const ToggleMinimizeToTrayLabel = minimizeToTrayEnabled ? LabelMinimizeToTrayEnabled : LabelMinimizeToTrayDisabled + menuBuilder.addTopLevelMenu('Window', 'w') + .addMenuItem(MenuWindowIndex, ToggleMinimizeToTrayLabel, 'toggle-minimize-to-tray', undefined, 'm') + return menuBuilder.build() } @@ -140,7 +157,7 @@ export class MenuBuilder { label: string, action: MenuBarAction, shortcut?: string, - acceleratorChar: string | null = null + acceleratorChar: string | undefined = undefined ): this { if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) { const item: MenuItem = { @@ -281,7 +298,7 @@ class MenuBarManager { private readonly StateStyleKeyboardSelected = 'desktop-app-keyboard-selected' private readonly StateStyleAltModeActive = 'desktop-app-alt-active' - constructor (private readonly root: HTMLElement) { + constructor (private readonly root: HTMLElement, minimizeToTrayEnabled: boolean) { this.state = new TitleBarMenuState( () => this.topLevelMenus().length, (topLevelMenuIndex: number) => { @@ -290,7 +307,7 @@ class MenuBarManager { } ) - this.view = buildHulyApplicationMenu() + this.view = buildHulyApplicationMenu(minimizeToTrayEnabled) } public getView (): HTMLElement { diff --git a/desktop/src/ui/titleBarMenuState.ts b/desktop/src/ui/titleBarMenuState.ts index ada16d9fdc..dd358c994a 100644 --- a/desktop/src/ui/titleBarMenuState.ts +++ b/desktop/src/ui/titleBarMenuState.ts @@ -14,113 +14,113 @@ // export class TitleBarMenuState { - private altModeActive: boolean = false - private topLevelMenuExpanded: boolean = false - private focusedTopLevelMenuIndex: number | null = null - private focusedChildMenuIndex: number | null = null + private altModeActive: boolean = false + private topLevelMenuExpanded: boolean = false + private focusedTopLevelMenuIndex: number | null = null + private focusedChildMenuIndex: number | null = null - private readonly topLevelMenuCount: () => number; - private readonly menuItemsCount: (topLevelMenuIndex: number) => number; + private readonly topLevelMenuCount: () => number + private readonly menuItemsCount: (topLevelMenuIndex: number) => number - public constructor( - topLevelMenuCount: () => number, - menuItemsCount: (topLevelMenuIndex: number) => number - ) { - this.topLevelMenuCount = topLevelMenuCount - this.menuItemsCount = menuItemsCount + public constructor ( + topLevelMenuCount: () => number, + menuItemsCount: (topLevelMenuIndex: number) => number + ) { + this.topLevelMenuCount = topLevelMenuCount + this.menuItemsCount = menuItemsCount + } + + public focusChildMenu (): void { + if (this.focusedTopLevelMenuIndex != null && this.topLevelMenuExpanded) { + this.focusedChildMenuIndex = 0 + } + } + + public expandTopLevelMenu (topLevelMenuIndex: number): void { + if (topLevelMenuIndex < 0 || topLevelMenuIndex >= this.topLevelMenuCount()) { + return } - public focusChildMenu() { - if (null != this.focusedTopLevelMenuIndex && this.topLevelMenuExpanded) { - this.focusedChildMenuIndex = 0 - } + if (this.focusedTopLevelMenuIndex === topLevelMenuIndex && this.topLevelMenuExpanded) { + this.closeAll() + } else { + this.focusedTopLevelMenuIndex = topLevelMenuIndex + this.topLevelMenuExpanded = true + } + } + + public exitAltMode (): void { + this.altModeActive = false + } + + public enterAltMode (topLevelMenuIndex: number | null): void { + this.altModeActive = true + + if (topLevelMenuIndex == null || topLevelMenuIndex < 0 || topLevelMenuIndex >= this.topLevelMenuCount()) { + return } - public expandTopLevelMenu(topLevelMenuIndex: number): void { - if (0 > topLevelMenuIndex || topLevelMenuIndex >= this.topLevelMenuCount()) { - return - } - - if (this.focusedTopLevelMenuIndex === topLevelMenuIndex && this.topLevelMenuExpanded) { - this.closeAll() - } else { - this.focusedTopLevelMenuIndex = topLevelMenuIndex - this.topLevelMenuExpanded = true - } + this.focusedTopLevelMenuIndex = topLevelMenuIndex + } + + public closeAll (): void { + this.altModeActive = false + this.topLevelMenuExpanded = false + this.focusedTopLevelMenuIndex = null + this.focusedChildMenuIndex = null + } + + public defocus (): void { + if (this.topLevelMenuExpanded) { + this.topLevelMenuExpanded = false + this.focusedChildMenuIndex = null + } else { + this.closeAll() + } + } + + public moveFocusHorizontal (increment: number): void { + if (Math.abs(increment) !== 1 || this.focusedTopLevelMenuIndex == null) { + return } - public exitAltMode(): void { - this.altModeActive = false + const topLevelMenuCount = this.topLevelMenuCount() + this.focusedTopLevelMenuIndex = (this.focusedTopLevelMenuIndex + topLevelMenuCount + increment) % topLevelMenuCount + + if (this.topLevelMenuExpanded) { + this.focusedChildMenuIndex = 0 + } + } + + public moveFocusVertical (increment: number): void { + if (Math.abs(increment) !== 1 || this.focusedTopLevelMenuIndex == null) { + return } - public enterAltMode(topLevelMenuIndex: number | null): void { - this.altModeActive = true - - if (null == topLevelMenuIndex || 0 > topLevelMenuIndex || topLevelMenuIndex >= this.topLevelMenuCount()) { - return - } - - this.focusedTopLevelMenuIndex = topLevelMenuIndex + const menuItemsCount = this.menuItemsCount(this.focusedTopLevelMenuIndex) + + this.topLevelMenuExpanded = true + + if (menuItemsCount === 0) { + return } - public closeAll(): void { - this.altModeActive = false - this.topLevelMenuExpanded = false - this.focusedTopLevelMenuIndex = null - this.focusedChildMenuIndex = null - } + this.focusedChildMenuIndex = ((this.focusedChildMenuIndex ?? -1) + menuItemsCount + increment) % menuItemsCount + } - public defocus(): void { - if (this.topLevelMenuExpanded) { - this.topLevelMenuExpanded = false - this.focusedChildMenuIndex = null - } else { - this.closeAll(); - } - } + public get isAltModeActive (): boolean { + return this.altModeActive + } - public moveFocusHorizontal(increment: number): void { - if (Math.abs(increment) != 1 || null == this.focusedTopLevelMenuIndex) { - return; - } - - const topLevelMenuCount = this.topLevelMenuCount() - this.focusedTopLevelMenuIndex = (this.focusedTopLevelMenuIndex + topLevelMenuCount + increment) % topLevelMenuCount - - if (this.topLevelMenuExpanded) { - this.focusedChildMenuIndex = 0 - } - } + public get isTopLevelMenuExpanded (): boolean { + return this.topLevelMenuExpanded + } - public moveFocusVertical(increment: number): void { - if (Math.abs(increment) != 1 || null == this.focusedTopLevelMenuIndex) { - return; - } + public get FocusedTopLevelMenuIndex (): number | null { + return this.focusedTopLevelMenuIndex + } - const menuItemsCount = this.menuItemsCount(this.focusedTopLevelMenuIndex) - - this.topLevelMenuExpanded = true - - if (menuItemsCount === 0) { - return; - } - - this.focusedChildMenuIndex = ((this.focusedChildMenuIndex ?? -1) + menuItemsCount + increment) % menuItemsCount - } - - public get isAltModeActive(): boolean { - return this.altModeActive - } - - public get isTopLevelMenuExpanded(): boolean { - return this.topLevelMenuExpanded - } - - public get FocusedTopLevelMenuIndex(): number | null { - return this.focusedTopLevelMenuIndex - } - - public get FocusedChildMenuIndex(): number | null { - return this.focusedChildMenuIndex - } -} \ No newline at end of file + public get FocusedChildMenuIndex (): number | null { + return this.focusedChildMenuIndex + } +} diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 1543e00dbd..85596757de 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -104,8 +104,26 @@ export interface NotificationParams { objectClass?: Ref> } -export const MenuBarActions = ['settings', 'select-workspace', 'logout', 'exit', 'undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'select-all', 'reload', 'force-reload', 'toggle-devtools', - 'zoom-in', 'zoom-out', 'restore-size', 'toggle-fullscreen'] as const +export const MenuBarActions = [ + 'settings', + 'select-workspace', + 'logout', + 'exit', + 'undo', + 'redo', + 'cut', + 'copy', + 'paste', + 'delete', + 'select-all', + 'reload', + 'force-reload', + 'toggle-devtools', + 'zoom-in', + 'zoom-out', + 'restore-size', + 'toggle-fullscreen', + 'toggle-minimize-to-tray'] as const export type MenuBarAction = typeof MenuBarActions[number] @@ -151,4 +169,10 @@ export interface IPCMainExposed { executeMenuBarAction: (action: MenuBarAction) => void rebuildJumpList: (spares: JumpListSpares) => void + + isMinimizeToTrayEnabled: () => Promise + onMinimizeToTraySettingChanged: (callback: (enabled: boolean) => void) => void } + +export type SendCommandDelegate = (cmd: Command, ...args: any[]) => void +export type WindowAction = () => void diff --git a/desktop/start-dev.ps1 b/desktop/start-dev.ps1 index 94bbd625f5..aad3d20939 100644 --- a/desktop/start-dev.ps1 +++ b/desktop/start-dev.ps1 @@ -1,11 +1,9 @@ -$MODEL_VERSION = node ../common/scripts/show_version.js | Out-String -$MODEL_VERSION = $MODEL_VERSION.Trim() - -$VERSION = node ../common/scripts/show_tag.js | Out-String -$VERSION = $VERSION.Trim() - -# Set environment variable -$env:NODE_ENV = "development" - -# Run Electron -electron --no-sandbox ./ \ No newline at end of file +$MODEL_VERSION = node ../common/scripts/show_version.js | Out-String +$MODEL_VERSION = $MODEL_VERSION.Trim() + +$VERSION = node ../common/scripts/show_tag.js | Out-String +$VERSION = $VERSION.Trim() + +$env:NODE_ENV = "development" + +electron --no-sandbox ./ diff --git a/models/process/src/index.ts b/models/process/src/index.ts index 99f34839eb..60c8aa6f78 100644 --- a/models/process/src/index.ts +++ b/models/process/src/index.ts @@ -47,6 +47,7 @@ import { type Asset, type IntlString, type Resource } from '@hcengineering/platf import { type CheckFunc, type ContextId, + type CreatedContext, type Execution, type ExecutionContext, type ExecutionError, @@ -206,7 +207,7 @@ export class TMethod extends TDoc implements Method { editor!: AnyComponent - contextClass!: Ref> | null + createdContext!: CreatedContext presenter?: AnyComponent @@ -247,6 +248,8 @@ export class TUpdateCriteriaComponent extends TDoc implements UpdateCriteriaComp editor!: AnyComponent of!: Ref>> + + props!: Record } export * from './migration' @@ -997,7 +1000,7 @@ export function createModel (builder: Builder): void { objectClass: process.class.Process, editor: process.component.SubProcessEditor, presenter: process.component.SubProcessPresenter, - contextClass: process.class.Execution, + createdContext: { _class: process.class.Execution }, requiredParams: ['_id'] }, process.method.RunSubProcess @@ -1011,8 +1014,8 @@ export function createModel (builder: Builder): void { editor: process.component.ToDoEditor, objectClass: process.class.ProcessToDo, presenter: process.component.ToDoPresenter, - contextClass: process.class.ProcessToDo, - requiredParams: ['state', 'title', 'user'], + createdContext: { _class: process.class.ProcessToDo, nameField: 'title' }, + requiredParams: ['title', 'user'], defaultParams: { withRollback: true } @@ -1039,7 +1042,7 @@ export function createModel (builder: Builder): void { label: process.string.UpdateCard, editor: process.component.UpdateCardEditor, objectClass: card.class.Card, - contextClass: null, + createdContext: null, presenter: process.component.UpdateCardPresenter, requiredParams: [] }, @@ -1054,7 +1057,7 @@ export function createModel (builder: Builder): void { objectClass: card.class.Card, editor: process.component.CreateCardEditor, presenter: process.component.CreateCardPresenter, - contextClass: card.class.Card, + createdContext: { _class: card.class.Card }, requiredParams: ['title', '_class'] }, process.method.CreateCard @@ -1068,7 +1071,7 @@ export function createModel (builder: Builder): void { objectClass: core.class.Relation, editor: process.component.AddRelationEditor, presenter: process.component.AddRelationPresenter, - contextClass: core.class.Relation, + createdContext: { _class: core.class.Relation }, requiredParams: ['association', 'direction', '_id'] }, process.method.AddRelation @@ -1082,7 +1085,7 @@ export function createModel (builder: Builder): void { objectClass: card.class.Tag, editor: process.component.AddTagEditor, presenter: process.component.AddTagPresenter, - contextClass: card.class.Card, + createdContext: { _class: card.class.Card }, requiredParams: ['_id'] }, process.method.AddTag @@ -1179,50 +1182,83 @@ export function createModel (builder: Builder): void { builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.StringCriteria, - of: core.class.TypeString + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeString, + props: { + modes: ['Equal', 'StringContains', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.StringCriteria, - of: core.class.TypeHyperlink + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeHyperlink, + props: { + modes: ['Equal', 'StringContains', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.NumberCriteria, - of: core.class.TypeNumber + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeNumber, + props: { + modes: ['Equal', 'GT', 'LT', 'Between', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.DateCriteria, - of: core.class.TypeDate + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeDate, + props: { + modes: ['Equal', 'GT', 'LT', 'Between', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.BooleanCriteria, - of: core.class.TypeBoolean + editor: process.criteriaEditor.BaseCriteria, + of: core.class.TypeBoolean, + props: { + modes: ['Equal', 'NotEqual', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'array', - editor: process.criteriaEditor.ArrayCriteria, - of: core.class.ArrOf + editor: process.criteriaEditor.BaseCriteria, + of: core.class.ArrOf, + props: { + modes: [ + 'ArrayAll', + 'ArrayAny', + 'ArrayNotIncludes', + 'ArraySizeEquals', + 'ArraySizeGt', + 'ArraySizeGte', + 'ArraySizeLt', + 'ArraySizeLte' + ] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'attribute', - editor: process.criteriaEditor.EnumCriteria, - of: core.class.EnumOf + editor: process.criteriaEditor.BaseCriteria, + of: core.class.EnumOf, + props: { + modes: ['Equal', 'NotEqual', 'Exists'] + } }) builder.createDoc(process.class.UpdateCriteriaComponent, core.space.Model, { category: 'object', - editor: process.criteriaEditor.RefCriteria, - of: core.class.RefTo + editor: process.criteriaEditor.BaseCriteria, + of: core.class.RefTo, + props: { + modes: ['Equal', 'NotEqual', 'Exists'] + } }) } diff --git a/packages/theme/styles/_layouts.scss b/packages/theme/styles/_layouts.scss index efc6754a2e..e1c0c7c1de 100644 --- a/packages/theme/styles/_layouts.scss +++ b/packages/theme/styles/_layouts.scss @@ -561,6 +561,7 @@ input.search { .mx-2 { margin: 0 .5rem; } .mx-3 { margin: 0 .75rem; } .mx-4 { margin: 0 1rem; } +.mx-8 { margin: 0 2rem; } .mx-10 { margin: 0 2.5rem; } .mx-auto { margin: 0 auto; } .my-1 { margin: .25rem 0; } diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index 6245426a01..65cf266fb2 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -102,14 +102,14 @@ async function deleteTypingInfo (): Promise { if (!withTypingInfo) return - clearTyping(me, object._id) + void clearTyping(me, object._id) } async function updateTypingInfo (): Promise { if (!withTypingInfo) return throttle.call(() => { - setTyping(me, object._id) + void setTyping(me, object._id) }) } diff --git a/plugins/communication-resources/src/components/message/MessageInput.svelte b/plugins/communication-resources/src/components/message/MessageInput.svelte index 27a45f8149..05f3da949b 100644 --- a/plugins/communication-resources/src/components/message/MessageInput.svelte +++ b/plugins/communication-resources/src/components/message/MessageInput.svelte @@ -139,7 +139,7 @@ await editMessage(message, markdown, blobsToLoad, linksToLoad, appletsToLoad) } - clearTyping(me, card._id) + void clearTyping(me, card._id) } async function attachApplets (messageId: MessageID, appletDrafts: AppletDraft[]): Promise { diff --git a/plugins/presence-resources/src/components/PresenceAvatars.svelte b/plugins/presence-resources/src/components/PresenceAvatars.svelte index 56428276fd..5a8fa2e7a6 100644 --- a/plugins/presence-resources/src/components/PresenceAvatars.svelte +++ b/plugins/presence-resources/src/components/PresenceAvatars.svelte @@ -14,26 +14,66 @@ --> {#if persons.length > 0} diff --git a/plugins/presence-resources/src/components/PresenceContext.svelte b/plugins/presence-resources/src/components/PresenceContext.svelte index c288232756..14b2c44ed0 100644 --- a/plugins/presence-resources/src/components/PresenceContext.svelte +++ b/plugins/presence-resources/src/components/PresenceContext.svelte @@ -14,20 +14,43 @@ --> diff --git a/plugins/presence-resources/src/index.ts b/plugins/presence-resources/src/index.ts index becb54bdb3..dfa54428eb 100644 --- a/plugins/presence-resources/src/index.ts +++ b/plugins/presence-resources/src/index.ts @@ -21,9 +21,10 @@ import WorkbenchExtension from './components/WorkbenchExtension.svelte' import { getFollowee, publishData, followeeDataSubscribe, followeeDataUnsubscribe } from './store' export { Presence, PresenceAvatars } -export { updateMyPresence, removeMyPresence, presenceByObjectId } from './store' export * from './pulse' +export * from './presence' +export * from './typing' export * from './types' export default async (): Promise => ({ diff --git a/plugins/presence-resources/src/presence.ts b/plugins/presence-resources/src/presence.ts new file mode 100644 index 0000000000..a2a95df570 --- /dev/null +++ b/plugins/presence-resources/src/presence.ts @@ -0,0 +1,69 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License + +import { type Person } from '@hcengineering/contact' +import { type UnsubscribeCallback, type Callback } from '@hcengineering/hulypulse-client' +import { type Class, type Doc, type Ref } from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import presentation from '@hcengineering/presentation' + +import { createPulseClient } from './pulse' + +export interface PresenceInfo { + personId: Ref + objectId: Ref + objectClass: Ref> +} + +export async function subscribePresence ( + objectClass: Ref>, + objectId: Ref, + callback: Callback +): Promise { + const client = await createPulseClient() + + if (client !== undefined) { + const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? '' + return await client.subscribe(`${workspace}/presence/${objectId}/`, callback) + } + + return async () => false +} + +export async function updatePresence (presence: PresenceInfo, presenceTtlSeconds: number): Promise { + const client = await createPulseClient() + + if (client !== undefined) { + const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? '' + const { personId, objectId } = presence + try { + await client.put(`${workspace}/presence/${objectId}/${personId}`, presence, presenceTtlSeconds) + } catch (error) { + console.warn('failed to put presence info:', error) + } + } +} + +export async function deletePresence (presence: PresenceInfo): Promise { + const client = await createPulseClient() + + if (client !== undefined) { + const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? '' + const { personId, objectId } = presence + try { + await client.delete(`${workspace}/presence/${objectId}/${personId}`) + } catch (error) { + console.warn('failed to delete presence info:', error) + } + } +} diff --git a/plugins/presence-resources/src/pulse.ts b/plugins/presence-resources/src/pulse.ts index 07ab39afba..52b379090e 100644 --- a/plugins/presence-resources/src/pulse.ts +++ b/plugins/presence-resources/src/pulse.ts @@ -11,20 +11,12 @@ // See the License for the specific language governing permissions and // limitations under the License -import { type Person, getCurrentEmployee } from '@hcengineering/contact' -import { HulypulseClient, type UnsubscribeCallback, type Callback } from '@hcengineering/hulypulse-client' +import { HulypulseClient } from '@hcengineering/hulypulse-client' import { getMetadata } from '@hcengineering/platform' import presentation from '@hcengineering/presentation' -import { type Doc, type Ref } from '@hcengineering/core' - -const typingDelaySeconds = 2 let pulseclient: HulypulseClient | undefined -function getWorkspace (): string { - return getMetadata(presentation.metadata.WorkspaceUuid) ?? '' -} - export async function createPulseClient (): Promise { if (pulseclient == null) { const wsPulseUrl = getMetadata(presentation.metadata.PulseUrl) @@ -35,37 +27,6 @@ export async function createPulseClient (): Promise return pulseclient } -export interface TypingInfo { - personId: Ref - objectId: Ref -} - -export async function subscribeTyping ( - objectId: Ref, - callback: Callback -): Promise { - const workspace = getWorkspace() - return (await pulseclient?.subscribe(`${workspace}/typing/${objectId}/`, callback)) ?? (async () => false) -} - -export async function setTyping (me: string, objectId: Ref): Promise { - const workspace = getWorkspace() - const personId = getCurrentEmployee() - const typingInfo: TypingInfo = { personId, objectId } - try { - await pulseclient?.put(`${workspace}/typing/${objectId}/${me}`, typingInfo, typingDelaySeconds) - } catch (error) { - console.warn('failed to put typing info:', error) - } -} - -export function clearTyping (me: string, objectId: string): void { - const workspace = getWorkspace() - void pulseclient?.delete(`${workspace}/typing/${objectId}/${me}`).catch((error) => { - console.warn('failed to delete typing info:', error) - }) -} - export function closePulseClient (): void { pulseclient?.close() pulseclient = undefined diff --git a/plugins/presence-resources/src/store.ts b/plugins/presence-resources/src/store.ts index eee9750b77..2b7de1f309 100644 --- a/plugins/presence-resources/src/store.ts +++ b/plugins/presence-resources/src/store.ts @@ -13,13 +13,12 @@ // limitations under the License. // -import { type Doc, type Ref } from '@hcengineering/core' -import { getCurrentEmployee, type Person } from '@hcengineering/contact' -import { type PresenceData } from '@hcengineering/presence' +import { type Ref } from '@hcengineering/core' +import { type Person } from '@hcengineering/contact' import { getPersonByPersonRef } from '@hcengineering/contact-resources' -import { type Readable, derived, writable, get } from 'svelte/store' +import { writable, get } from 'svelte/store' -import type { PersonRoomPresence, Room, RoomPresence, MyDataItem } from './types' +import type { RoomPresence, MyDataItem } from './types' type PersonPresenceMap = Map, RoomPresence[]> @@ -31,48 +30,6 @@ export const followee = writable | undefined>(undefined) const personDataMap = new Map, Map>() const followeeDataHandlers = new Map Promise>>() -export const presenceByObjectId = derived, Map, PersonRoomPresence[]>>( - otherPresence, - ($presence) => { - const map = new Map, PersonRoomPresence[]>() - for (const [person, presences] of $presence.entries()) { - if (person === getCurrentEmployee()) continue - - presences.forEach((presence) => { - const values = map.get(presence.room.objectId) ?? [] - values.push({ person, ...presence }) - - map.set(presence.room.objectId, values) - }) - } - - return map - } -) - -export function updateMyPresence (room: Room, presence: PresenceData): void { - // lleo console.log('@@@ key: ', room, presence) - - myPresence.update((rooms) => { - const value = { room, presence, lastUpdated: Date.now() } - - const index = rooms.findIndex((it) => it.room.objectId === room.objectId) - if (index >= 0) { - rooms[index] = value - } else { - rooms.push(value) - } - - return rooms - }) -} - -export function removeMyPresence (room: Room): void { - myPresence.update((old) => { - return old.filter((it) => it.room.objectId !== room.objectId) - }) -} - export function onPersonUpdate (person: Ref, presence: RoomPresence[]): void { otherPresence.update((map) => { map.set(person, [...presence]) diff --git a/plugins/presence-resources/src/typing.ts b/plugins/presence-resources/src/typing.ts new file mode 100644 index 0000000000..926d4f27b9 --- /dev/null +++ b/plugins/presence-resources/src/typing.ts @@ -0,0 +1,74 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License + +import { type Employee, type Person } from '@hcengineering/contact' +import { type UnsubscribeCallback, type Callback } from '@hcengineering/hulypulse-client' +import { getMetadata } from '@hcengineering/platform' +import presentation from '@hcengineering/presentation' +import { type Doc, type Ref } from '@hcengineering/core' +import { createPulseClient } from './pulse' + +const typingDelaySeconds = 2 + +function getWorkspace (): string { + return getMetadata(presentation.metadata.WorkspaceUuid) ?? '' +} + +export interface TypingInfo { + personId: Ref + objectId: Ref +} + +export async function subscribeTyping ( + objectId: Ref, + callback: Callback +): Promise { + const client = await createPulseClient() + if (client !== undefined) { + const workspace = getWorkspace() + try { + return await client.subscribe(`${workspace}/typing/${objectId}/`, callback) + } catch (error) { + console.warn('failed to subscribe typing info:', error) + } + } + + return async () => false +} + +export async function setTyping (personId: Ref, objectId: Ref): Promise { + const client = await createPulseClient() + + if (client !== undefined) { + const workspace = getWorkspace() + const typingInfo: TypingInfo = { personId, objectId } + try { + await client.put(`${workspace}/typing/${objectId}/${personId}`, typingInfo, typingDelaySeconds) + } catch (error) { + console.warn('failed to put typing info:', error) + } + } +} + +export async function clearTyping (me: string, objectId: string): Promise { + const client = await createPulseClient() + + if (client !== undefined) { + const workspace = getWorkspace() + try { + await client.delete(`${workspace}/typing/${objectId}/${me}`) + } catch (error) { + console.warn('failed to delete typing info:', error) + } + } +} diff --git a/plugins/process-resources/src/components/contextEditors/ProcessContextPresenter.svelte b/plugins/process-resources/src/components/contextEditors/ProcessContextPresenter.svelte index f63e19fc07..f70468c515 100644 --- a/plugins/process-resources/src/components/contextEditors/ProcessContextPresenter.svelte +++ b/plugins/process-resources/src/components/contextEditors/ProcessContextPresenter.svelte @@ -13,13 +13,15 @@ // limitations under the License. --> -{context.index}: +{#if context.index !== undefined} + {context.index}: +{/if} {#if context.name !== undefined && context.name !== ''} {context.name} {:else} diff --git a/plugins/process-resources/src/components/contextEditors/ProcessContextRawPresenter.svelte b/plugins/process-resources/src/components/contextEditors/ProcessContextRawPresenter.svelte index aac7568f6e..5a383565ee 100644 --- a/plugins/process-resources/src/components/contextEditors/ProcessContextRawPresenter.svelte +++ b/plugins/process-resources/src/components/contextEditors/ProcessContextRawPresenter.svelte @@ -14,10 +14,10 @@ --> - -
- { - mode = e.detail - if ((isSize(mode) && !isSize(selectedMode.id)) || (!isSize(mode) && isSize(selectedMode.id))) { - val = undefined - contextValue = undefined - } - selectedMode = modes.find((m) => m.id === mode) ?? modes[0] - changeResult(val, true) - }} - /> - -
- {#if !isSize(mode)} - {#if contextValue} - { - onSelect(e.detail) - }} - /> - {:else} -
- {#if baseEditor} - - {/if} -
- {/if} - {:else if contextValue} - { - onSelect(e.detail) - }} - /> - {:else if !Number.isNaN(val)} -
- -
- {/if} -
-
-
-
- - diff --git a/plugins/process-resources/src/components/criterias/ArraySizeCriteria.svelte b/plugins/process-resources/src/components/criterias/ArraySizeCriteria.svelte new file mode 100644 index 0000000000..062defcca7 --- /dev/null +++ b/plugins/process-resources/src/components/criterias/ArraySizeCriteria.svelte @@ -0,0 +1,137 @@ + + + + +
+ {#if contextValue} + { + onSelect(e.detail) + }} + /> + {:else} +
+ +
+ {/if} +
+
+
+ + diff --git a/plugins/process-resources/src/components/criterias/AttributeCriteria.svelte b/plugins/process-resources/src/components/criterias/AttributeCriteria.svelte index 688845bc5f..6241641ab2 100644 --- a/plugins/process-resources/src/components/criterias/AttributeCriteria.svelte +++ b/plugins/process-resources/src/components/criterias/AttributeCriteria.svelte @@ -15,7 +15,7 @@ + +
+ {#if contextValue} + { + onSelect(e.detail) + }} + /> + {:else} +
+ {#if baseEditor} + + {/if} +
+ {/if} +
+
+
+ + diff --git a/plugins/process-resources/src/components/criterias/BooleanCriteria.svelte b/plugins/process-resources/src/components/criterias/BooleanCriteria.svelte deleted file mode 100644 index 538ef8d874..0000000000 --- a/plugins/process-resources/src/components/criterias/BooleanCriteria.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - - diff --git a/plugins/process-resources/src/components/criterias/CriteriasEditor.svelte b/plugins/process-resources/src/components/criterias/CriteriasEditor.svelte index d46ae832ad..503e9aa81f 100644 --- a/plugins/process-resources/src/components/criterias/CriteriasEditor.svelte +++ b/plugins/process-resources/src/components/criterias/CriteriasEditor.svelte @@ -34,7 +34,7 @@ delete (params as any)[key] } params = params - dispatch('change', params) + dispatch('change', { params }) } diff --git a/plugins/process-resources/src/components/criterias/DateCriteria.svelte b/plugins/process-resources/src/components/criterias/DateCriteria.svelte deleted file mode 100644 index 35ddfb8f21..0000000000 --- a/plugins/process-resources/src/components/criterias/DateCriteria.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - - diff --git a/plugins/process-resources/src/components/criterias/EnumCriteria.svelte b/plugins/process-resources/src/components/criterias/EnumCriteria.svelte deleted file mode 100644 index 538ef8d874..0000000000 --- a/plugins/process-resources/src/components/criterias/EnumCriteria.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - - diff --git a/plugins/process-resources/src/components/criterias/NumberCriteria.svelte b/plugins/process-resources/src/components/criterias/NumberCriteria.svelte deleted file mode 100644 index 35ddfb8f21..0000000000 --- a/plugins/process-resources/src/components/criterias/NumberCriteria.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - - diff --git a/plugins/process-resources/src/components/criterias/RangeCriteria.svelte b/plugins/process-resources/src/components/criterias/RangeCriteria.svelte new file mode 100644 index 0000000000..c4faea5b68 --- /dev/null +++ b/plugins/process-resources/src/components/criterias/RangeCriteria.svelte @@ -0,0 +1,211 @@ + + + + +
+
+
+ {#if firstContextValue} + { + onSelect(e.detail, 0) + }} + /> + {:else} +
+ {#if baseEditor} + + {/if} +
+ {/if} +
+
+
+
+ {#if secondContextValue} + { + onSelect(e.detail, 1) + }} + /> + {:else} +
+ {#if baseEditor} + + {/if} +
+ {/if} +
+
+
+
+
+ + diff --git a/plugins/process-resources/src/components/criterias/RefCriteria.svelte b/plugins/process-resources/src/components/criterias/RefCriteria.svelte deleted file mode 100644 index 538ef8d874..0000000000 --- a/plugins/process-resources/src/components/criterias/RefCriteria.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - - diff --git a/plugins/process-resources/src/components/criterias/StringCriteria.svelte b/plugins/process-resources/src/components/criterias/StringCriteria.svelte deleted file mode 100644 index 9791c454cc..0000000000 --- a/plugins/process-resources/src/components/criterias/StringCriteria.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - - diff --git a/plugins/process-resources/src/components/settings/AddTagEditor.svelte b/plugins/process-resources/src/components/settings/AddTagEditor.svelte index 11ba9d4ad5..9daea38c9d 100644 --- a/plugins/process-resources/src/components/settings/AddTagEditor.svelte +++ b/plugins/process-resources/src/components/settings/AddTagEditor.svelte @@ -93,28 +93,30 @@ } -
- - - -
- -{#if possibleAttrs.length > 0} -
-
+ {/if} + diff --git a/plugins/process-resources/src/components/settings/AsideStepEditor.svelte b/plugins/process-resources/src/components/settings/AsideStepEditor.svelte index 6cbd6af9db..ef8770cf11 100644 --- a/plugins/process-resources/src/components/settings/AsideStepEditor.svelte +++ b/plugins/process-resources/src/components/settings/AsideStepEditor.svelte @@ -16,9 +16,9 @@ {/if} - {#if step != null} - - {/if} +
+
+
+ + diff --git a/plugins/process-resources/src/components/settings/AsideTransitionEditor.svelte b/plugins/process-resources/src/components/settings/AsideTransitionEditor.svelte index 97802e0e5f..f405d7815e 100644 --- a/plugins/process-resources/src/components/settings/AsideTransitionEditor.svelte +++ b/plugins/process-resources/src/components/settings/AsideTransitionEditor.svelte @@ -83,9 +83,7 @@ /> {#if trigger.editor !== undefined} -
- -
+ {/if} {/if} @@ -93,11 +91,7 @@ diff --git a/plugins/process-resources/src/components/settings/TimeEditor.svelte b/plugins/process-resources/src/components/settings/TimeEditor.svelte index 07f02062c7..cc40b8ca8c 100644 --- a/plugins/process-resources/src/components/settings/TimeEditor.svelte +++ b/plugins/process-resources/src/components/settings/TimeEditor.svelte @@ -84,7 +84,7 @@ bind:value={params.value} on:change={(e) => { params.value = e.detail - dispatch('change', params) + dispatch('change', { params }) }} /> diff --git a/plugins/process-resources/src/index.ts b/plugins/process-resources/src/index.ts index 1bcf1c3c0d..a34cd7b3e2 100644 --- a/plugins/process-resources/src/index.ts +++ b/plugins/process-resources/src/index.ts @@ -48,13 +48,9 @@ import MultiArrayElementEditor from './components/transformEditors/MultiArrayEle import DateOffsetEditor from './components/transformEditors/DateOffsetEditor.svelte' import NumberEditor from './components/transformEditors/NumberEditor.svelte' -import ArrayCriteria from './components/criterias/ArrayCriteria.svelte' -import BooleanCriteria from './components/criterias/BooleanCriteria.svelte' -import DateCriteria from './components/criterias/DateCriteria.svelte' -import EnumCriteria from './components/criterias/EnumCriteria.svelte' -import NumberCriteria from './components/criterias/NumberCriteria.svelte' -import RefCriteria from './components/criterias/RefCriteria.svelte' -import StringCriteria from './components/criterias/StringCriteria.svelte' +import ArraySizeCriteria from './components/criterias/ArraySizeCriteria.svelte' +import BaseCriteria from './components/criterias/BaseCriteria.svelte' +import RangeCriteria from './components/criterias/RangeCriteria.svelte' import LogActionPresenter from './components/LogActionPresenter.svelte' import NotifierExtension from './components/NotifierExtension.svelte' import AddRelationPresenter from './components/presenters/AddRelationPresenter.svelte' @@ -139,13 +135,9 @@ export default async (): Promise => ({ ExecutionMyToDos }, criteriaEditor: { - DateCriteria, - StringCriteria, - BooleanCriteria, - NumberCriteria, - RefCriteria, - EnumCriteria, - ArrayCriteria + BaseCriteria, + ArraySizeCriteria, + RangeCriteria }, transformPresenter: { NumberPresenter, diff --git a/plugins/process-resources/src/plugin.ts b/plugins/process-resources/src/plugin.ts index 5570152def..91d29bad7f 100644 --- a/plugins/process-resources/src/plugin.ts +++ b/plugins/process-resources/src/plugin.ts @@ -72,13 +72,9 @@ export default mergeIds(processId, process, { AddTagPresenter: '' as AnyComponent }, criteriaEditor: { - StringCriteria: '' as AnyComponent, - NumberCriteria: '' as AnyComponent, - DateCriteria: '' as AnyComponent, - BooleanCriteria: '' as AnyComponent, - ArrayCriteria: '' as AnyComponent, - EnumCriteria: '' as AnyComponent, - RefCriteria: '' as AnyComponent + BaseCriteria: '' as AnyComponent, + RangeCriteria: '' as AnyComponent, + ArraySizeCriteria: '' as AnyComponent }, transformEditor: { MultiArrayElementEditor: '' as AnyComponent, diff --git a/plugins/process-resources/src/query.ts b/plugins/process-resources/src/query.ts index d93249d62e..f83d6be38b 100644 --- a/plugins/process-resources/src/query.ts +++ b/plugins/process-resources/src/query.ts @@ -13,7 +13,9 @@ import { type QuerySelector } from '@hcengineering/core' import { getEmbeddedLabel, type IntlString } from '@hcengineering/platform' +import { type AnyComponent } from '@hcengineering/ui' import view from '@hcengineering/view-resources/src/plugin' +import plugin from './plugin' export interface Mode { id: string @@ -21,6 +23,7 @@ export interface Mode { query: QuerySelector | string parse?: (value: any) => any withoutEditor?: boolean + editor?: AnyComponent } const Equal: Mode = { @@ -47,6 +50,17 @@ const LT: Mode = { query: { $lt: '$val' as any } } +const Between: Mode = { + id: 'between', + label: view.string.Between, + query: { $gte: '$val[0]' as any, $lte: '$val[1]' as any }, + parse: (value: any): any => { + if (!Array.isArray(value) || value.length !== 2) return value + return value + }, + editor: plugin.criteriaEditor.RangeCriteria +} + const Exists: Mode = { id: 'exists', label: view.string.ValueIsSet, @@ -85,40 +99,46 @@ const ArrayNotIncludes: Mode = { const ArraySizeEquals: Mode = { id: 'sizeEquals', label: getEmbeddedLabel('='), - query: { $size: '$val' as any } + query: { $size: '$val' as any }, + editor: plugin.criteriaEditor.ArraySizeCriteria } const ArraySizeGt: Mode = { id: 'sizeGt', label: getEmbeddedLabel('>'), - query: { $size: { $gt: '$val' as any } } + query: { $size: { $gt: '$val' as any } }, + editor: plugin.criteriaEditor.ArraySizeCriteria } const ArraySizeGte: Mode = { id: 'sizeGte', label: getEmbeddedLabel('≥'), - query: { $size: { $gte: '$val' as any } } + query: { $size: { $gte: '$val' as any } }, + editor: plugin.criteriaEditor.ArraySizeCriteria } const ArraySizeLt: Mode = { id: 'sizeLt', label: getEmbeddedLabel('<'), - query: { $size: { $lt: '$val' as any } } + query: { $size: { $lt: '$val' as any } }, + editor: plugin.criteriaEditor.ArraySizeCriteria } const ArraySizeLte: Mode = { id: 'sizeLte', label: getEmbeddedLabel('≤'), - query: { $size: { $lte: '$val' as any } } + query: { $size: { $lte: '$val' as any } }, + editor: plugin.criteriaEditor.ArraySizeCriteria } -export const Modes: Record = { +export const Modes = { Exists, Equal, NotEqual, StringContains, GT, LT, + Between, ArrayAll, ArrayAny, ArrayNotIncludes, @@ -129,6 +149,8 @@ export const Modes: Record = { ArraySizeLte } as const +export type ModeId = keyof typeof Modes + export function parseValue (modes: Mode[], value: any): [any, Mode] { if (value == null) { return [value, modes[0]] @@ -163,24 +185,20 @@ export function buildResult (mode: Mode, value: any): any { if (typeof q !== 'object') return value const result: any = {} let res = result - while (true) { - if (typeof q === 'object') { - const key = Object.keys(q)[0] - const v: any = (q as any)[key] - if (typeof v !== 'object') { - if (typeof value === 'string' && typeof v === 'string') { - res[key] = v.replace('$val', value) - } else { - res[key] = mode.withoutEditor === true ? v : value ?? v - } - return result + while (typeof q === 'object') { + const key = Object.keys(q)[0] + const v: any = (q as any)[key] + if (typeof v !== 'object') { + if (typeof value === 'string' && typeof v === 'string') { + res[key] = v.replace('$val', value) + } else { + res[key] = mode.withoutEditor === true ? v : value ?? v } - q = v - res[key] = {} - res = res[key] - } else { - break + return result } + q = v + res[key] = {} + res = res[key] } return result } diff --git a/plugins/process-resources/src/types.ts b/plugins/process-resources/src/types.ts index 8c7fb27624..2b3fee6a76 100644 --- a/plugins/process-resources/src/types.ts +++ b/plugins/process-resources/src/types.ts @@ -13,10 +13,12 @@ import { type DocumentQuery } from '@hcengineering/core' import { type IntlString } from '@hcengineering/platform' -import { type Execution } from '@hcengineering/process' +import { type ProcessContext, type Execution } from '@hcengineering/process' export interface Special { _id: string label: IntlString baseQuery: DocumentQuery } + +export type ProcessContextView = Pick & { index?: number } diff --git a/plugins/process-resources/src/utils.ts b/plugins/process-resources/src/utils.ts index 879dc76bab..cbea58cc6d 100644 --- a/plugins/process-resources/src/utils.ts +++ b/plugins/process-resources/src/utils.ts @@ -50,9 +50,10 @@ import { type Step, type StepId, type Transition, + type UpdateCriteriaComponent, type UserResult } from '@hcengineering/process' -import { type AnyComponent, showPopup } from '@hcengineering/ui' +import { showPopup } from '@hcengineering/ui' import { type AttributeCategory } from '@hcengineering/view' import process from './plugin' @@ -151,10 +152,10 @@ export async function initState (methodId: Ref>): Promi const step: Step = { _id: generateId() as string as StepId, context: - method.contextClass !== null + method.createdContext !== null ? { _id: generateContextId(), - _class: method.contextClass + _class: method.createdContext._class } : null, methodId, @@ -209,41 +210,45 @@ export function getContext ( const relationsA = allRelations.filter((it) => descendants.has(it.classA)) for (const rel of relationsA) { - const refAttributes = getClassAttributes(client, rel.classB, target, 'attribute') - if (refAttributes.length > 0) { - relations[rel.nameB] = { - name: rel.nameB, - association: rel._id, - direction: 'B', - attributes: refAttributes - } - } else if (['object', 'array'].includes(category) && client.getHierarchy().isDerived(rel.classB, target)) { + if (['object', 'array'].includes(category) && client.getHierarchy().isDerived(rel.classB, target)) { relations[rel.nameB] = { name: rel.nameB, association: rel._id, direction: 'B', attributes: [] } + } else { + const refAttributes = getClassAttributes(client, rel.classB, target, 'attribute') + if (refAttributes.length > 0) { + relations[rel.nameB] = { + name: rel.nameB, + association: rel._id, + direction: 'B', + attributes: refAttributes + } + } } } const relationsB = allRelations.filter((it) => descendants.has(it.classB)) for (const rel of relationsB) { - const refAttributes = getClassAttributes(client, rel.classA, target, 'attribute') - if (refAttributes.length > 0) { - relations[rel.nameA] = { - name: rel.nameA, - association: rel._id, - direction: 'A', - attributes: refAttributes - } - } else if (['object', 'array'].includes(category) && client.getHierarchy().isDerived(rel.classA, target)) { + if (['object', 'array'].includes(category) && client.getHierarchy().isDerived(rel.classA, target)) { relations[rel.nameA] = { name: rel.nameA, association: rel._id, direction: 'A', attributes: [] } + } else { + const refAttributes = getClassAttributes(client, rel.classA, target, 'attribute') + if (refAttributes.length > 0) { + relations[rel.nameA] = { + name: rel.nameA, + association: rel._id, + direction: 'A', + attributes: refAttributes + } + } } } @@ -634,17 +639,20 @@ export async function subProcessesDoneCheck ( return res === undefined } -export function getCirteriaEditor (of: Ref>, category: AttributeCategory): AnyComponent | undefined { +export function getCirteriaEditor ( + of: Ref>, + category: AttributeCategory +): UpdateCriteriaComponent | undefined { const client = getClient() if (category !== 'attribute') { const res = client.getModel().findAllSync(process.class.UpdateCriteriaComponent, { category })[0] - return res?.editor + return res } const res = client.getModel().findAllSync(process.class.UpdateCriteriaComponent, { category, of })[0] - return res?.editor + return res } diff --git a/plugins/process/src/index.ts b/plugins/process/src/index.ts index 90d8affa4c..93018c79a5 100644 --- a/plugins/process/src/index.ts +++ b/plugins/process/src/index.ts @@ -38,7 +38,7 @@ export interface Process extends Doc { export interface ProcessContext { name: string _class: Ref> - action?: StepId + action: StepId index: number producer: Ref isResult?: boolean @@ -165,10 +165,15 @@ export interface Method extends Doc { description?: IntlString editor?: AnyComponent presenter?: AnyComponent - contextClass: Ref> | null + createdContext: CreatedContext | null defaultParams?: MethodParams } +export interface CreatedContext { + _class: Ref> + nameField?: string +} + export interface ProcessFunction extends Doc { type: 'transform' | 'reduce' | 'context' of: Ref> @@ -183,6 +188,7 @@ export interface UpdateCriteriaComponent extends Doc { category: AttributeCategory editor: AnyComponent of: Ref> + props: Record } export * from './errors' diff --git a/pods/external/services.d/hulylake.service b/pods/external/services.d/hulylake.service index 8d482fb428..73c519ab8c 100644 --- a/pods/external/services.d/hulylake.service +++ b/pods/external/services.d/hulylake.service @@ -1,2 +1 @@ -hulylake hardcoreeng/service_hulylake:0.1.4 - +hulylake hardcoreeng/service_hulylake:0.1.5 diff --git a/server-plugins/process-resources/src/index.ts b/server-plugins/process-resources/src/index.ts index 99c4f80429..3ef716fe15 100644 --- a/server-plugins/process-resources/src/index.ts +++ b/server-plugins/process-resources/src/index.ts @@ -28,11 +28,13 @@ import core, { import process, { ContextId, Execution, + Method, Process, ProcessContext, ProcessToDo, SelectedExecutionContext, State, + Step, Transition } from '@hcengineering/process' import { QueueTopic, TriggerControl } from '@hcengineering/server-core' @@ -273,6 +275,15 @@ export async function OnCardUpdate (txes: Tx[], control: TriggerControl): Promis return res } +function getName (current: ProcessContext | undefined, method: Method, action: Step): string { + const nameField = method.createdContext?.nameField + if (nameField !== undefined) { + const name = action.params[nameField] + if (name !== undefined && typeof name === 'string' && name !== '') return name + } + return current?.name ?? '' +} + async function syncContext (control: TriggerControl, _process: Process): Promise { const transitions = control.modelDb.findAllSync(process.class.Transition, { process: _process._id }) const exists = new Set() @@ -284,7 +295,7 @@ async function syncContext (control: TriggerControl, _process: Process): Promise exists.add(action.context._id) const method = control.modelDb.findObject(action.methodId) const current = _process.context[action.context._id] - if (method?.contextClass != null) { + if (method?.createdContext != null) { changed = true const ctx: SelectedExecutionContext = { type: 'context', @@ -292,8 +303,8 @@ async function syncContext (control: TriggerControl, _process: Process): Promise key: '' } _process.context[action.context._id] = { - name: current?.name ?? '', - _class: action.context._class ?? method.contextClass, + name: getName(current, method, action), + _class: action.context._class ?? method.createdContext._class, action: action._id, index: index++, producer: transition._id, @@ -304,7 +315,6 @@ async function syncContext (control: TriggerControl, _process: Process): Promise if (action.results != null) { for (const result of action.results) { exists.add(result._id) - const context = _process.context[result._id] changed = true const ctx: SelectedExecutionContext = { type: 'context', @@ -314,9 +324,10 @@ async function syncContext (control: TriggerControl, _process: Process): Promise const parentType = result.type._class === core.class.ArrOf ? (result.type as ArrOf).of : result.type const _class = parentType._class === core.class.RefTo ? (parentType as RefTo).to : parentType._class _process.context[result._id] = { - name: context?.name ?? result.name, + name: result.name, isResult: true, type: result.type, + action: action._id, _class, index: index++, producer: transition._id, diff --git a/server-plugins/process/src/types.ts b/server-plugins/process/src/types.ts index 9c169d4781..65e392f581 100644 --- a/server-plugins/process/src/types.ts +++ b/server-plugins/process/src/types.ts @@ -1,5 +1,5 @@ import { Card } from '@hcengineering/card' -import { Doc, MeasureContext, PersonId, Ref, Tx, TxOperations } from '@hcengineering/core' +import { Doc, MeasureContext, PersonId, Ref, Tx, TxOperations, WorkspaceUuid } from '@hcengineering/core' import { Execution, ExecutionError, MethodParams, Trigger, UserResult } from '@hcengineering/process' export type ExecuteFunc = ( @@ -42,6 +42,7 @@ export interface ProcessControl { client: TxOperations cache: Map messageContext: Record + workspace: WorkspaceUuid } export type RollbackFunc = (context: Record, control: ProcessControl) => Tx diff --git a/services/process/src/main.ts b/services/process/src/main.ts index 5178cbd416..7db1d3b659 100644 --- a/services/process/src/main.ts +++ b/services/process/src/main.ts @@ -41,6 +41,7 @@ import process, { State, Step, Transition, + Trigger, UserResult } from '@hcengineering/process' import serverProcess, { @@ -67,12 +68,13 @@ export async function messageHandler (record: ProcessMessage, ws: WorkspaceUuid, ctx, client, cache: new Map(), - messageContext: record.context + messageContext: record.context, + workspace: ws } ctx.info('Processing event', { event: record.event, ws, record }) if (record.execution !== undefined) { const execution = await control.client.findOne(process.class.Execution, { _id: record.execution }) - if (execution !== undefined) { + if (execution !== undefined && isActiveExecution(execution, record.event)) { await processExecution(control, record, execution) } } else if (record.card !== undefined) { @@ -88,6 +90,12 @@ export async function messageHandler (record: ProcessMessage, ws: WorkspaceUuid, } } +function isActiveExecution (execution: Execution, trigger: Ref): boolean { + if (execution.status !== ExecutionStatus.Active) return false + if (execution.error != null && trigger !== process.trigger.OnExecutionContinue) return false + return true +} + async function processBroadcast (control: ProcessControl, record: ProcessMessage): Promise { const transitions = control.client.getModel().findAllSync(process.class.Transition, { trigger: record.event @@ -99,7 +107,9 @@ async function processBroadcast (control: ProcessControl, record: ProcessMessage currentState: transition.from }) for (const execution of executions) { - await execute(execution, transition, control) + if (isActiveExecution(execution, record.event)) { + await execute(execution, transition, control) + } } } } @@ -121,7 +131,9 @@ async function processCardExecutions (control: ProcessControl, record: ProcessMe currentState: { $in: Array.from(states) } }) for (const execution of executions) { - await processExecution(control, record, execution) + if (isActiveExecution(execution, record.event)) { + await processExecution(control, record, execution) + } } } @@ -308,115 +320,123 @@ async function execute (execution: Execution, transition: Transition, control: P } } -async function executeTransition (execution: Execution, transition: Transition, control: ProcessControl): Promise { - let deep = control.cache.get(execution._id + 'transition') ?? 0 - deep++ - control.cache.set(execution._id + 'transition', deep) - if (deep > 100) { - const error = parseError( - processError(process.error.TooDeepTransitionRecursion, undefined, undefined, true), - transition._id - ) - await control.client.update(execution, { error: [error] }) - return - } - const trigger = control.client.getModel().findObject(transition.trigger) - if (trigger === undefined) return - const rollback: Tx[] = [] - const triggerImpl = control.client.getHierarchy().as(trigger, serverProcess.mixin.TriggerImpl) - const disableRollback = triggerImpl?.preventRollback ?? false - const triggerRollback = await getTriggerRollback(triggerImpl, control) - if (triggerRollback !== undefined) { - rollback.push(triggerRollback) - } - const client = control.client - const res: Tx[] = [] - const _process = client.getModel().findObject(execution.process) - if (_process === undefined) return - - const state = client.getModel().findObject(transition.to) - if (state === undefined) return - const isDone = - client.getModel().findAllSync(process.class.Transition, { - from: transition.to, - process: transition.process - }).length === 0 - const errors: ExecutionError[] = [] - if (execution.currentState !== null) { - rollback.push( - client.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, { - currentState: execution.currentState, - status: execution.status - }) - ) - } else { - rollback.push(client.txFactory.createTxRemoveDoc(execution._class, execution.space, execution._id)) - } - if (!control.cache.has(execution.card)) { - const card = await control.client.findOne(cardPlugin.class.Card, { _id: execution.card }) - if (card !== undefined) { - control.cache.set(execution.card, card) +async function executeTransition ( + execution: Execution, + _transition: Transition, + control: ProcessControl +): Promise { + let transition: Transition | undefined = _transition + while (transition !== undefined) { + let deep = control.cache.get(execution._id + 'transition') ?? 0 + deep++ + control.cache.set(execution._id + 'transition', deep) + if (deep > 100) { + const error = parseError( + processError(process.error.TooDeepTransitionRecursion, undefined, undefined, true), + transition._id + ) + await control.client.update(execution, { error: [error] }) + return } - } - for (const action of transition.actions) { - const actionResult = await executeAction(action, transition._id, execution, control) - if (isError(actionResult)) { - errors.push(actionResult) + const trigger = control.client.getModel().findObject(transition.trigger) + if (trigger === undefined) return + const rollback: Tx[] = [] + const triggerImpl = control.client.getHierarchy().as(trigger, serverProcess.mixin.TriggerImpl) + const disableRollback = triggerImpl?.preventRollback ?? false + const triggerRollback = await getTriggerRollback(triggerImpl, control) + if (triggerRollback !== undefined) { + rollback.push(triggerRollback) + } + const client = control.client + const res: Tx[] = [] + const _process = client.getModel().findObject(execution.process) + if (_process === undefined) return + + const state = client.getModel().findObject(transition.to) + if (state === undefined) return + const isDone = + client.getModel().findAllSync(process.class.Transition, { + from: transition.to, + process: transition.process + }).length === 0 + const errors: ExecutionError[] = [] + if (execution.currentState !== null) { + rollback.push( + client.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, { + currentState: execution.currentState, + status: execution.status + }) + ) } else { - if (actionResult.rollback !== undefined) { - rollback.push(...actionResult.rollback) + rollback.push(client.txFactory.createTxRemoveDoc(execution._class, execution.space, execution._id)) + } + if (!control.cache.has(execution.card)) { + const card = await control.client.findOne(cardPlugin.class.Card, { _id: execution.card }) + if (card !== undefined) { + control.cache.set(execution.card, card) } - res.push(...actionResult.txes) - for (const tx of actionResult.txes) { - const updateTx = tx as TxUpdateDoc - if (updateTx._class === core.class.TxUpdateDoc && updateTx.objectId === execution.card) { - const updatedCard = TxProcessor.updateDoc2Doc( - control.client.getHierarchy().clone(control.cache.get(execution.card)), - updateTx - ) - control.cache.set(execution.card, updatedCard) + } + for (const action of transition.actions) { + const actionResult = await executeAction(action, transition._id, execution, control) + if (isError(actionResult)) { + errors.push(actionResult) + } else { + if (actionResult.rollback !== undefined) { + rollback.push(...actionResult.rollback) + } + res.push(...actionResult.txes) + for (const tx of actionResult.txes) { + const updateTx = tx as TxUpdateDoc + if (updateTx._class === core.class.TxUpdateDoc && updateTx.objectId === execution.card) { + const updatedCard = TxProcessor.updateDoc2Doc( + control.client.getHierarchy().clone(control.cache.get(execution.card)), + updateTx + ) + control.cache.set(execution.card, updatedCard) + } } } } - } - if (!disableRollback) { - execution.rollback.push(rollback) - } - const executionUpdate = getDiffUpdate(execution, { - currentState: state._id, - status: isDone ? ExecutionStatus.Done : ExecutionStatus.Active, - error: null - }) - executionUpdate.context = execution.context - executionUpdate.rollback = execution.rollback.length > 30 ? execution.rollback.slice(-30) : execution.rollback - res.push(client.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, executionUpdate)) - res.push( - client.txFactory.createTxCreateDoc(process.class.ExecutionLog, execution.space, { - execution: execution._id, - process: execution.process, - card: execution.card, - transition: transition._id, - action: transition.from === null ? ExecutionLogAction.Started : ExecutionLogAction.Transition + if (!disableRollback) { + execution.rollback.push(rollback) + } + const executionUpdate = getDiffUpdate(execution, { + currentState: state._id, + status: isDone ? ExecutionStatus.Done : ExecutionStatus.Active, + error: null }) - ) - if (errors.length === 0) { - for (const tx of res) { - const timeout = setTimeout(() => { - control.ctx.warn('TX HANG', tx) - }, 30000) - await client.tx(tx) - clearTimeout(timeout) + executionUpdate.context = execution.context + executionUpdate.rollback = execution.rollback.length > 30 ? execution.rollback.slice(-30) : execution.rollback + res.push(client.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, executionUpdate)) + res.push( + client.txFactory.createTxCreateDoc(process.class.ExecutionLog, execution.space, { + execution: execution._id, + process: execution.process, + card: execution.card, + transition: transition._id, + action: transition.from === null ? ExecutionLogAction.Started : ExecutionLogAction.Transition + }) + ) + if (errors.length === 0) { + for (const tx of res) { + const timeout = setTimeout(() => { + control.ctx.warn('TX HANG', tx) + }, 30000) + await client.tx(tx) + clearTimeout(timeout) + } + if (isDone && execution.parentId !== undefined) { + await checkParent(execution, control) + } + TxProcessor.applyUpdate(execution, executionUpdate) + transition = await checkNext(control, execution) + if (transition === undefined) { + await setNextTimers(control, execution) + } + } else { + await client.update(execution, { error: errors }) + break } - if (isDone && execution.parentId !== undefined) { - await checkParent(execution, control) - } - TxProcessor.applyUpdate(execution, executionUpdate) - const next = await checkNext(control, execution) - if (!next) { - await setNextTimers(control, execution) - } - } else { - await client.update(execution, { error: errors }) } } @@ -528,7 +548,7 @@ async function setTimer ( await temporalClient.workflow.signalWithStart('processTimeWorkflow', { taskQueue: 'process', signal: 'setDate', - args: [targetDate, 'workspace', execution._id], + args: [targetDate, control.workspace, execution._id], signalArgs: [targetDate], workflowId: `${execution._id}_${transition._id}`, searchAttributes: { @@ -622,7 +642,7 @@ async function checkParent (execution: Execution, control: ProcessControl): Prom } } -async function checkNext (control: ProcessControl, execution: Execution): Promise { +async function checkNext (control: ProcessControl, execution: Execution): Promise { const autoTriggers = control.client.getModel().findAllSync(process.class.Trigger, { auto: true }) const transitions = control.client.getModel().findAllSync( process.class.Transition, @@ -640,13 +660,9 @@ async function checkNext (control: ProcessControl, execution: Execution): Promis const transition = await pickTransition(control, execution, transitions, { card: doc }) - if (transition !== undefined) { - await executeTransition(execution, transition, control) - return true - } + return transition } } - return false } export async function pickTransition (