[fractal/fractal-next] account-settings: Add device list
- From: Julian Sparber <jsparber src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [fractal/fractal-next] account-settings: Add device list
- Date: Fri, 24 Sep 2021 12:47:41 +0000 (UTC)
commit 9bf216a8546c7579c37cccf2331c8907a2d42238
Author: Julian Sparber <julian sparber net>
Date: Tue Sep 14 13:07:23 2021 +0200
account-settings: Add device list
This allows also the remove devices
.../icons/scalable/status/devices-symbolic.svg | 151 ++++++++++++
.../icons/scalable/status/verified-symbolic.svg | 17 ++
data/resources/resources.gresource.xml | 6 +
data/resources/ui/account-settings-device-row.ui | 70 ++++++
data/resources/ui/account-settings-devices-page.ui | 39 +++
data/resources/ui/account-settings.ui | 15 ++
.../resources/ui/components-loading-listbox-row.ui | 41 ++++
data/resources/ui/user-entry-row.ui | 16 +-
po/POTFILES.in | 11 +
src/components/loading_listbox_row.rs | 174 +++++++++++++
src/components/mod.rs | 2 +
src/meson.build | 6 +
.../account_settings/devices_page/device.rs | 245 +++++++++++++++++++
.../account_settings/devices_page/device_item.rs | 97 ++++++++
.../account_settings/devices_page/device_list.rs | 257 ++++++++++++++++++++
.../account_settings/devices_page/device_row.rs | 270 +++++++++++++++++++++
src/session/account_settings/devices_page/mod.rs | 195 +++++++++++++++
src/session/account_settings/mod.rs | 106 ++++++++
src/session/mod.rs | 22 ++
src/session/sidebar/account_switcher/mod.rs | 1 +
src/session/sidebar/account_switcher/user_entry.rs | 25 ++
21 files changed, 1764 insertions(+), 2 deletions(-)
---
diff --git a/data/resources/icons/scalable/status/devices-symbolic.svg
b/data/resources/icons/scalable/status/devices-symbolic.svg
new file mode 100644
index 00000000..9699a38f
--- /dev/null
+++ b/data/resources/icons/scalable/status/devices-symbolic.svg
@@ -0,0 +1,151 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
+ <filter id="a" height="100%" width="100%" x="0%" y="0%">
+ <feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
+ </filter>
+ <mask id="b">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.3"/>
+ </g>
+ </mask>
+ <clipPath id="c">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="d">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/>
+ </g>
+ </mask>
+ <clipPath id="e">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="f">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/>
+ </g>
+ </mask>
+ <clipPath id="g">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="h">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/>
+ </g>
+ </mask>
+ <clipPath id="i">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="j">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/>
+ </g>
+ </mask>
+ <clipPath id="k">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="l">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/>
+ </g>
+ </mask>
+ <clipPath id="m">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="n">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/>
+ </g>
+ </mask>
+ <clipPath id="o">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="p">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.3"/>
+ </g>
+ </mask>
+ <clipPath id="q">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="r">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.5"/>
+ </g>
+ </mask>
+ <clipPath id="s">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="t">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.4"/>
+ </g>
+ </mask>
+ <clipPath id="u">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="v">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.4"/>
+ </g>
+ </mask>
+ <clipPath id="w">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="x">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.5"/>
+ </g>
+ </mask>
+ <clipPath id="y">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <mask id="z">
+ <g filter="url(#a)">
+ <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.5"/>
+ </g>
+ </mask>
+ <clipPath id="A">
+ <path d="m 0 0 h 1024 v 800 h -1024 z"/>
+ </clipPath>
+ <g clip-path="url(#c)" mask="url(#b)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 562.460938 212.058594 h 10.449218 c -1.183594 0.492187 -1.296875 2.460937 0 3 h
-10.449218 z m 0 0" fill="#2e3436"/>
+ </g>
+ <g clip-path="url(#e)" mask="url(#d)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 16 632 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/>
+ </g>
+ <g clip-path="url(#g)" mask="url(#f)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 17 631 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/>
+ </g>
+ <g clip-path="url(#i)" mask="url(#h)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 18 634 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/>
+ </g>
+ <g clip-path="url(#k)" mask="url(#j)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 16 634 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/>
+ </g>
+ <g clip-path="url(#m)" mask="url(#l)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 17 635 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/>
+ </g>
+ <g clip-path="url(#o)" mask="url(#n)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 19 635 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/>
+ </g>
+ <g clip-path="url(#q)" mask="url(#p)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 136 660 v 7 h 7 v -7 z m 0 0" fill="#2e3436"/>
+ </g>
+ <g clip-path="url(#s)" mask="url(#r)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 199 642 h 3 v 12 h -3 z m 0 0" fill="#2e3436"/>
+ </g>
+ <path d="m 2 4 v 7 h -2 c 0 1.105469 0.894531 2 2 2 h 8 v -2 h -6 v -7 h 9 v 2 h 2 v -2 c 0 -1.105469
-0.894531 -2 -2 -2 h -9 c -1.105469 0 -2 0.894531 -2 2 z m 0 0" fill="#2e3436"/>
+ <path d="m 11 8 v 5 c 0 0.550781 0.449219 1 1 1 h 3 c 0.550781 0 1 -0.449219 1 -1 v -5 c 0 -0.550781
-0.449219 -1 -1 -1 h -3 c -0.550781 0 -1 0.449219 -1 1 z m 1 0 h 3 v 4 h -3 z m 0 0" fill="#2e3436"/>
+ <g clip-path="url(#u)" mask="url(#t)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 209.5 144.160156 c 0.277344 0 0.5 0.222656 0.5 0.5 v 1 c 0 0.277344 -0.222656 0.5 -0.5
0.5 s -0.5 -0.222656 -0.5 -0.5 v -1 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 z m 0 0" fill="#2e3436"/>
+ </g>
+ <g clip-path="url(#w)" mask="url(#v)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 206.5 144.160156 c 0.277344 0 0.5 0.222656 0.5 0.5 v 1 c 0 0.277344 -0.222656 0.5 -0.5
0.5 s -0.5 -0.222656 -0.5 -0.5 v -1 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 z m 0 0" fill="#2e3436"/>
+ </g>
+ <g clip-path="url(#y)" mask="url(#x)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 229.5 143.160156 c -0.546875 0 -1 0.457032 -1 1 c 0 0.546875 0.453125 1 1 1 s 1 -0.453125
1 -1 c 0 -0.542968 -0.453125 -1 -1 -1 z m 0 0" fill="#2e3436"/>
+ </g>
+ <g clip-path="url(#A)" mask="url(#z)" transform="matrix(1 0 0 1 -100 -376)">
+ <path d="m 226.453125 143.160156 c -0.519531 0 -0.953125 0.433594 -0.953125 0.953125 v 0.09375 c 0
0.519531 0.433594 0.953125 0.953125 0.953125 h 0.09375 c 0.519531 0 0.953125 -0.433594 0.953125 -0.953125 v
-0.09375 c 0 -0.519531 -0.433594 -0.953125 -0.953125 -0.953125 z m 0 0" fill="#2e3436"/>
+ </g>
+</svg>
diff --git a/data/resources/icons/scalable/status/verified-symbolic.svg
b/data/resources/icons/scalable/status/verified-symbolic.svg
new file mode 100644
index 00000000..82a6ac86
--- /dev/null
+++ b/data/resources/icons/scalable/status/verified-symbolic.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="16px" height="16px" viewBox="0 0 16 16"
version="1.1" id="svg7" sodipodi:docname="verified-symbolic.svg" inkscape:version="1.0.1 (3bc2e813f5,
2020-09-07)">
+ <metadata id="metadata13">
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ <dc:title/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs id="defs11"/>
+ <sodipodi:namedview pagecolor="#ffffff" bordercolor="#666666" borderopacity="1" objecttolerance="10"
gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2"
inkscape:window-width="1920" inkscape:window-height="1016" id="namedview9" showgrid="false"
inkscape:snap-bbox="true" inkscape:bbox-paths="true" inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true" inkscape:snap-bbox-midpoints="true" inkscape:zoom="11.313708"
inkscape:cx="16.27964" inkscape:cy="11.763079" inkscape:window-x="0" inkscape:window-y="0"
inkscape:window-maximized="1" inkscape:current-layer="svg7">
+ <inkscape:grid type="xygrid" id="grid851"/>
+ </sodipodi:namedview>
+ <path id="path7578"
style="fill:#2e3335;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 2,2 V 8.6191406 C 2,11.158741 3.5001,13.001285 7.4999998,14.333985 11.5001,13.001285 13,11.158741
13,8.6191406 V 2 Z M 10,4.2929687 11.414062,5.7070312 6.707031,10.416016 3.9999998,7.7070312
5.4140622,6.2929687 6.707031,7.5859375 Z"/>
+</svg>
\ No newline at end of file
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index 874d4dbd..182b4cfc 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -35,10 +35,16 @@
<file compressed="true" preprocess="xml-stripblanks"
alias="components-avatar.ui">ui/components-avatar.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="avatar-with-selection.ui">ui/avatar-with-selection.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="components-auth-dialog.ui">ui/components-auth-dialog.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="account-settings.ui">ui/account-settings.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="account-settings-device-row.ui">ui/account-settings-device-row.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="account-settings-devices-page.ui">ui/account-settings-devices-page.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="components-loading-listbox-row.ui">ui/components-loading-listbox-row.ui</file>
<file compressed="true">style.css</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/send-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/welcome.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/empty-page.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/explore-symbolic.svg</file>
+ <file preprocess="xml-stripblanks">icons/scalable/status/devices-symbolic.svg</file>
+ <file preprocess="xml-stripblanks">icons/scalable/status/verified-symbolic.svg</file>
</gresource>
</gresources>
diff --git a/data/resources/ui/account-settings-device-row.ui
b/data/resources/ui/account-settings-device-row.ui
new file mode 100644
index 00000000..f7646bd4
--- /dev/null
+++ b/data/resources/ui/account-settings-device-row.ui
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="AccountSettingsDeviceRow">
+ <property name="activatable">False</property>
+ <property name="selectable">False</property>
+ <property name="child">
+ <object class="GtkBox">
+ <property name="orientation">vertical</property>
+ <property name="halign">start</property>
+ <property name="spacing">6</property>
+ <property name="margin-top">12</property>
+ <property name="margin-bottom">12</property>
+ <property name="margin-start">12</property>
+ <property name="margin-end">12</property>
+ <child>
+ <object class="GtkBox">
+ <child>
+ <object class="GtkLabel" id="display_name">
+ <property name="xalign">0.0</property>
+ <property name="ellipsize">end</property>
+ <style>
+ <class name="heading"/>
+ </style>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImage" id="verified_icon">
+ <property name="icon-name">verified-symbolic</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkLabel" id="last_seen_ts">
+ <property name="xalign">0.0</property>
+ <style>
+ <class name="caption"/>
+ </style>
+ </object>
+ </child>
+ <child>
+ <object class="GtkLabel" id="last_seen_ip">
+ <property name="xalign">0.0</property>
+ <style>
+ <class name="dim-label"/>
+ <class name="caption"/>
+ </style>
+ </object>
+ </child>
+ <child>
+ <object class="GtkBox">
+ <property name="spacing">6</property>
+ <child>
+ <object class="SpinnerButton" id="delete_button">
+ <property name="label" translatable="yes">Delete Session</property>
+ </object>
+ </child>
+ <child>
+ <object class="SpinnerButton" id="verify_button">
+ <property name="visible">False</property>
+ <property name="label" translatable="yes">Verify Session</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </property>
+ </template>
+</interface>
+
diff --git a/data/resources/ui/account-settings-devices-page.ui
b/data/resources/ui/account-settings-devices-page.ui
new file mode 100644
index 00000000..453ee781
--- /dev/null
+++ b/data/resources/ui/account-settings-devices-page.ui
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="DevicesPage" parent="AdwPreferencesPage">
+ <property name="icon-name">devices-symbolic</property>
+ <property name="title" translatable="yes">Sessions</property>
+ <property name="name">sessions</property>
+ <child>
+ <object class="AdwPreferencesGroup">
+ <property name="title" translatable="yes">Current Session</property>
+ <child>
+ <object class="GtkListBox" id="current_session">
+ <accessibility>
+ <property name="label" translatable="yes">Current Session</property>
+ </accessibility>
+ <style>
+ <class name="content"/>
+ </style>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="AdwPreferencesGroup" id="other_sessions_group">
+ <property name="title" translatable="yes">Other Active Sessions</property>
+ <child>
+ <object class="GtkListBox" id="other_sessions">
+ <accessibility>
+ <property name="label" translatable="yes">Other Active Sessions</property>
+ </accessibility>
+ <style>
+ <class name="content"/>
+ </style>
+ </object>
+ </child>
+ </object>
+ </child>
+ </template>
+</interface>
+
diff --git a/data/resources/ui/account-settings.ui b/data/resources/ui/account-settings.ui
new file mode 100644
index 00000000..c4c54c6c
--- /dev/null
+++ b/data/resources/ui/account-settings.ui
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="AccountSettings" parent="AdwPreferencesWindow">
+ <property name="title" translatable="yes">Account Settings</property>
+ <property name="search-enabled">False</property>
+ <child>
+ <object class="DevicesPage">
+ <binding name="user">
+ <lookup name="user">AccountSettings</lookup>
+ </binding>
+ </object>
+ </child>
+ </template>
+</interface>
+
diff --git a/data/resources/ui/components-loading-listbox-row.ui
b/data/resources/ui/components-loading-listbox-row.ui
new file mode 100644
index 00000000..bb2d4b3d
--- /dev/null
+++ b/data/resources/ui/components-loading-listbox-row.ui
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="ComponentsLoadingListBoxRow">
+ <property name="activatable">False</property>
+ <property name="selectable">False</property>
+ <property name="child">
+ <object class="GtkStack" id="stack">
+ <property name="transition-type">crossfade</property>
+ <child>
+ <object class="GtkSpinner" id="spinner">
+ <property name="spinning">True</property>
+ <property name="valign">center</property>
+ <property name="halign">center</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkBox" id="error">
+ <property name="orientation">vertical</property>
+ <property name="spacing">12</property>
+ <property name="margin-top">12</property>
+ <property name="margin-bottom">12</property>
+ <property name="margin-start">12</property>
+ <property name="margin-end">12</property>
+ <child>
+ <object class="GtkLabel" id="error_label">
+ <property name="wrap">True</property>
+ <property name="wrap-mode">word-char</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkButton" id="retry_button">
+ <property name="label" translatable="yes">Retry</property>
+ <property name="halign">center</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </property>
+ </template>
+</interface>
diff --git a/data/resources/ui/user-entry-row.ui b/data/resources/ui/user-entry-row.ui
index 9785ad23..68241553 100644
--- a/data/resources/ui/user-entry-row.ui
+++ b/data/resources/ui/user-entry-row.ui
@@ -49,14 +49,26 @@
</lookup>
</binding>
<style>
- <class name="dim-label" />
- <class name="user-id" />
+ <class name="dim-label"/>
+ <class name="user-id"/>
</style>
</object>
</child>
</object>
</child>
+ <child>
+ <object class="GtkButton">
+ <property name="icon-name">applications-system-symbolic</property>
+ <property name="action-name">user-entry-row.open-account-settings</property>
+ <property name="valign">center</property>
+ <property name="halign">center</property>
+ <style>
+ <class name="circular"/>
+ </style>
+ </object>
+ </child>
</object>
</child>
</template>
</interface>
+
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 4dc61b5d..902998b2 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -6,8 +6,12 @@ data/org.gnome.FractalNext.metainfo.xml.in.in
# UI files
data/resources/ui/add_account.ui
+data/resources/ui/account-settings.ui
+data/resources/ui/account-settings-device-row.ui
+data/resources/ui/account-settings-devices-page.ui
data/resources/ui/components-auth-dialog.ui
data/resources/ui/components-avatar.ui
+data/resources/ui/components-loading-listbox-row.ui
data/resources/ui/avatar-with-selection.ui
data/resources/ui/content-divider-row.ui
data/resources/ui/content-item-row-menu.ui
@@ -42,6 +46,7 @@ src/components/avatar.rs
src/components/context_menu_bin.rs
src/components/custom_entry.rs
src/components/label_with_widgets.rs
+src/components/loading_listbox_row.rs
src/components/in_app_notification.rs
src/components/mod.rs
src/components/spinner_button.rs
@@ -50,6 +55,12 @@ src/error.rs
src/login.rs
src/main.rs
src/secret.rs
+src/session/account_settings/devices_page/device.rs
+src/session/account_settings/devices_page/device_item.rs
+src/session/account_settings/devices_page/device_list.rs
+src/session/account_settings/devices_page/device_row.rs
+src/session/account_settings/devices_page/mod.rs
+src/session/account_settings/mod.rs
src/session/categories/category.rs
src/session/categories/category_type.rs
src/session/categories/mod.rs
diff --git a/src/components/loading_listbox_row.rs b/src/components/loading_listbox_row.rs
new file mode 100644
index 00000000..ae997750
--- /dev/null
+++ b/src/components/loading_listbox_row.rs
@@ -0,0 +1,174 @@
+use glib::subclass::Signal;
+use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+mod imp {
+ use super::*;
+ use glib::subclass::InitializingObject;
+ use once_cell::sync::Lazy;
+ use std::cell::Cell;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(resource = "/org/gnome/FractalNext/components-loading-listbox-row.ui")]
+ pub struct LoadingListBoxRow {
+ #[template_child]
+ pub spinner: TemplateChild<gtk::Spinner>,
+ #[template_child]
+ pub stack: TemplateChild<gtk::Stack>,
+ #[template_child]
+ pub error: TemplateChild<gtk::Box>,
+ #[template_child]
+ pub error_label: TemplateChild<gtk::Label>,
+ #[template_child]
+ pub retry_button: TemplateChild<gtk::Button>,
+ pub is_error: Cell<bool>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for LoadingListBoxRow {
+ const NAME: &'static str = "ComponentsLoadingListBoxRow";
+ type Type = super::LoadingListBoxRow;
+ type ParentType = gtk::ListBoxRow;
+
+ fn class_init(klass: &mut Self::Class) {
+ Self::bind_template(klass);
+ }
+
+ fn instance_init(obj: &InitializingObject<Self>) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for LoadingListBoxRow {
+ fn properties() -> &'static [glib::ParamSpec] {
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![
+ glib::ParamSpec::new_boolean(
+ "loading",
+ "Loading",
+ "Whether to show the loading spinner",
+ true,
+ glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+ ),
+ glib::ParamSpec::new_string(
+ "error",
+ "Error",
+ "The error message to show",
+ None,
+ glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+ ),
+ ]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "loading" => {
+ obj.set_loading(value.get().unwrap());
+ }
+ "error" => {
+ obj.set_error(value.get().unwrap());
+ }
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "loading" => obj.is_loading().to_value(),
+ "error" => obj.error().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn signals() -> &'static [Signal] {
+ static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
+ vec![Signal::builder("retry", &[], <()>::static_type().into()).build()]
+ });
+ SIGNALS.as_ref()
+ }
+
+ fn constructed(&self, obj: &Self::Type) {
+ self.parent_constructed(obj);
+
+ self.retry_button
+ .connect_clicked(clone!(@weak obj => move |_| {
+ obj.emit_by_name("retry", &[]).unwrap();
+ }));
+ }
+ }
+ impl WidgetImpl for LoadingListBoxRow {}
+ impl ListBoxRowImpl for LoadingListBoxRow {}
+}
+
+glib::wrapper! {
+ /// This is a `ListBoxRow` continaing a loading spinner.
+ ///
+ /// It's also possible to set an error once the loading fails including a retry button.
+ pub struct LoadingListBoxRow(ObjectSubclass<imp::LoadingListBoxRow>)
+ @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible;
+}
+
+impl LoadingListBoxRow {
+ pub fn new() -> Self {
+ glib::Object::new(&[]).expect("Failed to create LoadingListBoxRow")
+ }
+
+ pub fn is_loading(&self) -> bool {
+ let priv_ = imp::LoadingListBoxRow::from_instance(self);
+ !priv_.is_error.get()
+ }
+
+ pub fn set_loading(&self, loading: bool) {
+ let priv_ = imp::LoadingListBoxRow::from_instance(self);
+
+ if self.is_loading() == loading {
+ return;
+ }
+
+ priv_.stack.set_visible_child(&*priv_.spinner);
+ priv_.is_error.set(false);
+
+ self.notify("loading");
+ }
+
+ pub fn error(&self) -> Option<glib::GString> {
+ let priv_ = imp::LoadingListBoxRow::from_instance(self);
+ let message = priv_.error_label.text();
+ if message.is_empty() {
+ None
+ } else {
+ Some(message)
+ }
+ }
+
+ pub fn set_error(&self, message: Option<&str>) {
+ let priv_ = imp::LoadingListBoxRow::from_instance(self);
+
+ if let Some(message) = message {
+ priv_.is_error.set(true);
+ priv_.error_label.set_text(message);
+ priv_.stack.set_visible_child(&*priv_.error);
+ } else {
+ priv_.is_error.set(false);
+ priv_.stack.set_visible_child(&*priv_.spinner);
+ }
+ self.notify("error");
+ }
+
+ pub fn connect_retry<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
+ self.connect_local("retry", true, move |values| {
+ let obj = values[0].get::<Self>().unwrap();
+ f(&obj);
+ None
+ })
+ .unwrap()
+ }
+}
diff --git a/src/components/mod.rs b/src/components/mod.rs
index 8dea5bdd..83d37e61 100644
--- a/src/components/mod.rs
+++ b/src/components/mod.rs
@@ -4,6 +4,7 @@ mod context_menu_bin;
mod custom_entry;
mod in_app_notification;
mod label_with_widgets;
+mod loading_listbox_row;
mod pill;
mod room_title;
mod spinner_button;
@@ -14,6 +15,7 @@ pub use self::context_menu_bin::{ContextMenuBin, ContextMenuBinExt, ContextMenuB
pub use self::custom_entry::CustomEntry;
pub use self::in_app_notification::InAppNotification;
pub use self::label_with_widgets::LabelWithWidgets;
+pub use self::loading_listbox_row::LoadingListBoxRow;
pub use self::pill::Pill;
pub use self::room_title::RoomTitle;
pub use self::spinner_button::SpinnerButton;
diff --git a/src/meson.build b/src/meson.build
index bf83a98d..84177de3 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -30,6 +30,7 @@ sources = files(
'components/room_title.rs',
'components/in_app_notification.rs',
'components/spinner_button.rs',
+ 'components/loading_listbox_row.rs',
'config.rs',
'error.rs',
'main.rs',
@@ -39,6 +40,11 @@ sources = files(
'utils.rs',
'session/avatar.rs',
'session/event_source_dialog.rs',
+ 'session/account_settings/devices_page/device.rs',
+ 'session/account_settings/devices_page/device_row.rs',
+ 'session/account_settings/devices_page/device_list.rs',
+ 'session/account_settings/devices_page/mod.rs',
+ 'session/account_settings/mod.rs',
'session/user.rs',
'session/mod.rs',
'session/content/divider_row.rs',
diff --git a/src/session/account_settings/devices_page/device.rs
b/src/session/account_settings/devices_page/device.rs
new file mode 100644
index 00000000..70862b31
--- /dev/null
+++ b/src/session/account_settings/devices_page/device.rs
@@ -0,0 +1,245 @@
+use gtk::{glib, prelude::*, subclass::prelude::*};
+
+use crate::components::{AuthData, AuthDialog};
+use crate::session::Session;
+use matrix_sdk::{
+ encryption::identities::Device as CryptoDevice,
+ ruma::{
+ api::client::r0::device::{delete_device, Device as MatrixDevice},
+ assign,
+ identifiers::DeviceId,
+ },
+};
+
+use log::error;
+
+mod imp {
+ use super::*;
+ use once_cell::sync::{Lazy, OnceCell};
+ use std::cell::RefCell;
+
+ #[derive(Debug, Default)]
+ pub struct Device {
+ pub device: OnceCell<MatrixDevice>,
+ pub crypto_device: OnceCell<CryptoDevice>,
+ pub session: RefCell<Option<Session>>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for Device {
+ const NAME: &'static str = "Device";
+ type Type = super::Device;
+ type ParentType = glib::Object;
+ }
+
+ impl ObjectImpl for Device {
+ fn properties() -> &'static [glib::ParamSpec] {
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![
+ glib::ParamSpec::new_object(
+ "session",
+ "Session",
+ "The session",
+ Session::static_type(),
+ glib::ParamFlags::READWRITE,
+ ),
+ glib::ParamSpec::new_string(
+ "device-id",
+ "Device Id",
+ "The Id of this device",
+ None,
+ glib::ParamFlags::READABLE,
+ ),
+ glib::ParamSpec::new_string(
+ "display-name",
+ "Display Name",
+ "The display name of the device",
+ None,
+ glib::ParamFlags::READABLE,
+ ),
+ glib::ParamSpec::new_string(
+ "last-seen-ip",
+ "Last Seen Ip",
+ "The last ip the device used",
+ None,
+ glib::ParamFlags::READABLE,
+ ),
+ glib::ParamSpec::new_pointer(
+ "last-seen-ts",
+ "Last Seen Ts",
+ "The last time the device was used",
+ glib::ParamFlags::READABLE,
+ ),
+ glib::ParamSpec::new_pointer(
+ "verified",
+ "Verified",
+ "Whether this devices is verified",
+ glib::ParamFlags::READABLE,
+ ),
+ ]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "session" => obj.set_session(value.get().unwrap()),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "session" => obj.session().to_value(),
+ "display-name" => obj.display_name().to_value(),
+ "device-id" => obj.device_id().as_str().to_value(),
+ "last-seen-ip" => obj.last_seen_ip().to_value(),
+ "last-seen-ts" => obj.last_seen_ts().to_value(),
+ "verified" => obj.is_verified().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+ }
+}
+
+glib::wrapper! {
+ /// `glib::Object` representation of a Device/Session of a User.
+ pub struct Device(ObjectSubclass<imp::Device>);
+}
+
+impl Device {
+ pub fn new(
+ session: Option<&Session>,
+ device: MatrixDevice,
+ crypto_device: Option<CryptoDevice>,
+ ) -> Self {
+ let obj: Self =
+ glib::Object::new(&[("session", &session)]).expect("Failed to create Device");
+
+ obj.set_matrix_device(device, crypto_device);
+
+ obj
+ }
+
+ pub fn session(&self) -> Option<Session> {
+ let priv_ = imp::Device::from_instance(self);
+ priv_.session.borrow().clone()
+ }
+
+ fn set_session(&self, session: Option<Session>) {
+ let priv_ = imp::Device::from_instance(self);
+
+ if self.session() == session {
+ return;
+ };
+
+ priv_.session.replace(session);
+
+ self.notify("session");
+ }
+
+ fn set_matrix_device(&self, device: MatrixDevice, crypto_device: Option<CryptoDevice>) {
+ let priv_ = imp::Device::from_instance(self);
+ priv_.device.set(device).unwrap();
+ if let Some(crypto_device) = crypto_device {
+ priv_.crypto_device.set(crypto_device).unwrap();
+ }
+ }
+
+ pub fn device_id(&self) -> &DeviceId {
+ let priv_ = imp::Device::from_instance(self);
+ &priv_.device.get().unwrap().device_id
+ }
+
+ pub fn display_name(&self) -> &str {
+ let priv_ = imp::Device::from_instance(self);
+ if let Some(ref display_name) = priv_.device.get().unwrap().display_name {
+ display_name
+ } else {
+ self.device_id().as_str()
+ }
+ }
+
+ pub fn last_seen_ip(&self) -> Option<&str> {
+ let priv_ = imp::Device::from_instance(self);
+ // TODO: Would be nice to also show the location
+ // See: https://gitlab.gnome.org/GNOME/fractal/-/issues/700
+ priv_
+ .device
+ .get()
+ .unwrap()
+ .last_seen_ip
+ .as_ref()
+ .map(String::as_str)
+ }
+
+ pub fn last_seen_ts(&self) -> Option<glib::DateTime> {
+ let priv_ = imp::Device::from_instance(self);
+ if let Some(last_seen_ts) = priv_.device.get().unwrap().last_seen_ts {
+ Some(
+ glib::DateTime::from_unix_utc(last_seen_ts.as_secs().into())
+ .and_then(|t| t.to_local())
+ .unwrap(),
+ )
+ } else {
+ None
+ }
+ }
+
+ /// Delete the `Device`
+ ///
+ /// Returns `true` for success
+ pub async fn delete(&self, transient_for: Option<&impl IsA<gtk::Window>>) -> bool {
+ let session = self
+ .session()
+ .expect("Session needs to be set when removing a device");
+ let client = session.client().clone();
+ let device_id = self.device_id().to_owned();
+
+ let delete_fn = move |auth_data: Option<AuthData>| {
+ let device_id = device_id.clone();
+ let client = client.clone();
+
+ async move {
+ if let Some(auth) = auth_data {
+ let auth = Some(auth.as_matrix_auth_data());
+ let request = assign!(delete_device::Request::new(&device_id), { auth });
+ client.send(request, None).await
+ } else {
+ let request = delete_device::Request::new(&device_id);
+ client.send(request, None).await
+ }
+ }
+ };
+
+ let dialog = AuthDialog::new(transient_for, &session);
+
+ let result = dialog
+ .authenticate::<delete_device::Request, _, _>(delete_fn)
+ .await;
+ match result {
+ Some(Ok(_)) => true,
+ Some(Err(err)) => {
+ // TODO: show error message to the user
+ error!("Failed to delete device: {}", err);
+ false
+ }
+ None => false,
+ }
+ }
+
+ pub fn is_verified(&self) -> bool {
+ let priv_ = imp::Device::from_instance(self);
+ priv_
+ .crypto_device
+ .get()
+ .map_or(false, |device| device.verified())
+ }
+}
diff --git a/src/session/account_settings/devices_page/device_item.rs
b/src/session/account_settings/devices_page/device_item.rs
new file mode 100644
index 00000000..59d5e29f
--- /dev/null
+++ b/src/session/account_settings/devices_page/device_item.rs
@@ -0,0 +1,97 @@
+use gtk::{glib, prelude::*, subclass::prelude::*};
+
+use super::Device;
+
+/// This enum contains all possible types the device list can hold.
+#[derive(Debug, Clone)]
+pub enum ItemType {
+ Device(Device),
+ Error(String),
+ LoadingSpinner,
+}
+
+#[derive(Clone, Debug, glib::GBoxed)]
+#[gboxed(type_name = "BoxedDeviceItemType")]
+pub struct BoxedItemType(ItemType);
+
+impl From<ItemType> for BoxedItemType {
+ fn from(type_: ItemType) -> Self {
+ BoxedItemType(type_)
+ }
+}
+
+mod imp {
+ use super::*;
+ use once_cell::sync::{Lazy, OnceCell};
+
+ #[derive(Debug, Default)]
+ pub struct Item {
+ pub type_: OnceCell<ItemType>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for Item {
+ const NAME: &'static str = "DeviceItem";
+ type Type = super::Item;
+ type ParentType = glib::Object;
+ }
+
+ impl ObjectImpl for Item {
+ fn properties() -> &'static [glib::ParamSpec] {
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![glib::ParamSpec::new_boxed(
+ "type",
+ "Type",
+ "The type of this item",
+ BoxedItemType::static_type(),
+ glib::ParamFlags::WRITABLE | glib::ParamFlags::CONSTRUCT_ONLY,
+ )]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ _obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "type" => {
+ let type_ = value.get::<BoxedItemType>().unwrap();
+ self.type_.set(type_.0).unwrap();
+ }
+
+ _ => unimplemented!(),
+ }
+ }
+ }
+}
+
+glib::wrapper! {
+ pub struct Item(ObjectSubclass<imp::Item>);
+}
+
+impl Item {
+ pub fn for_device(device: Device) -> Self {
+ let type_ = BoxedItemType(ItemType::Device(device));
+ glib::Object::new(&[("type", &type_)]).expect("Failed to create Item")
+ }
+
+ pub fn for_error(error: String) -> Self {
+ let type_ = BoxedItemType(ItemType::Error(error));
+ glib::Object::new(&[("type", &type_)]).expect("Failed to create Item")
+ }
+
+ pub fn for_loading_spinner() -> Self {
+ let type_ = BoxedItemType(ItemType::LoadingSpinner);
+ glib::Object::new(&[("type", &type_)]).expect("Failed to create Item")
+ }
+
+ pub fn type_(&self) -> &ItemType {
+ let priv_ = imp::Item::from_instance(self);
+ priv_.type_.get().unwrap()
+ }
+}
diff --git a/src/session/account_settings/devices_page/device_list.rs
b/src/session/account_settings/devices_page/device_list.rs
new file mode 100644
index 00000000..5ab1ddb7
--- /dev/null
+++ b/src/session/account_settings/devices_page/device_list.rs
@@ -0,0 +1,257 @@
+use gettextrs::gettext;
+use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
+use log::error;
+use matrix_sdk::encryption::identities::UserDevices as CryptoDevices;
+use matrix_sdk::ruma::api::client::r0::device::Device as MatrixDevice;
+use matrix_sdk::Error;
+
+use crate::{session::Session, utils::do_async};
+
+use super::{Device, DeviceItem};
+
+mod imp {
+ use once_cell::sync::Lazy;
+ use std::cell::{Cell, RefCell};
+
+ use super::*;
+
+ #[derive(Debug, Default)]
+ pub struct DeviceList {
+ pub list: RefCell<Vec<DeviceItem>>,
+ pub session: RefCell<Option<Session>>,
+ pub current_device: RefCell<Option<DeviceItem>>,
+ pub loading: Cell<bool>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for DeviceList {
+ const NAME: &'static str = "DeviceList";
+ type Type = super::DeviceList;
+ type ParentType = glib::Object;
+ type Interfaces = (gio::ListModel,);
+ }
+
+ impl ObjectImpl for DeviceList {
+ fn properties() -> &'static [glib::ParamSpec] {
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![
+ glib::ParamSpec::new_object(
+ "session",
+ "Session",
+ "The session",
+ Session::static_type(),
+ glib::ParamFlags::READWRITE,
+ ),
+ glib::ParamSpec::new_object(
+ "current-device",
+ "Current Device",
+ "The device of this session",
+ DeviceItem::static_type(),
+ glib::ParamFlags::READABLE,
+ ),
+ ]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "session" => obj.set_session(value.get().unwrap()),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "session" => obj.session().to_value(),
+ "current-device" => obj.current_device().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+ }
+
+ impl ListModelImpl for DeviceList {
+ fn item_type(&self, _list_model: &Self::Type) -> glib::Type {
+ DeviceItem::static_type()
+ }
+ fn n_items(&self, _list_model: &Self::Type) -> u32 {
+ self.list.borrow().len() as u32
+ }
+ fn item(&self, _list_model: &Self::Type, position: u32) -> Option<glib::Object> {
+ self.list
+ .borrow()
+ .get(position as usize)
+ .map(glib::object::Cast::upcast_ref::<glib::Object>)
+ .cloned()
+ }
+ }
+}
+
+glib::wrapper! {
+ /// List of active devices for the logged in user.
+ pub struct DeviceList(ObjectSubclass<imp::DeviceList>)
+ @implements gio::ListModel;
+}
+
+impl DeviceList {
+ pub fn new(session: &Session) -> Self {
+ glib::Object::new(&[("session", session)]).expect("Failed to create DeviceList")
+ }
+
+ pub fn session(&self) -> Option<Session> {
+ let priv_ = imp::DeviceList::from_instance(self);
+ priv_.session.borrow().clone()
+ }
+
+ fn set_session(&self, session: Option<Session>) {
+ let priv_ = imp::DeviceList::from_instance(self);
+
+ if self.session() == session {
+ return;
+ };
+
+ priv_.session.replace(session);
+
+ self.load_devices();
+
+ self.notify("session");
+ }
+
+ fn set_loading(&self, loading: bool) {
+ let priv_ = imp::DeviceList::from_instance(self);
+
+ if loading == priv_.loading.get() {
+ return;
+ }
+ if loading {
+ self.update_list(vec![DeviceItem::for_loading_spinner()]);
+ }
+ priv_.loading.set(loading);
+ self.notify("current-device");
+ }
+
+ fn loading(&self) -> bool {
+ let priv_ = imp::DeviceList::from_instance(self);
+ priv_.loading.get()
+ }
+
+ pub fn current_device(&self) -> DeviceItem {
+ let priv_ = imp::DeviceList::from_instance(self);
+
+ priv_.current_device.borrow().clone().unwrap_or_else(|| {
+ if self.loading() {
+ DeviceItem::for_loading_spinner()
+ } else {
+ DeviceItem::for_error(gettext("Failed to load connected device."))
+ }
+ })
+ }
+
+ fn set_current_device(&self, device: Option<DeviceItem>) {
+ let priv_ = imp::DeviceList::from_instance(self);
+
+ priv_.current_device.replace(device);
+
+ self.notify("current-device");
+ }
+
+ fn update_list(&self, devices: Vec<DeviceItem>) {
+ let priv_ = imp::DeviceList::from_instance(self);
+ let added = devices.len();
+
+ let prev_devices = priv_.list.replace(devices);
+
+ self.items_changed(0, prev_devices.len() as u32, added as u32);
+ }
+
+ fn finish_loading(
+ &self,
+ response: Result<(Option<MatrixDevice>, Vec<MatrixDevice>, CryptoDevices), Error>,
+ ) {
+ let session = self.session();
+ let session = session.as_ref();
+
+ match response {
+ Ok((current_device, devices, crypto_devices)) => {
+ let devices = devices
+ .into_iter()
+ .map(|device| {
+ let crypto_device = crypto_devices.get(&device.device_id);
+ DeviceItem::for_device(Device::new(session, device, crypto_device))
+ })
+ .collect();
+
+ self.update_list(devices);
+
+ self.set_current_device(current_device.map(|device| {
+ let crypto_device = crypto_devices.get(&device.device_id);
+ DeviceItem::for_device(Device::new(session, device, crypto_device))
+ }));
+ }
+ Err(error) => {
+ error!("Couldn't load device list: {}", error);
+ self.update_list(vec![DeviceItem::for_error(gettext(
+ "Failed to load connected devices.",
+ ))]);
+ }
+ }
+ self.set_loading(false);
+ }
+
+ pub fn load_devices(&self) {
+ let client = if let Some(session) = self.session() {
+ session.client().clone()
+ } else {
+ return;
+ };
+
+ self.set_loading(true);
+
+ do_async(
+ glib::PRIORITY_DEFAULT,
+ async move {
+ let user_id = client.user_id().await.unwrap();
+ let crypto_devices = client.get_user_devices(&user_id).await;
+
+ let crypto_devices = match crypto_devices {
+ Ok(crypto_devices) => crypto_devices,
+ Err(error) => return Err(Error::CryptoStoreError(error)),
+ };
+
+ match client.devices().await {
+ Ok(mut response) => {
+ response
+ .devices
+ .sort_unstable_by(|a, b| b.last_seen_ts.cmp(&a.last_seen_ts));
+
+ let current_device =
+ if let Some(current_device_id) = client.device_id().await {
+ if let Some(index) = response.devices.iter().position(|device| {
+ *device.device_id == current_device_id.as_ref()
+ }) {
+ Some(response.devices.remove(index))
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ Ok((current_device, response.devices, crypto_devices))
+ }
+ Err(error) => Err(Error::Http(error)),
+ }
+ },
+ clone!(@weak self as obj => move |response| async move {
+ obj.finish_loading(response);
+ }),
+ );
+ }
+}
diff --git a/src/session/account_settings/devices_page/device_row.rs
b/src/session/account_settings/devices_page/device_row.rs
new file mode 100644
index 00000000..19fe9b4c
--- /dev/null
+++ b/src/session/account_settings/devices_page/device_row.rs
@@ -0,0 +1,270 @@
+use gettextrs::gettext;
+use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
+use gtk_macros::spawn;
+
+use super::Device;
+use crate::components::SpinnerButton;
+
+const G_TIME_SPAN_DAY: i64 = 86400000000;
+
+mod imp {
+ use super::*;
+ use glib::subclass::InitializingObject;
+ use once_cell::sync::Lazy;
+ use std::cell::RefCell;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(resource = "/org/gnome/FractalNext/account-settings-device-row.ui")]
+ pub struct DeviceRow {
+ #[template_child]
+ pub display_name: TemplateChild<gtk::Label>,
+ #[template_child]
+ pub verified_icon: TemplateChild<gtk::Image>,
+ #[template_child]
+ pub last_seen_ip: TemplateChild<gtk::Label>,
+ #[template_child]
+ pub last_seen_ts: TemplateChild<gtk::Label>,
+ #[template_child]
+ pub delete_button: TemplateChild<SpinnerButton>,
+ #[template_child]
+ pub verify_button: TemplateChild<SpinnerButton>,
+ pub device: RefCell<Option<Device>>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for DeviceRow {
+ const NAME: &'static str = "AccountSettingsDeviceRow";
+ type Type = super::DeviceRow;
+ type ParentType = gtk::ListBoxRow;
+
+ fn class_init(klass: &mut Self::Class) {
+ Self::bind_template(klass);
+ }
+
+ fn instance_init(obj: &InitializingObject<Self>) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for DeviceRow {
+ fn properties() -> &'static [glib::ParamSpec] {
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![glib::ParamSpec::new_object(
+ "device",
+ "Device",
+ "The device this row is showing",
+ Device::static_type(),
+ glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+ )]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "device" => {
+ obj.set_device(value.get().unwrap());
+ }
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "device" => obj.device().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn constructed(&self, obj: &Self::Type) {
+ self.parent_constructed(obj);
+
+ self.delete_button
+ .connect_clicked(clone!(@weak obj => move |_| {
+ obj.delete();
+ }));
+
+ self.verify_button
+ .connect_clicked(clone!(@weak obj => move |_| {
+ todo!("Not implemented");
+ }));
+ }
+ }
+ impl WidgetImpl for DeviceRow {}
+ impl ListBoxRowImpl for DeviceRow {}
+}
+
+glib::wrapper! {
+ pub struct DeviceRow(ObjectSubclass<imp::DeviceRow>)
+ @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible;
+}
+
+impl DeviceRow {
+ pub fn new(device: &Device) -> Self {
+ glib::Object::new(&[("device", device)]).expect("Failed to create DeviceRow")
+ }
+
+ pub fn device(&self) -> Option<Device> {
+ let priv_ = imp::DeviceRow::from_instance(self);
+ priv_.device.borrow().clone()
+ }
+
+ pub fn set_device(&self, device: Option<Device>) {
+ let priv_ = imp::DeviceRow::from_instance(self);
+
+ if self.device() == device {
+ return;
+ }
+
+ if let Some(ref device) = device {
+ priv_.display_name.set_label(&device.display_name());
+ self.set_tooltip_text(Some(device.device_id().as_str()));
+
+ priv_.verified_icon.set_visible(device.is_verified());
+ // TODO: Implement verification
+ //priv_.verify_button.set_visible(!device.is_verified());
+
+ if let Some(last_seen_ip) = device.last_seen_ip() {
+ priv_.last_seen_ip.set_label(last_seen_ip);
+ priv_.last_seen_ip.show();
+ } else {
+ priv_.last_seen_ip.hide();
+ }
+
+ if let Some(last_seen_ts) = device.last_seen_ts() {
+ let last_seen_ts = format_date_time_as_string(last_seen_ts);
+ priv_.last_seen_ts.set_label(&last_seen_ts);
+ priv_.last_seen_ts.show();
+ } else {
+ priv_.last_seen_ts.hide();
+ }
+ }
+
+ priv_.device.replace(device);
+ self.notify("device");
+ }
+
+ fn delete(&self) {
+ let priv_ = imp::DeviceRow::from_instance(self);
+
+ priv_.delete_button.set_loading(true);
+
+ if let Some(device) = self.device() {
+ spawn!(clone!(@weak self as obj => async move {
+ let window: Option<gtk::Window> = obj.root().and_then(|root| root.downcast().ok());
+ let success = device.delete(window.as_ref()).await;
+ let priv_ = imp::DeviceRow::from_instance(&obj);
+ priv_.delete_button.set_loading(false);
+
+ if success {
+ obj.hide();
+ }
+ }));
+ }
+ }
+}
+
+// This was ported from Nautilus and simplified for our use case.
+// See: https://gitlab.gnome.org/GNOME/nautilus/-/blob/master/src/nautilus-file.c#L5488
+pub fn format_date_time_as_string(datetime: glib::DateTime) -> glib::GString {
+ let now = glib::DateTime::new_now_local().unwrap();
+ let format;
+ let days_ago = {
+ let today_midnight =
+ glib::DateTime::new_local(now.year(), now.month(), now.day_of_month(), 0, 0, 0f64)
+ .unwrap();
+
+ let date = glib::DateTime::new_local(
+ datetime.year(),
+ datetime.month(),
+ datetime.day_of_month(),
+ 0,
+ 0,
+ 0f64,
+ )
+ .unwrap();
+
+ today_midnight.difference(&date) / G_TIME_SPAN_DAY
+ };
+
+ let use_24 = {
+ let local_time = datetime.format("%X").unwrap().as_str().to_ascii_lowercase();
+ local_time.ends_with("am") || local_time.ends_with("pm")
+ };
+
+ // Show only the time if date is on today
+ if days_ago == 0 {
+ if use_24 {
+ // Translators: Time in 24h format
+ format = gettext("Last seen at %H:%M");
+ } else {
+ // Translators: Time in 12h format
+ format = gettext("Last seen at %l:%M %p");
+ }
+ }
+ // Show the word "Yesterday" and time if date is on yesterday
+ else if days_ago == 1 {
+ if use_24 {
+ // Translators: this is the word Yesterday followed by
+ // a time in 24h format. i.e. "Last seen Yesterday at 23:04"
+ // xgettext:no-c-format
+ format = gettext("Last seen Yesterday at %H:%M");
+ } else {
+ // Translators: this is the word Yesterday followed by
+ // a time in 12h format. i.e. "Last seen Yesterday at 9:04 PM"
+ // xgettext:no-c-format
+ format = gettext("Last seen Yesterday at %l:%M %p");
+ }
+ }
+ // Show a week day and time if date is in the last week
+ else if days_ago > 1 && days_ago < 7 {
+ if use_24 {
+ // Translators: this is the name of the week day followed by
+ // a time in 24h format. i.e. "Last seen Monday at 23:04"
+ // xgettext:no-c-format
+ format = gettext("Last seen %A at %H:%M");
+ } else {
+ // Translators: this is the week day name followed by
+ // a time in 12h format. i.e. "Last seen Monday at 9:04 PM"
+ // xgettext:no-c-format
+ format = gettext("Last seen %A at %l:%M %p");
+ }
+ } else if datetime.year() == now.year() {
+ if use_24 {
+ // Translators: this is the day of the month followed
+ // by the abbreviated month name followed by a time in
+ // 24h format i.e. "Last seen February 3 at 23:04"
+ // xgettext:no-c-format
+ format = gettext("Last seen %B %-e at %H:%M");
+ } else {
+ // Translators: this is the day of the month followed
+ // by the abbreviated month name followed by a time in
+ // 12h format i.e. "Last seen February 3 at 9:04 PM"
+ // xgettext:no-c-format
+ format = gettext("Last seen %B %-e at %l:%M %p");
+ }
+ } else {
+ if use_24 {
+ // Translators: this is the day number followed
+ // by the abbreviated month name followed by the year followed
+ // by a time in 24h format i.e. "Last seen February 3 2015 at 23:04"
+ // xgettext:no-c-format
+ format = gettext("Last seen %B %-e %Y at %H:%M");
+ } else {
+ // Translators: this is the day number followed
+ // by the abbreviated month name followed by the year followed
+ // by a time in 12h format i.e. "Last seen February 3 2015 at 9:04 PM"
+ // xgettext:no-c-format
+ format = gettext("Last seen %B %-e %Y at %l:%M %p");
+ }
+ }
+
+ datetime.format(&format).unwrap()
+}
diff --git a/src/session/account_settings/devices_page/mod.rs
b/src/session/account_settings/devices_page/mod.rs
new file mode 100644
index 00000000..cfb98eab
--- /dev/null
+++ b/src/session/account_settings/devices_page/mod.rs
@@ -0,0 +1,195 @@
+use adw::subclass::prelude::*;
+use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+mod device;
+use self::device::Device;
+mod device_row;
+use self::device_row::DeviceRow;
+mod device_item;
+use self::device_item::Item as DeviceItem;
+mod device_list;
+use self::device_list::DeviceList;
+
+use crate::components::LoadingListBoxRow;
+
+use crate::session::user::UserExt;
+use crate::session::User;
+
+mod imp {
+ use super::*;
+ use glib::subclass::InitializingObject;
+ use std::cell::RefCell;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(resource = "/org/gnome/FractalNext/account-settings-devices-page.ui")]
+ pub struct DevicesPage {
+ pub user: RefCell<Option<User>>,
+ #[template_child]
+ pub other_sessions_group: TemplateChild<adw::PreferencesGroup>,
+ #[template_child]
+ pub other_sessions: TemplateChild<gtk::ListBox>,
+ #[template_child]
+ pub current_session: TemplateChild<gtk::ListBox>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for DevicesPage {
+ const NAME: &'static str = "DevicesPage";
+ type Type = super::DevicesPage;
+ type ParentType = adw::PreferencesPage;
+
+ fn class_init(klass: &mut Self::Class) {
+ Self::bind_template(klass);
+ }
+
+ fn instance_init(obj: &InitializingObject<Self>) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for DevicesPage {
+ fn properties() -> &'static [glib::ParamSpec] {
+ use once_cell::sync::Lazy;
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![glib::ParamSpec::new_object(
+ "user",
+ "User",
+ "The user of this account",
+ User::static_type(),
+ glib::ParamFlags::READWRITE,
+ )]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "user" => obj.set_user(value.get().unwrap()),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "user" => obj.user().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+ }
+
+ impl WidgetImpl for DevicesPage {}
+ impl PreferencesPageImpl for DevicesPage {}
+}
+
+glib::wrapper! {
+ /// Preference Window to display and update room details.
+ pub struct DevicesPage(ObjectSubclass<imp::DevicesPage>)
+ @extends gtk::Widget, gtk::Window, adw::Window, adw::PreferencesWindow, @implements gtk::Accessible;
+}
+
+impl DevicesPage {
+ pub fn new(parent_window: &Option<gtk::Window>, user: &User) -> Self {
+ glib::Object::new(&[("transient-for", parent_window), ("user", user)])
+ .expect("Failed to create DevicesPage")
+ }
+
+ pub fn user(&self) -> Option<User> {
+ let priv_ = imp::DevicesPage::from_instance(self);
+ priv_.user.borrow().clone()
+ }
+
+ fn set_user(&self, user: Option<User>) {
+ let priv_ = imp::DevicesPage::from_instance(self);
+
+ if self.user() == user {
+ return;
+ }
+
+ if let Some(ref user) = user {
+ let device_list = DeviceList::new(user.session());
+ priv_.other_sessions.bind_model(
+ Some(&device_list),
+ clone!(@weak device_list => @default-panic, move |item| {
+ match item.downcast_ref::<DeviceItem>().unwrap().type_() {
+ device_item::ItemType::Device(device) => {
+ DeviceRow::new(&device).upcast::<gtk::Widget>()
+ }
+ device_item::ItemType::Error(error) => {
+ let row = LoadingListBoxRow::new();
+ row.set_error(Some(error));
+ row.connect_retry(clone!(@weak device_list => move|_| {
+ device_list.load_devices()
+ }));
+ row.upcast::<gtk::Widget>()
+ }
+ device_item::ItemType::LoadingSpinner => {
+ LoadingListBoxRow::new().upcast::<gtk::Widget>()
+ }
+ }
+ }),
+ );
+
+ device_list.connect_items_changed(
+ clone!(@weak self as obj => move |device_list, _, _, _| {
+ obj.set_other_sessions_visiblity(device_list.n_items() > 0)
+ }),
+ );
+
+ self.set_other_sessions_visiblity(device_list.n_items() > 0);
+
+ device_list.connect_notify_local(
+ Some("current-device"),
+ clone!(@weak self as obj => move |device_list, _| {
+ obj.set_current_device(&device_list);
+ }),
+ );
+
+ self.set_current_device(&device_list);
+ } else {
+ priv_.other_sessions.unbind_model();
+
+ if let Some(child) = priv_.current_session.first_child() {
+ priv_.current_session.remove(&child);
+ }
+ }
+
+ priv_.user.replace(user);
+ self.notify("user");
+ }
+
+ fn set_other_sessions_visiblity(&self, visible: bool) {
+ let priv_ = imp::DevicesPage::from_instance(self);
+ priv_.other_sessions_group.set_visible(visible);
+ }
+
+ fn set_current_device(&self, device_list: &DeviceList) {
+ let priv_ = imp::DevicesPage::from_instance(self);
+ if let Some(child) = priv_.current_session.first_child() {
+ priv_.current_session.remove(&child);
+ }
+ let row = match device_list.current_device().type_() {
+ device_item::ItemType::Device(device) => {
+ DeviceRow::new(&device).upcast::<gtk::Widget>()
+ }
+ device_item::ItemType::Error(error) => {
+ let row = LoadingListBoxRow::new();
+ row.set_error(Some(error));
+ row.connect_retry(clone!(@weak device_list => move|_| {
+ device_list.load_devices()
+ }));
+ row.upcast::<gtk::Widget>()
+ }
+ device_item::ItemType::LoadingSpinner => {
+ LoadingListBoxRow::new().upcast::<gtk::Widget>()
+ }
+ };
+ priv_.current_session.append(&row);
+ }
+}
diff --git a/src/session/account_settings/mod.rs b/src/session/account_settings/mod.rs
new file mode 100644
index 00000000..c19cd442
--- /dev/null
+++ b/src/session/account_settings/mod.rs
@@ -0,0 +1,106 @@
+use adw::subclass::prelude::*;
+use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+mod devices_page;
+use devices_page::DevicesPage;
+
+use crate::session::User;
+
+mod imp {
+ use super::*;
+ use glib::subclass::InitializingObject;
+ use std::cell::RefCell;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(resource = "/org/gnome/FractalNext/account-settings.ui")]
+ pub struct AccountSettings {
+ pub user: RefCell<Option<User>>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for AccountSettings {
+ const NAME: &'static str = "AccountSettings";
+ type Type = super::AccountSettings;
+ type ParentType = adw::PreferencesWindow;
+
+ fn class_init(klass: &mut Self::Class) {
+ DevicesPage::static_type();
+ Self::bind_template(klass);
+ }
+
+ fn instance_init(obj: &InitializingObject<Self>) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for AccountSettings {
+ fn properties() -> &'static [glib::ParamSpec] {
+ use once_cell::sync::Lazy;
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![glib::ParamSpec::new_object(
+ "user",
+ "User",
+ "The user of this account",
+ User::static_type(),
+ glib::ParamFlags::READWRITE,
+ )]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "user" => obj.set_user(value.get().unwrap()),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "user" => obj.user().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+ }
+
+ impl WidgetImpl for AccountSettings {}
+ impl WindowImpl for AccountSettings {}
+ impl AdwWindowImpl for AccountSettings {}
+ impl PreferencesWindowImpl for AccountSettings {}
+}
+
+glib::wrapper! {
+ /// Preference Window to display and update room details.
+ pub struct AccountSettings(ObjectSubclass<imp::AccountSettings>)
+ @extends gtk::Widget, gtk::Window, adw::Window, adw::PreferencesWindow, @implements gtk::Accessible;
+}
+
+impl AccountSettings {
+ pub fn new(parent_window: &Option<gtk::Window>, user: &User) -> Self {
+ glib::Object::new(&[("transient-for", parent_window), ("user", user)])
+ .expect("Failed to create AccountSettings")
+ }
+
+ pub fn user(&self) -> Option<User> {
+ let priv_ = imp::AccountSettings::from_instance(self);
+ priv_.user.borrow().clone()
+ }
+
+ fn set_user(&self, user: Option<User>) {
+ let priv_ = imp::AccountSettings::from_instance(self);
+
+ if self.user() == user {
+ return;
+ }
+
+ priv_.user.replace(user);
+ self.notify("user");
+ }
+}
diff --git a/src/session/mod.rs b/src/session/mod.rs
index 23549bef..74e3efef 100644
--- a/src/session/mod.rs
+++ b/src/session/mod.rs
@@ -1,3 +1,4 @@
+mod account_settings;
mod avatar;
mod content;
mod event_source_dialog;
@@ -6,6 +7,7 @@ mod room_list;
mod sidebar;
mod user;
+use self::account_settings::AccountSettings;
pub use self::avatar::Avatar;
use self::content::Content;
pub use self::room::Room;
@@ -100,6 +102,14 @@ mod imp {
"session.toggle-room-search",
None,
);
+
+ klass.install_action(
+ "session.open-account-settings",
+ None,
+ move |widget, _, _| {
+ widget.open_account_settings();
+ },
+ );
}
fn instance_init(obj: &InitializingObject<Self>) {
@@ -482,6 +492,18 @@ impl Session {
.sidebar
.set_logged_in_users(sessions_stack_pages, self);
}
+
+ /// Returns the parent GtkWindow containing this widget.
+ fn parent_window(&self) -> Option<gtk::Window> {
+ self.root()?.downcast().ok()
+ }
+
+ fn open_account_settings(&self) {
+ if let Some(user) = self.user() {
+ let window = AccountSettings::new(&self.parent_window(), &user);
+ window.show();
+ }
+ }
}
impl Default for Session {
diff --git a/src/session/sidebar/account_switcher/mod.rs b/src/session/sidebar/account_switcher/mod.rs
index d4f92ee2..82765f3d 100644
--- a/src/session/sidebar/account_switcher/mod.rs
+++ b/src/session/sidebar/account_switcher/mod.rs
@@ -34,6 +34,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
+ klass.set_accessible_role(gtk::AccessibleRole::Dialog);
}
fn instance_init(obj: &InitializingObject<Self>) {
diff --git a/src/session/sidebar/account_switcher/user_entry.rs
b/src/session/sidebar/account_switcher/user_entry.rs
index 613df8c8..34c33fc4 100644
--- a/src/session/sidebar/account_switcher/user_entry.rs
+++ b/src/session/sidebar/account_switcher/user_entry.rs
@@ -2,6 +2,8 @@ use super::avatar_with_selection::AvatarWithSelection;
use adw::subclass::prelude::BinImpl;
use gtk::{self, glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+use crate::session::Session;
+
mod imp {
use super::*;
use glib::subclass::InitializingObject;
@@ -29,6 +31,14 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
AvatarWithSelection::static_type();
Self::bind_template(klass);
+
+ klass.install_action(
+ "user-entry-row.open-account-settings",
+ None,
+ move |item, _, _| {
+ item.show_account_settings();
+ },
+ );
}
fn instance_init(obj: &InitializingObject<Self>) {
@@ -107,4 +117,19 @@ impl UserEntryRow {
.display_name
.set_css_classes(if hinted { &["bold"] } else { &[] });
}
+
+ pub fn show_account_settings(&self) {
+ let priv_ = imp::UserEntryRow::from_instance(self);
+
+ let session = priv_
+ .session_page
+ .borrow()
+ .as_ref()
+ .map(|widget| widget.child())
+ .unwrap()
+ .downcast::<Session>()
+ .unwrap();
+
+ session.activate_action("session.open-account-settings", None);
+ }
}
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]