[fractal/fractal-next] account-settings: Add device list



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]