diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 5a5d520..43b23df 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1119,6 +1119,9 @@ export default {
if (this.config?.do_not_disturb_enabled) {
break;
}
+ if (json.sieve_suppress_notifications) {
+ break;
+ }
// show notification for new messages if window is not focussed
// only for incoming messages from people (with content)
diff --git a/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue b/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue
new file mode 100644
index 0000000..437d0b2
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue
@@ -0,0 +1,447 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("tools.utilities") }}
+
+
+ {{ $t("tools.sieve_filters.title") }}
+
+
+ {{ $t("tools.sieve_filters.subtitle") }}
+
+
+
+
+
+ {{ $t("tools.sieve_filters.back_tools") }}
+
+
+
+
+
+
+
+
+ {{ $t("tools.sieve_filters.rules_heading") }}
+
+
+
+ {{ $t("tools.sieve_filters.add_rule") }}
+
+
+
+ {{ $t("tools.sieve_filters.order_hint") }}
+
+
+
+ {{ $t("tools.sieve_filters.empty_rules") }}
+
+
+
+
+
+
+
+ {{ $t("tools.sieve_filters.enabled") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("tools.sieve_filters.terms_label") }}
+
+
+
+ {{ $t("tools.sieve_filters.scope_label") }}
+
+
+ {{ $t("tools.sieve_filters.scope_everyone") }}
+
+
+ {{ $t("tools.sieve_filters.scope_contacts") }}
+
+
+ {{ $t("tools.sieve_filters.scope_non_contacts") }}
+
+
+
+
+
+ {{ $t("tools.sieve_filters.match_targets_label") }}
+
+
+
+ {{ $t("tools.sieve_filters.match_peer_fields") }}
+
+
+
+ {{ $t("tools.sieve_filters.match_message") }}
+
+
+ {{ $t("tools.sieve_filters.match_targets_hint") }}
+
+
+
+ {{ $t("tools.sieve_filters.match_mode_label") }}
+
+
+ {{ $t("tools.sieve_filters.match_mode_substring") }}
+
+
+ {{ $t("tools.sieve_filters.match_mode_regex") }}
+
+
+
+
+
+ {{ $t("tools.sieve_filters.action_label") }}
+
+
+ {{ $t("tools.sieve_filters.action_hide") }}
+
+
+ {{ $t("tools.sieve_filters.action_ignore") }}
+
+
+ {{ $t("tools.sieve_filters.action_folder") }}
+
+
+ {{ $t("tools.sieve_filters.action_banish") }}
+
+
+
+
+ {{ $t("tools.sieve_filters.folder_label") }}
+
+
+ {{ f.name }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t("tools.sieve_filters.saving") }}
+ {{ $t("tools.sieve_filters.save") }}
+
+
+
+ {{ $t("tools.sieve_filters.revert") }}
+
+
+
+
+
+
+
+ {{ $t("tools.sieve_filters.flow_heading") }}
+
+
+
+
+
+
+
+
+
+
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index fd6f76f..97d1235 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -206,6 +206,14 @@ export default {
titleKey: "tools.forwarder.title",
descriptionKey: "tools.forwarder.description",
},
+ {
+ name: "sieve-filters",
+ route: { name: "sieve-filters" },
+ icon: "filter-variant",
+ iconBg: "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
+ titleKey: "tools.sieve_filters.title",
+ descriptionKey: "tools.sieve_filters.description",
+ },
{
name: "documentation",
route: { name: "documentation" },
diff --git a/meshchatx/src/frontend/components/tools/internal/SieveFlowNetwork.vue b/meshchatx/src/frontend/components/tools/internal/SieveFlowNetwork.vue
new file mode 100644
index 0000000..39d52a4
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/internal/SieveFlowNetwork.vue
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/frontend/SieveFiltersPage.test.js b/tests/frontend/SieveFiltersPage.test.js
new file mode 100644
index 0000000..1f896c0
--- /dev/null
+++ b/tests/frontend/SieveFiltersPage.test.js
@@ -0,0 +1,114 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import SieveFiltersPage from "@/components/tools/SieveFiltersPage.vue";
+import { createRouter, createWebHistory } from "vue-router";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+describe("SieveFiltersPage.vue", () => {
+ const router = createRouter({
+ history: createWebHistory(),
+ routes: [{ path: "/tools", name: "tools", component: { template: "
" } }],
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.api.get = vi.fn((url) => {
+ if (url.includes("sieve-filters")) {
+ return Promise.resolve({
+ data: {
+ filters: [
+ {
+ id: "r1",
+ enabled: true,
+ terms: ["spam"],
+ action: "ignore",
+ folder_id: null,
+ },
+ ],
+ },
+ });
+ }
+ if (url.includes("folders")) {
+ return Promise.resolve({ data: [{ id: 1, name: "Inbox" }] });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ global.api.put = vi.fn(() =>
+ Promise.resolve({
+ data: {
+ filters: [
+ {
+ id: "r1",
+ enabled: true,
+ terms: ["spam"],
+ action: "ignore",
+ folder_id: null,
+ },
+ ],
+ },
+ })
+ );
+ window.api = global.api;
+ });
+
+ it("loads filters and folders from the API", async () => {
+ const wrapper = mount(SieveFiltersPage, {
+ global: {
+ plugins: [router],
+ mocks: { $t: (k) => k },
+ stubs: {
+ MaterialDesignIcon: { template: " ", props: ["iconName"] },
+ SieveFlowNetwork: { template: "
" },
+ RouterLink: { template: " ", props: ["to"] },
+ },
+ },
+ });
+ await Promise.resolve();
+ await wrapper.vm.$nextTick();
+ await Promise.resolve();
+ expect(global.api.get).toHaveBeenCalled();
+ expect(wrapper.vm.filters.length).toBe(1);
+ expect(wrapper.vm.filters[0].terms).toEqual(["spam"]);
+ expect(wrapper.vm.folders.length).toBe(1);
+ });
+
+ it("saves filters via PUT", async () => {
+ const wrapper = mount(SieveFiltersPage, {
+ global: {
+ plugins: [router],
+ mocks: { $t: (k) => k },
+ stubs: {
+ MaterialDesignIcon: { template: " ", props: ["iconName"] },
+ SieveFlowNetwork: { template: "
" },
+ RouterLink: { template: " ", props: ["to"] },
+ },
+ },
+ });
+ await Promise.resolve();
+ await wrapper.vm.$nextTick();
+ await Promise.resolve();
+ await wrapper.vm.save();
+ expect(global.api.put).toHaveBeenCalledWith(
+ "/api/v1/lxmf/sieve-filters",
+ expect.objectContaining({
+ filters: expect.arrayContaining([
+ expect.objectContaining({
+ match_peer_fields: true,
+ match_message: false,
+ match_mode: "substring",
+ }),
+ ]),
+ })
+ );
+ expect(ToastUtils.success).toHaveBeenCalled();
+ });
+});
diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js
index 456cef8..76dabaf 100644
--- a/tests/frontend/ToolsPage.test.js
+++ b/tests/frontend/ToolsPage.test.js
@@ -24,6 +24,7 @@ describe("ToolsPage.vue", () => {
{ path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } },
{ path: "/debug-logs", name: "debug-logs", component: { template: "div" } },
{ path: "/mesh-server", name: "mesh-server", component: { template: "div" } },
+ { path: "/tools/sieve-filters", name: "sieve-filters", component: { template: "div" } },
],
});
@@ -53,7 +54,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
- expect(toolRows.length).toBe(19);
+ expect(toolRows.length).toBe(20);
});
it("filters tools based on search query", async () => {
@@ -78,6 +79,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
- expect(wrapper.vm.filteredTools.length).toBe(19);
+ expect(wrapper.vm.filteredTools.length).toBe(20);
});
});