diff --git a/openemail/_property.py b/openemail/_property.py index 7d2f4d8..5b776d7 100644 --- a/openemail/_property.py +++ b/openemail/_property.py @@ -3,6 +3,7 @@ # SPDX-FileCopyrightText: Copyright 2025 OpenEmail SA # SPDX-FileContributor: kramo +from collections.abc import Callable from typing import Any from gi.repository import Gio, GObject @@ -29,6 +30,9 @@ def bind( target: GObject.Object, target_property: str | None = None, /, + transform_to: Callable[..., Any] | None = None, + transform_from: Callable[..., Any] | None = None, + user_data: Any | None = None, # noqa: ANN401 *, bidirectional: bool = False, ) -> GObject.Binding: @@ -42,6 +46,9 @@ def bind( target_property or source_property, GObject.BindingFlags.SYNC_CREATE | (GObject.BindingFlags.BIDIRECTIONAL if bidirectional else 0), + transform_to, + transform_from, + user_data, ) @staticmethod diff --git a/openemail/gtk/contacts.py b/openemail/gtk/contacts.py index d5f7e6e..81cc8c0 100644 --- a/openemail/gtk/contacts.py +++ b/openemail/gtk/contacts.py @@ -14,7 +14,8 @@ from openemail.store import DictStore, People from .form import Form -from .page import Page + +# from .page import Page from .profile_view import ProfileView if TYPE_CHECKING: @@ -74,50 +75,51 @@ def _show_context_menu(self, _gesture, _n_press: int, x: float, y: float): self.context_menu.popup() -@Gtk.Template.from_resource(f"{PREFIX}/contacts.ui") -class Contacts(Adw.NavigationPage): - """A page with the contents of the user's address book.""" - - __gtype_name__ = __qualname__ - - page: Page = child - - add_contact_dialog: Adw.AlertDialog = child - remove_contact_dialog: Adw.AlertDialog = child - address: Adw.EntryRow = child - address_form: Form = child - - counter = Property(int) - - def __init__(self, **kwargs: Any): - super().__init__(**kwargs) - - self.insert_action_group("contacts", group := Gio.SimpleActionGroup()) - group.add_action_entries(( - ( - "remove", - lambda _action, address, _data: tasks.create( - self._remove_contact(address.get_string()) - ), - "s", - ), - )) - - async def _remove_contact(self, address: str): - response = await cast("Awaitable[str]", self.remove_contact_dialog.choose(self)) - if response == "remove": - await store.address_book.delete(Address(address)) - - @Gtk.Template.Callback() - def _new_contact(self, *_args): - self.address_form.reset() - self.add_contact_dialog.present(self) - - @Gtk.Template.Callback() - def _add_contact(self, *_args): - with suppress(ValueError): - tasks.create(store.address_book.new(Address(self.address.props.text))) - - @Gtk.Template.Callback() - def _on_selected(self, selection: Gtk.SingleSelection, *_args): - self.page.split_view.props.show_content = bool(selection.props.selected_item) +# +# @Gtk.Template.from_resource(f"{PREFIX}/contacts.ui") +# class Contacts(Adw.NavigationPage): +# """A page with the contents of the user's address book.""" +# +# __gtype_name__ = __qualname__ +# +# page: Page = child +# +# add_contact_dialog: Adw.AlertDialog = child +# remove_contact_dialog: Adw.AlertDialog = child +# address: Adw.EntryRow = child +# address_form: Form = child +# +# counter = Property(int) +# +# def __init__(self, **kwargs: Any): +# super().__init__(**kwargs) +# +# self.insert_action_group("contacts", group := Gio.SimpleActionGroup()) +# group.add_action_entries(( +# ( +# "remove", +# lambda _action, address, _data: tasks.create( +# self._remove_contact(address.get_string()) +# ), +# "s", +# ), +# )) +# +# async def _remove_contact(self, address: str): +# response = await cast("Awaitable[str]", self.remove_contact_dialog.choose(self)) +# if response == "remove": +# await store.address_book.delete(Address(address)) +# +# @Gtk.Template.Callback() +# def _new_contact(self, *_args): +# self.address_form.reset() +# self.add_contact_dialog.present(self) +# +# @Gtk.Template.Callback() +# def _add_contact(self, *_args): +# with suppress(ValueError): +# tasks.create(store.address_book.new(Address(self.address.props.text))) +# +# @Gtk.Template.Callback() +# def _on_selected(self, selection: Gtk.SingleSelection, *_args): +# self.page.split_view.props.show_content = bool(selection.props.selected_item) diff --git a/openemail/gtk/messages.py b/openemail/gtk/messages.py index 81b6061..31fe8c2 100644 --- a/openemail/gtk/messages.py +++ b/openemail/gtk/messages.py @@ -3,18 +3,17 @@ # SPDX-FileCopyrightText: Copyright 2025 OpenEmail SA # SPDX-FileContributor: kramo -from typing import Any, override +from typing import Any -from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk +from gi.repository import Gdk, Gio, GLib, GObject, Gtk -from openemail import PREFIX, Property, store +from openemail import PREFIX, Property from openemail.message import Message -from openemail.store import DictStore -from .page import Page +# from .page import Page from .thread_view import ThreadView -for t in Page, ThreadView: +for t in (ThreadView,): GObject.type_ensure(t) @@ -65,188 +64,46 @@ def _show_context_menu(self, _gesture, _n_press: int, x: float, y: float): self.context_menu.popup() -class _Messages(Adw.NavigationPage): - counter = Property(int) - - _count_unread = False - - def __init__( - self, - model: Gio.ListModel, - /, - *, - title: str, - subtitle: str = "", - **kwargs: Any, - ): - super().__init__(**kwargs) - - self.builder = Gtk.Builder.new_from_resource(f"{PREFIX}/messages.ui") - - self.trashed: Gtk.BoolFilter = self._get_object("trashed") - store.settings.connect("changed::trashed-messages", self._on_trash_changed) - - self._get_object("sort_model").props.model = model - self.thread_view: ThreadView = self._get_object("thread_view") - - self.page: Page = self._get_object("page") - self.page.title = self.props.title = title - self.page.subtitle = subtitle - self.page.model.connect("notify::selected", self._on_selected) - - self.props.child = self.page - - if not self._count_unread: - return - - unread: Gtk.FilterListModel = self._get_object("unread") - unread.bind_property( - "n-items", self, "counter", GObject.BindingFlags.SYNC_CREATE - ) - - unread_filter = self._get_object("unread_filter") - store.settings.connect( - "changed::unread-messages", - lambda *_: unread_filter.changed(Gtk.FilterChange.DIFFERENT), - ) - - def _get_object(self, name: str) -> Any: # noqa: ANN401 - return self.builder.get_object(name) - - def _on_trash_changed(self, *_args): - props.autoselect = (props := self.page.model.props).selected != GLib.MAXUINT - self.trashed.changed(Gtk.FilterChange.DIFFERENT) - props.autoselect = False - - def _on_selected(self, selection: Gtk.SingleSelection, *_args): - if (msg := selection.props.selected_item) and not isinstance(msg, Message): - return - - self.thread_view.message = msg - if isinstance(msg, Message): - msg.unread = False - self.page.split_view.props.show_content = True - - -class _Folder(_Messages): - folder: DictStore[str, Message] - title: str - subtitle: str = "" - - def __init__(self, **kwargs: Any): - super().__init__( - self.folder, - title=self.title, - subtitle=self.subtitle, - **kwargs, - ) - - self.page.toolbar_button = self._get_object("toolbar_new") - self.page.empty_page = self._get_object("no_messages") - - Property.bind(self.page.model, "selected-item", self.thread_view, "message") - Property.bind(self.folder, "updating", self.page, "loading") - - -class Inbox(_Folder): - """A navigation page displaying the user's inbox.""" - - __gtype_name__ = __qualname__ - folder, title = store.inbox, _("Inbox") - - _count_unread = True - - -class Outbox(_Folder): - """A navigation page displaying the user's outbox.""" - - __gtype_name__ = __qualname__ - folder, title, subtitle = store.outbox, _("Outbox"), _("Can be discarded") - - -class Sent(_Folder): - """A navigation page displaying the user's sent messages.""" - - __gtype_name__ = __qualname__ - folder, title, subtitle = store.sent, _("Sent"), _("From this device") - - -class Drafts(_Messages): - """A navigation page displaying the user's drafts.""" - - __gtype_name__ = __qualname__ - - @override - @Property(int) - def counter(self) -> int: - return len(store.drafts) - - def __init__(self, **kwargs: Any): - super().__init__(store.drafts, title=_("Drafts"), **kwargs) - - self.page.model.props.can_unselect = True - - delete_dialog: Adw.AlertDialog = self._get_object("delete_dialog") - delete_dialog.connect("response::delete", lambda *_: store.drafts.delete_all()) - - delete_button: Gtk.Button = self._get_object("delete_button") - delete_button.connect("clicked", lambda *_: delete_dialog.present(self)) - self.page.toolbar_button = delete_button - - self.page.empty_page = self._get_object("no_drafts") - Property.bind(self.page.model, "n-items", delete_button, "sensitive") - - store.drafts.connect("items-changed", lambda *_: self.notify("counter")) - - def _on_selected(self, selection: Gtk.SingleSelection, *_args): - if isinstance(msg := selection.props.selected_item, Message): - selection.unselect_all() - self.activate_action( - "compose.draft", GLib.Variant.new_string(msg.unique_id) - ) - - -class Trash(_Messages): - """A navigation page displaying the user's trash folder.""" - - __gtype_name__ = __qualname__ - - model = store.flatten(store.inbox, store.sent, store.broadcasts) - - _count_unread = True - - def __init__(self, **kwargs: Any): - super().__init__( - self.model, - title=_("Trash"), - subtitle=_("On this device"), - **kwargs, - ) - - self.trashed.props.invert = False - - empty_dialog: Adw.AlertDialog = self._get_object("empty_dialog") - empty_dialog.connect("response::empty", lambda *_: store.empty_trash()) - - empty_button: Gtk.Button = self._get_object("empty_button") - empty_button.connect("clicked", lambda *_: empty_dialog.present(self)) - self.page.toolbar_button = empty_button - - self.page.empty_page = self._get_object("empty_trash") - Property.bind(self.page.model, "selected-item", self.thread_view, "message") - Property.bind(self.page.model, "n-items", empty_button, "sensitive") - - def set_loading(*_args): - self.page.loading = store.inbox.updating or store.broadcasts.updating - - store.inbox.connect("notify::updating", set_loading) - store.broadcasts.connect("notify::updating", set_loading) - - -class Broadcasts(_Folder): - """A navigation page displaying the user's broadcasts folder.""" - - __gtype_name__ = __qualname__ - folder, title = store.broadcasts, _("Public") - - _count_unread = True +# Property.bind(self.folder, "updating", self.page, "loading") + +# class Drafts(_Messages): +# def __init__(self, **kwargs: Any): +# self.page.model.props.can_unselect = True +# +# delete_dialog: Adw.AlertDialog = self._get_object("delete_dialog") +# delete_dialog.connect( +# "response::delete", lambda *_: store.drafts.delete_all() +# ) +# +# delete_button: Gtk.Button = self._get_object("delete_button") +# delete_button.connect("clicked", lambda *_: delete_dialog.present(self)) +# self.page.toolbar_button = delete_button +# +# self.page.empty_page = self._get_object("no_drafts") +# Property.bind(self.page.model, "n-items", delete_button, "sensitive") +# +# def _on_selected(self, selection: Gtk.SingleSelection, *_args): +# if isinstance(msg := selection.props.selected_item, Message): +# selection.unselect_all() +# self.activate_action( +# "compose.draft", GLib.Variant.new_string(msg.unique_id) +# ) + +# class Trash(_Messages): +# def __init__(self, **kwargs: Any): +# empty_dialog: Adw.AlertDialog = self._get_object("empty_dialog") +# empty_dialog.connect("response::empty", lambda *_: store.empty_trash()) +# +# empty_button: Gtk.Button = self._get_object("empty_button") +# empty_button.connect("clicked", lambda *_: empty_dialog.present(self)) +# self.page.toolbar_button = empty_button +# +# self.page.empty_page = self._get_object("empty_trash") +# Property.bind(self.page.model, "selected-item", self.thread_view, "message") +# Property.bind(self.page.model, "n-items", empty_button, "sensitive") +# +# def set_loading(*_args): +# self.page.loading = store.inbox.updating or store.broadcasts.updating +# +# store.inbox.connect("notify::updating", set_loading) +# store.broadcasts.connect("notify::updating", set_loading) diff --git a/openemail/gtk/page.py b/openemail/gtk/page.py deleted file mode 100644 index a906b35..0000000 --- a/openemail/gtk/page.py +++ /dev/null @@ -1,70 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# SPDX-FileCopyrightText: Copyright 2025 Mercata Sagl -# SPDX-FileCopyrightText: Copyright 2025 OpenEmail SA -# SPDX-FileContributor: kramo - -from typing import Any - -from gi.repository import Adw, Gtk - -import openemail as app -from openemail import PREFIX, Property, store, tasks - -child = Gtk.Template.Child() - - -@Gtk.Template.from_resource(f"{PREFIX}/page.ui") -class Page(Adw.BreakpointBin): - """A split view for content and details.""" - - __gtype_name__ = __qualname__ - - split_view: Adw.NavigationSplitView = child - sync_button: Gtk.Button = child - offline_banner: Adw.Banner = child - - factory = Property(Gtk.ListItemFactory) - - sidebar_child_name = Property(str, default="empty") - search_text = Property(str) - - title = Property(str, default=_("Content")) - subtitle = Property(str) - details = Property(Gtk.Widget) - toolbar_button = Property(Gtk.Widget) - empty_page = Property(Gtk.Widget) - - model = Property(Gtk.SingleSelection) - loading = Property(bool) - - def __init__(self, **kwargs: Any): - super().__init__(**kwargs) - - def on_syncing_changed(*_args): - if app.notifier.syncing: - self.sync_button.props.sensitive = False - self.sync_button.add_css_class("spinning") - else: - self.sync_button.remove_css_class("spinning") - self.sync_button.props.sensitive = True - - app.notifier.connect("notify::syncing", on_syncing_changed) - Property.bind(app.notifier, "offline", self.offline_banner, "revealed") - - @Gtk.Template.Callback() - def _sync(self, *_args): - tasks.create(store.sync()) - - @Gtk.Template.Callback() - def _get_sidebar_child_name( - self, _obj, items: int, loading: bool, search_text: str - ) -> str: - return ( - "content" - if items - else "loading" - if loading - else "no-results" - if search_text - else "empty" - ) diff --git a/openemail/gtk/sidebar_item.py b/openemail/gtk/sidebar_item.py new file mode 100644 index 0000000..bda7479 --- /dev/null +++ b/openemail/gtk/sidebar_item.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2025 OpenEmail SA +# SPDX-FileContributor: kramo + +from typing import Any + +from gi.repository import Adw, Gio, Gtk + +from openemail import Property + + +class SidebarItem(Adw.SidebarItem): # pyright: ignore[reportUntypedBaseClass, reportAttributeAccessIssue] + """An item in the main navigation sidebar.""" + + __gtype_name__ = __qualname__ + + description = Property[str | None](str) + + model = Property(Gio.ListModel) + badge_number = Property(int) + details = Property(Gtk.Widget) + + action_name = Property(str) + action_label = Property(str) + action_icon_name = Property(str) + + placeholder_title = Property(str) + placeholder_description = Property[str | None](str) + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + + label = Gtk.Label() + label.add_css_class("dim-label") + self.props.suffix = Gtk.Revealer( + child=label, transition_type=Gtk.RevealerTransitionType.CROSSFADE + ) + + Property.bind(self, "badge-number", self.props.suffix, "reveal-child") + Property.bind( + self, + "badge-number", + label, + "label", + lambda _, i: str(i or label.props.label), + ) + + +class FolderSidebarItem(SidebarItem): + """A sidebar item used by folders of messages.""" + + __gtype_name__ = __qualname__ + + def __init__(self, **kwargs: Any): + self.action_name = "compose.new" + self.action_label = _("New Message") + self.action_icon_name = "mail-message-new-symbolic" + + self.placeholder_title = _("Empty Folder") + self.placeholder_description = _( + "Select another folder or start a conversation" + ) + + super().__init__(**kwargs) diff --git a/openemail/gtk/ui/meson.build b/openemail/gtk/ui/meson.build index 29c104d..63640e6 100644 --- a/openemail/gtk/ui/meson.build +++ b/openemail/gtk/ui/meson.build @@ -10,7 +10,6 @@ blueprints = custom_target( 'message-row.blp', 'message-view.blp', 'messages.blp', - 'page.blp', 'preferences.blp', 'profile-settings.blp', 'profile-view.blp', diff --git a/openemail/gtk/ui/messages.blp b/openemail/gtk/ui/messages.blp index 7bd0374..84b2ad2 100644 --- a/openemail/gtk/ui/messages.blp +++ b/openemail/gtk/ui/messages.blp @@ -1,85 +1,7 @@ using Gtk 4.0; using Adw 1; -/* Messages */ -$Page page { - details: $ThreadView thread_view {}; - - model: SingleSelection selection { - autoselect: false; - - model: FilterListModel { - filter: AnyFilter { - StringFilter { - expression: expr item as <$Message>.subject; - search: bind page.search_text; - } - - StringFilter { - expression: expr item as <$Message>.body; - search: bind page.search_text; - } - }; - - model: FilterListModel trashed_model { - model: SortListModel sort_model { - sorter: NumericSorter { - expression: expr item as <$Message>.date; - sort-order: descending; - }; - }; - - filter: BoolFilter trashed { - expression: expr item as <$Message>.trashed; - invert: true; - }; - }; - }; - }; - - factory: BuilderListItemFactory { - template ListItem { - child: $MessageRow { - message: bind template.item; - }; - } - }; -} - -FilterListModel unread { - filter: BoolFilter unread_filter { - expression: expr item as <$Message>.unread; - }; - - model: trashed_model; -} - -/* Folder */ -Adw.StatusPage no_messages { - icon-name: "mailbox-symbolic"; - title: _("No Messages"); - description: _("Select another folder or start a conversation"); - - child: Button { - halign: center; - label: _("New Message"); - action-name: "compose.new"; - - styles [ - "pill", - ] - }; - - styles [ - "compact", - ] -} - Button toolbar_new { - icon-name: "mail-message-new-symbolic"; - tooltip-text: _("New Message"); - action-name: "compose.new"; - ShortcutController { scope: managed; diff --git a/openemail/gtk/ui/page.blp b/openemail/gtk/ui/page.blp deleted file mode 100644 index 81e3de2..0000000 --- a/openemail/gtk/ui/page.blp +++ /dev/null @@ -1,150 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $Page: Adw.BreakpointBin { - width-request: bind template.root as .width-request; - height-request: bind template.root as .height-request; - - Adw.Breakpoint { - condition ("max-width: 600") - - setters { - split_view.collapsed: true; - } - } - - child: Adw.NavigationSplitView split_view { - sidebar-width-unit: px; - min-sidebar-width: 300; - max-sidebar-width: 400; - sidebar-width-fraction: 0.4; - - sidebar: Adw.NavigationPage { - title: bind template.title; - - child: Adw.ToolbarView { - [top] - Adw.HeaderBar { - title-widget: Adw.WindowTitle { - title: bind template.title; - subtitle: bind template.subtitle; - }; - - [start] - Button { - icon-name: "sidebar-show-symbolic"; - tooltip-text: _("Toggle Sidebar"); - action-name: "win.toggle-sidebar"; - } - - [start] - Button sync_button { - icon-name: "sync-symbolic"; - tooltip-text: _("Sync"); - clicked => $_sync(); - - ShortcutController { - scope: managed; - - Shortcut { - trigger: "r|F5"; - action: "activate"; - } - } - } - - [end] - ToggleButton search_button { - icon-name: "search-symbolic"; - tooltip-text: _("Search"); - - ShortcutController { - scope: managed; - - Shortcut { - trigger: "f"; - action: "activate"; - } - } - } - - [end] - Adw.Bin { - child: bind template.toolbar-button; - } - } - - [top] - SearchBar { - search-mode-enabled: bind search_button.active bidirectional; - key-capture-widget: bind template.root; - - child: SearchEntry { - hexpand: true; - placeholder-text: _("Search"); - text: bind template.search-text bidirectional; - }; - } - - [top] - Adw.Banner offline_banner { - title: _("Offline"); - } - - content: Adw.ViewStack { - enable-transitions: true; - visible-child-name: bind $_get_sidebar_child_name(template.model as .n-items, template.loading, template.search_text) as ; - - Adw.ViewStackPage { - name: "empty"; - - child: Adw.Bin { - child: bind template.empty-page; - }; - } - - Adw.ViewStackPage { - name: "content"; - - child: ScrolledWindow { - child: ListView { - vexpand: true; - factory: bind template.factory; - model: bind template.model; - - styles [ - "navigation-sidebar", - ] - }; - }; - } - - Adw.ViewStackPage { - name: "loading"; - - child: Adw.Spinner {}; - } - - Adw.ViewStackPage { - name: "no-results"; - - child: Adw.StatusPage { - icon-name: "search-symbolic"; - title: _("No Results Found"); - description: _("Try a different search"); - - styles [ - "compact", - ] - }; - } - }; - }; - }; - - content: Adw.NavigationPage { - title: _("Details"); - child: bind template.details; - }; - }; -} diff --git a/openemail/gtk/ui/ui.gresource.xml.in b/openemail/gtk/ui/ui.gresource.xml.in index 037b226..679cc48 100644 --- a/openemail/gtk/ui/ui.gresource.xml.in +++ b/openemail/gtk/ui/ui.gresource.xml.in @@ -11,7 +11,6 @@ message-row.ui message-view.ui messages.ui - page.ui preferences.ui profile-settings.ui profile-view.ui diff --git a/openemail/gtk/ui/window.blp b/openemail/gtk/ui/window.blp index 1a15753..b0b42a1 100644 --- a/openemail/gtk/ui/window.blp +++ b/openemail/gtk/ui/window.blp @@ -1,8 +1,31 @@ using Gtk 4.0; using Adw 1; +$Folders folders {} + +$People people {} + $ProfileSettings profile_settings {} +BoolFilter unread_filter { + expression: expr item as <$Message>.unread; +} + +FilterListModel inbox_unread { + filter: unread_filter; + model: bind folders.inbox; +} + +FilterListModel trash_unread { + filter: unread_filter; + model: bind folders.trash; +} + +FilterListModel broadcasts_unread { + filter: unread_filter; + model: bind folders.broadcasts; +} + template $Window: Adw.ApplicationWindow { title: _("OpenEmail"); default-width: 1080; @@ -25,7 +48,7 @@ template $Window: Adw.ApplicationWindow { name: "content"; child: $ComposeSheet { - content: Adw.OverlaySplitView split_view { + content: Adw.OverlaySplitView outer_split_view { sidebar-width-unit: px; min-sidebar-width: 200; max-sidebar-width: 200; @@ -130,76 +153,299 @@ template $Window: Adw.ApplicationWindow { ] } - content: Adw.ViewSwitcherSidebar { - stack: stack; - activated => $_hide_sidebar(); + content: Adw.Sidebar inner_sidebar { + activated => $_switch_page(); + + Adw.SidebarSection { + $FolderSidebarItem { + icon-name: "inbox-symbolic"; + title: _("Inbox"); + model: bind folders.inbox; + badge-number: bind inbox_unread.n-items; + } + + $FolderSidebarItem { + icon-name: "outbox-symbolic"; + title: _("Outbox"); + description: _("Can be discarded"); + model: bind folders.outbox; + } + + $FolderSidebarItem { + icon-name: "sent-symbolic"; + title: _("Sent"); + description: _("From this device"); + model: bind folders.sent; + } + + $FolderSidebarItem { + icon-name: "drafts-symbolic"; + title: _("Drafts"); + model: bind folders.drafts; + badge-number: bind folders.drafts as <$DictStore>.n-items; + } + + $FolderSidebarItem { + icon-name: "trash-symbolic"; + title: _("Trash"); + description: _("On this device"); + model: bind folders.trash; + badge-number: bind trash_unread.n-items; + } + } + + Adw.SidebarSection { + $FolderSidebarItem { + icon-name: "broadcasts-symbolic"; + title: _("Public"); + model: bind folders.broadcasts; + badge-number: bind broadcasts_unread.n-items; + } + + $SidebarItem contacts_item { + icon-name: "contacts-symbolic"; + title: _("Contacts"); + action-name: "contacts.new"; + action-label: _("New Contact"); + action-icon-name: "contact-new-symbolic"; + placeholder-title: _("No Contacts"); + model: bind people.all; + badge-number: bind people.contact-requests as <$DictStore>.n-items; + } + } }; }; }; content: Adw.NavigationPage { - title: _("Content"); + title: bind template.item as <$SidebarItem>.title; - child: Adw.ViewStack stack { - enable-transitions: true; + Adw.NavigationSplitView inner_split_view { + sidebar-width-unit: px; + min-sidebar-width: 300; + max-sidebar-width: 400; + sidebar-width-fraction: 0.4; - Adw.ViewStackPage { - icon-name: "inbox-symbolic"; - title: bind inbox.title; - badge-number: bind inbox.counter; - needs-attention: bind inbox.counter; + sidebar: Adw.NavigationPage { + title: _("Content"); - child: $Inbox inbox {}; - } + child: Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.WindowTitle { + title: bind template.item as <$SidebarItem>.title; + subtitle: bind template.item as <$SidebarItem>.description; + }; - Adw.ViewStackPage { - icon-name: "outbox-symbolic"; - title: bind outbox.title; + [start] + Button { + icon-name: "sidebar-show-symbolic"; + tooltip-text: _("Toggle Sidebar"); + action-name: "win.toggle-sidebar"; + } - child: $Outbox outbox {}; - } + [start] + Button sync_button { + icon-name: "sync-symbolic"; + tooltip-text: _("Sync"); + clicked => $_sync(); + + ShortcutController { + scope: managed; + + Shortcut { + trigger: "r|F5"; + action: "activate"; + } + } + } - Adw.ViewStackPage { - icon-name: "sent-symbolic"; - title: bind sent.title; + [end] + ToggleButton search_button { + icon-name: "search-symbolic"; + tooltip-text: _("Search"); + + ShortcutController { + scope: managed; + + Shortcut { + trigger: "f"; + action: "activate"; + } + } + } - child: $Sent sent {}; - } + [end] + Button { + icon-name: bind template.item as <$SidebarItem>.action-icon-name; + tooltip-text: bind template.item as <$SidebarItem>.action-label; + action-name: bind template.item as <$SidebarItem>.action-name; + } + } + + [top] + SearchBar { + search-mode-enabled: bind search_button.active bidirectional; + key-capture-widget: template; + + child: SearchEntry { + hexpand: true; + placeholder-text: _("Search"); + text: bind template.search-text bidirectional; + }; + } + + [top] + Adw.Banner offline_banner { + title: _("Offline"); + } + + content: Adw.ViewStack list_view_stack { + visible-child-name: bind $_get_list_child_name(template.item-type, folder_selection.n-items, contacts_selection.n-items, template.loading, template.search-text) as ; + + Adw.ViewStackPage { + name: "empty"; + + child: Adw.StatusPage { + icon-name: bind template.item as <$SidebarItem>.icon-name; + title: bind template.item as <$SidebarItem>.placeholder-title; + description: bind template.item as <$SidebarItem>.placeholder-description; + + child: Button { + halign: center; + label: bind template.item as <$SidebarItem>.action-label; + action-name: bind template.item as <$SidebarItem>.action-name; + + styles [ + "pill", + ] + }; + + styles [ + "compact", + ] + }; + } - Adw.ViewStackPage { - icon-name: "drafts-symbolic"; - title: bind drafts.title; - badge-number: bind drafts.counter; + Adw.ViewStackPage { + name: "folder"; + + child: ScrolledWindow { + child: ListView { + model: SingleSelection folder_selection { + autoselect: false; + + model: FilterListModel { + filter: AnyFilter { + StringFilter { + expression: expr item as <$Message>.subject; + search: bind template.search_text; + } + + StringFilter { + expression: expr item as <$Message>.body; + search: bind template.search_text; + } + }; + + model: SortListModel { + sorter: NumericSorter { + expression: expr item as <$Message>.date; + sort-order: descending; + }; + + model: bind template.folder as <$FolderSidebarItem>.model; + }; + }; + }; + + factory: BuilderListItemFactory { + template ListItem { + child: $MessageRow { + message: bind template.item as <$Message>; + }; + } + }; + + styles [ + "navigation-sidebar", + ] + }; + }; + } - child: $Drafts drafts {}; - } + Adw.ViewStackPage { + name: "contacts"; + + child: ScrolledWindow { + child: ListView { + model: SingleSelection contacts_selection { + autoselect: false; + model: bind contacts_item.model; + }; + + factory: BuilderListItemFactory { + template ListItem { + child: $ContactRow { + profile: bind template.item as <$Profile>; + }; + } + }; + + styles [ + "navigation-sidebar", + ] + }; + }; + } - Adw.ViewStackPage { - icon-name: "trash-symbolic"; - title: bind trash.title; - badge-number: bind trash.counter; + Adw.ViewStackPage { + name: "loading"; - child: $Trash trash {}; - } + child: Adw.Spinner {}; + } - Adw.ViewStackPage { - starts-section: true; - icon-name: "broadcasts-symbolic"; - title: bind broadcasts.title; - badge-number: bind broadcasts.counter; + Adw.ViewStackPage { + name: "no-results"; - child: $Broadcasts broadcasts {}; - } + child: Adw.StatusPage { + icon-name: "search-symbolic"; + title: _("No Results Found"); + description: _("Try a different search"); - Adw.ViewStackPage { - icon-name: "contacts-symbolic"; - title: bind contacts.title; - badge-number: bind contacts.counter; - needs-attention: bind contacts.counter; + styles [ + "compact", + ] + }; + } + }; + }; + }; - child: $Contacts contacts {}; - } - }; + content: Adw.NavigationPage { + title: _("Details"); + + child: Adw.ViewStack { + visible-child-name: bind template.item-type; + + Adw.ViewStackPage { + name: "folder"; + + child: $ThreadView { + message: bind folder_selection.selected-item; + }; + } + + Adw.ViewStackPage { + name: "contacts"; + + child: $ProfileView { + profile: bind contacts_selection.selected-item; + }; + } + }; + }; + } }; }; }; @@ -218,7 +464,16 @@ template $Window: Adw.ApplicationWindow { condition ("max-width: 900") setters { - split_view.collapsed: true; + outer_split_view.collapsed: true; + } + } + + Adw.Breakpoint { + condition ("max-width: 600") + + setters { + inner_split_view.collapsed: true; + outer_split_view.collapsed: true; } } } diff --git a/openemail/gtk/window.py b/openemail/gtk/window.py index 71676b7..d2073eb 100644 --- a/openemail/gtk/window.py +++ b/openemail/gtk/window.py @@ -13,14 +13,24 @@ from openemail import APP_ID, PREFIX, Property, store, tasks from openemail.core import client from openemail.gtk.compose_sheet import ComposeSheet -from openemail.store import Profile +from openemail.gtk.contacts import ContactRow +from openemail.gtk.messages import MessageRow +from openemail.gtk.profile_view import ProfileView +from openemail.gtk.sidebar_item import FolderSidebarItem, SidebarItem +from openemail.store import DictStore, Folders, People, Profile -from .contacts import Contacts from .login_view import LoginView -from .messages import Broadcasts, Drafts, Inbox, Outbox, Sent, Trash from .profile_settings import ProfileSettings -for t in Contacts, Broadcasts, Drafts, Inbox, Outbox, Sent, Trash, ComposeSheet: +for t in ( + ComposeSheet, + ContactRow, + MessageRow, + ProfileView, + DictStore, + Folders, + People, +): GObject.type_ensure(t) @@ -33,21 +43,39 @@ class Window(Adw.ApplicationWindow): __gtype_name__ = __qualname__ + unread_filter: Gtk.Filter = child + + # For some reason, the badge doesn't update without these here + inbox_unread: Gtk.FilterListModel = child + trash_unread: Gtk.FilterListModel = child + broadcasts_unread: Gtk.FilterListModel = child + toast_overlay: Adw.ToastOverlay = child - split_view: Adw.OverlaySplitView = child + outer_split_view: Adw.OverlaySplitView = child sidebar_view: Adw.ToolbarView = child - stack: Adw.ViewStack = child profile_settings: ProfileSettings = child - content_child_name = Property(str, default="inbox") - profile_stack_child_name = Property(str, default="loading") - profile_image = Property(Gdk.Paintable) - app_icon_name = Property(str, default=f"{APP_ID}-symbolic") + sync_button: Gtk.Button = child + offline_banner: Adw.Banner = child login_view: LoginView = child + inner_sidebar: Adw.Sidebar = child # pyright: ignore[reportAttributeAccessIssue] + contacts_item: SidebarItem = child + visible_child_name = Property(str, default="auth") + profile_stack_child_name = Property(str, default="loading") + item_type = Property(str, default="folder") + + item = Property(SidebarItem) + folder = Property(FolderSidebarItem) + + search_text = Property(str) + loading = Property(bool) + + profile_image = Property(Gdk.Paintable) + app_icon_name = Property(str, default=f"{APP_ID}-symbolic") _quit: bool = False @@ -68,8 +96,8 @@ def __init__(self, **kwargs: Any): ("profile-settings", lambda *_: self.profile_settings.present(self)), ( "toggle-sidebar", - lambda *_: self.split_view.set_show_sidebar( - not self.split_view.props.show_sidebar + lambda *_: self.outer_split_view.set_show_sidebar( + not self.outer_split_view.props.show_sidebar ), ), )) @@ -79,31 +107,92 @@ def __init__(self, **kwargs: Any): Property.bind_setting(store.state_settings, "width", self, "default-width") Property.bind_setting(store.state_settings, "height", self, "default-height") - Property.bind_setting(store.state_settings, "show-sidebar", self.split_view) + Property.bind_setting( + store.state_settings, + "show-sidebar", + self.outer_split_view, + ) + + store.settings.connect( + "changed::unread-messages", + lambda *_: self.unread_filter.changed(Gtk.FilterChange.DIFFERENT), + ) self.get_settings().connect( "notify::gtk-decoration-layout", lambda *_: self.notify("header-bar-layout"), ) + def on_syncing_changed(*_args): + if app.notifier.syncing: + self.sync_button.props.sensitive = False + self.sync_button.add_css_class("spinning") + else: + self.sync_button.remove_css_class("spinning") + self.sync_button.props.sensitive = True + + app.notifier.connect("notify::syncing", on_syncing_changed) app.notifier.connect("send", self._on_send_notification) + Property.bind(app.notifier, "offline", self.offline_banner, "revealed") + tasks.create(store.sync(periodic=True)) + self._switch_page(None, 0) + if client.user.logged_in: self.visible_child_name = "content" + def _on_send_notification(self, _obj, toast: Adw.Toast): + if isinstance(dialog := self.props.visible_dialog, Adw.PreferencesDialog): + dialog.add_toast(toast) + return + + self.toast_overlay.add_toast(toast) + @Gtk.Template.Callback() - def _hide_sidebar(self, *_args): - if self.split_view.props.collapsed: - self.split_view.props.show_sidebar = False + def _switch_page(self, _obj, index: int): # pyright: ignore[reportAttributeAccessIssue] + self.item = self.inner_sidebar.get_item(index) # pyright: ignore[reportUnknownVariableType] + + match self.item: + case FolderSidebarItem(): + self.folder = self.item + self.item_type = "folder" + case self.contacts_item: + self.item_type = "contacts" + + if self.outer_split_view.props.collapsed: + self.outer_split_view.props.show_sidebar = False @Gtk.Template.Callback() def _on_auth(self, *_args): self.visible_child_name = "content" - def _on_send_notification(self, _obj, toast: Adw.Toast): - if isinstance(dialog := self.props.visible_dialog, Adw.PreferencesDialog): - dialog.add_toast(toast) - return + @Gtk.Template.Callback() + def _sync(self, *_args): + tasks.create(store.sync()) - self.toast_overlay.add_toast(toast) + @Gtk.Template.Callback() + def _get_list_child_name( + self, + _obj, + item_type: str, + folder_n_items: int, + contacts_n_items: int, + loading: bool, + search_text: str, + ) -> str: + return ( + item_type + if ( + folder_n_items + if item_type == "folder" + else contacts_n_items + if item_type == "contacts" + else 0 + ) + else "loading" + if loading + else "no-results" + if search_text + else "empty" + ) diff --git a/openemail/store.py b/openemail/store.py index bb64c3c..7da6f02 100644 --- a/openemail/store.py +++ b/openemail/store.py @@ -481,6 +481,52 @@ async def _fetch(self) -> AsyncGenerator[model.Message]: drafts = _DraftStore() +_trashed_msg = Gtk.PropertyExpression.new(Message, None, "trashed") +_trashed_filter = Gtk.BoolFilter(expression=_trashed_msg) +_not_trashed_filter = Gtk.BoolFilter(expression=_trashed_msg, invert=True) + + +def _update_trashed_state(*_args): + for f in _trashed_filter, _not_trashed_filter: + f.changed(Gtk.FilterChange.DIFFERENT) + + +settings.connect("changed::trashed-messages", _update_trashed_state) + + +def _filtered_folder( + folder: Gio.ListModel, /, *, trashed: bool = False +) -> Property[Gtk.FilterListModel]: + return Property( + Gtk.FilterListModel, + default=Gtk.FilterListModel.new( + folder, _trashed_filter if trashed else _not_trashed_filter + ), + ) + + +class Folders(GObject.Object): + """The global GObject message store. Most useful in a `Gtk.Builder` context. + + Has the added benefit of filtering out trashed messages when not relevant. + """ + + __gtype_name__ = __qualname__ + + inbox = _filtered_folder(inbox) + outbox = Property(_OutboxStore, default=outbox) + sent = _filtered_folder(sent) + drafts = Property(_DraftStore, default=drafts) + broadcasts = _filtered_folder(broadcasts) + trash = _filtered_folder( + flatten( + globals()["inbox"], + globals()["sent"], + globals()["broadcasts"], + ), + trashed=True, + ) + async def sync(*, periodic: bool = False): """Populate the app's content by fetching the user's data.""" diff --git a/po/POTFILES.in b/po/POTFILES.in index 17a8d82..3969247 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -14,7 +14,6 @@ openemail/gtk/ui/dialogs.blp openemail/gtk/ui/login-view.blp openemail/gtk/ui/message-row.blp openemail/gtk/ui/message-view.blp -openemail/gtk/ui/page.blp openemail/gtk/ui/preferences.blp openemail/gtk/ui/profile-settings.blp openemail/gtk/ui/profile-view.blp @@ -28,11 +27,11 @@ openemail/gtk/contacts.py openemail/gtk/form.py openemail/gtk/login_view.py openemail/gtk/messages.py -openemail/gtk/page.py openemail/gtk/preferences.py openemail/gtk/profile_settings.py openemail/gtk/profile_view.py openemail/gtk/request_buttons.py +openemail/gtk/sidebar_item.py openemail/gtk/thread_view.py openemail/gtk/window.py