Compare commits

...

14 Commits
12 ... main

Author SHA1 Message Date
Yuri Chornoivan
5a873ff96d Update Ukrainian translation 2025-08-15 16:03:08 +00:00
Kévin Commaille
d87aa8ff60
room-history: Fix binding to prevent mentioning own user with sender name
Clicking was not possible for mentioning our own user, but the key
binding still worked.
2025-08-15 11:48:18 +02:00
Kévin Commaille
31c0607568
room-history: Remove sender avatar context menu
The avatar now opens the sender profile, where all the same actions can
be performed.
2025-08-15 11:47:32 +02:00
Kévin Commaille
17533ddfe9
ci: Fix cargo-deny config 2025-08-15 11:02:46 +02:00
Kévin Commaille
71bd5988a5
Clean up .gitignore
Add comments for documenting the entries.

Remove IDE hidden folders, they should be ignored globally on the
system, not per-project.
2025-08-15 10:47:19 +02:00
Kévin Commaille
49a3ce16a3
room-history: Apply system monospace font to code blocks and event source 2025-08-14 11:06:29 +02:00
Kévin Commaille
99b0a54e01
room-history: Apply system document font to messages 2025-08-14 11:06:16 +02:00
Kévin Commaille
7a64036bb1
Use body class extensively
To benefit from the increased line height for improved legibility.
2025-08-14 10:14:59 +02:00
Kévin Commaille
e3c34328ee
utils: Avoid to use a temp file for decoding images when possible
By using the new API from glycin.
2025-08-13 11:45:22 +02:00
Kévin Commaille
0e9d34dd9d
Upgrade glycin
Tests the beta for GNOME 49 with the loaders in the Flatpak runtime.
2025-08-13 11:45:22 +02:00
Kévin Commaille
4597519128
utils: Simplify TokioDrop API
It is now just a wrapper.
2025-08-13 11:45:22 +02:00
Kévin Commaille
8a7c690d21
session: Do not expose Matrix client as property 2025-08-13 11:45:22 +02:00
Kévin Commaille
07cc8f9787
Upgrade matrix-sdk
Brings in fixes for sending media with the unauthenticated endpoints.
2025-08-13 10:28:28 +02:00
Kévin Commaille
2a09a76fb0
Upgrade crate dependencies
Just run `cargo update`.
2025-08-11 15:22:48 +02:00
52 changed files with 1159 additions and 1866 deletions

24
.gitignore vendored
View File

@ -1,15 +1,19 @@
target/
build/
# Common build directory names
_build/
build/
builddir/
build-aux/app
# Cargo
target/
# Flatpak
.flatpak-builder
src/config.rs
.flatpak
.fenv
# Temporary files
*.ui.in~
*.ui~
.flatpak
subprojects/libadwaita
subprojects/gtksourceview
.vscode
.fenv
.zed
# Dynamically-generated file
src/config.rs

466
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -61,7 +61,7 @@ zeroize = "1"
# gtk-rs project and dependents. These usually need to be updated together.
adw = { package = "libadwaita", version = "0.7", features = ["v1_7"] }
glycin = { version = "2", default-features = false, features = ["tokio", "gdk4"] }
glycin = { version = "3.0.0-beta.1", default-features = false, features = ["tokio", "gdk4"] }
gst = { version = "0.23", package = "gstreamer" }
gst_app = { version = "0.23", package = "gstreamer-app" }
gst_pbutils = { version = "0.23", package = "gstreamer-pbutils" }
@ -74,23 +74,23 @@ sourceview = { package = "sourceview5", version = "0.9" }
[dependencies.matrix-sdk]
# version = "0.13"
git = "https://github.com/matrix-org/matrix-rust-sdk.git"
rev = "872713c4bc024ac9246dfa72f834584ebe92a3d7"
rev = "a9ce1c6e5822b8eb8411c5bc257049d9a9d15884"
features = ["socks", "sso-login", "markdown", "qrcode"]
[dependencies.matrix-sdk-store-encryption]
# version = "0.13"
git = "https://github.com/matrix-org/matrix-rust-sdk.git"
rev = "872713c4bc024ac9246dfa72f834584ebe92a3d7"
rev = "a9ce1c6e5822b8eb8411c5bc257049d9a9d15884"
[dependencies.matrix-sdk-ui]
# version = "0.13"
git = "https://github.com/matrix-org/matrix-rust-sdk.git"
rev = "872713c4bc024ac9246dfa72f834584ebe92a3d7"
rev = "a9ce1c6e5822b8eb8411c5bc257049d9a9d15884"
[dependencies.ruma]
# version = "0.12.5"
git = "https://github.com/ruma/ruma.git"
rev = "e73f302e4df7f5f0511fca1aa43853d4cf8416c8"
rev = "a2fe858133ba932b4bda730dc7472c9c985739a0"
features = [
"client-api-c",
"markdown",

View File

@ -95,27 +95,6 @@
}
]
},
{
"name": "glycin-loaders",
"buildsystem": "meson",
"config-opts": [
"-Dtests=false",
"-Dlibglycin=false",
"-Dintrospection=false",
"-Dvapi=false",
"-Dcapi_docs=false",
"-Dpython_tests=false"
],
"sources": [
{
"type": "git",
"url": "https://gitlab.gnome.org/sophie-h/glycin.git",
"tag": "1.2.2",
"commit": "c7d362287303944721cf583d4d9e9f7721bfa407",
"disable-submodules": true
}
]
},
{
"name": "fractal",
"buildsystem": "meson",

View File

@ -99,43 +99,30 @@ room-title {
}
}
}
}
sender-avatar {
padding: 5px;
border-radius: 100%;
button.sender-avatar {
padding: 5px;
@include vendor.focus-ring();
/* @include vendor.focus-ring(); */
&:hover {
background-color: vendor.$hover_color;
image {
filter: brightness(1.07) ;
&:hover {
image {
filter: brightness(1.07) ;
}
}
}
&:active {
background-color: vendor.$active_color;
image {
filter: brightness(1.16) ;
&:active {
image {
filter: brightness(1.16) ;
}
}
}
&:checked {
background-color: vendor.$selected_color;
image {
filter: brightness(1.1) ;
&:checked {
image {
filter: brightness(1.1) ;
}
}
}
popover button.text-button {
padding-left: 10px;
padding-right: 10px;
font-weight: 400;
}
}
message-sender {
@ -208,7 +195,6 @@ message-sender {
.codeview {
border-radius: vendor.$menu_radius;
padding: 6px;
font-family: monospace;
background-color: var(--text-view-bg);
color: var(--view-fg-color);
}

View File

@ -201,11 +201,6 @@ sidebar {
font-size: 1.6em;
}
// Event details dialog
.event-details-dialog .sourceview {
font-family: monospace;
}
// Account settings
.account-settings listview {
background: transparent;

View File

@ -17,8 +17,8 @@ allow = [
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"GPL-3.0",
"LGPL-2.1",
"GPL-3.0-or-later",
"GPL-3.0-only",
"ISC",
"MIT",
"MPL-2.0",

View File

@ -162,6 +162,7 @@ src/session/view/content/room_history/message_row/location.rs
src/session/view/content/room_history/message_row/location.ui
src/session/view/content/room_history/message_row/message_state_stack.ui
src/session/view/content/room_history/message_row/mod.rs
src/session/view/content/room_history/message_row/mod.ui
src/session/view/content/room_history/message_row/reaction/mod.rs
src/session/view/content/room_history/message_row/reaction_list.ui
src/session/view/content/room_history/message_row/reply.ui
@ -177,8 +178,6 @@ src/session/view/content/room_history/member_timestamp/row.rs
src/session/view/content/room_history/mod.rs
src/session/view/content/room_history/mod.ui
src/session/view/content/room_history/read_receipts_list/mod.rs
src/session/view/content/room_history/sender_avatar/mod.rs
src/session/view/content/room_history/sender_avatar/mod.ui
src/session/view/content/room_history/state/content.rs
src/session/view/content/room_history/state/creation.rs
src/session/view/content/room_history/state/creation.ui

614
po/uk.po

File diff suppressed because it is too large Load Diff

View File

@ -62,7 +62,6 @@
<property name="label" translatable="yes">Select the account you want to open the URI with</property>
<style>
<class name="body"/>
<class name="description"/>
</style>
</object>
</child>

View File

@ -47,6 +47,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -163,6 +166,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -171,6 +177,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -238,6 +247,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -246,6 +258,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -254,6 +269,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -55,6 +55,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -157,6 +160,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -182,6 +188,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -190,6 +199,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -269,6 +281,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -277,6 +292,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -285,6 +303,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -72,7 +72,6 @@
<property name="label" translatable="yes">Enter a room ID, room alias, or link to look up a room</property>
<style>
<class name="body"/>
<class name="description"/>
</style>
</object>
</child>
@ -161,6 +160,9 @@
<property name="justify">center</property>
<property name="use-markup">True</property>
<property name="selectable">True</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -6,7 +6,7 @@ use tracing::error;
use crate::{
spawn, spawn_tokio,
utils::{CountedRef, File},
utils::{CountedRef, File, TokioDrop},
};
mod imp {
@ -19,9 +19,12 @@ mod imp {
#[derive(Default)]
pub struct AnimatedImagePaintable {
/// The image loader.
image_loader: OnceCell<Arc<Image<'static>>>,
/// The image decoder.
decoder: OnceCell<Arc<TokioDrop<Image>>>,
/// The file of the image.
///
/// We need to keep a strong reference to the temporary file or it will
/// be destroyed.
file: OnceCell<File>,
/// The current frame that is displayed.
pub(super) current_frame: RefCell<Option<Arc<Frame>>>,
@ -49,7 +52,7 @@ mod imp {
self.current_frame
.borrow()
.as_ref()
.map_or_else(|| self.image_loader().info().height, |f| f.height())
.map_or_else(|| self.decoder().details().height(), |f| f.height())
.try_into()
.unwrap_or(i32::MAX)
}
@ -58,7 +61,7 @@ mod imp {
self.current_frame
.borrow()
.as_ref()
.map_or_else(|| self.image_loader().info().width, |f| f.width())
.map_or_else(|| self.decoder().details().width(), |f| f.width())
.try_into()
.unwrap_or(i32::MAX)
}
@ -94,26 +97,27 @@ mod imp {
}
impl AnimatedImagePaintable {
/// The image loader.
fn image_loader(&self) -> &Arc<Image<'static>> {
self.image_loader
.get()
.expect("image loader is initialized")
/// The image decoder.
fn decoder(&self) -> &Arc<TokioDrop<Image>> {
self.decoder.get().expect("decoder should be initialized")
}
/// Initialize the image.
pub(super) fn init(
&self,
file: File,
image_loader: Arc<Image<'static>>,
decoder: Arc<TokioDrop<Image>>,
first_frame: Arc<Frame>,
file: Option<File>,
) {
self.file.set(file).expect("file is uninitialized");
self.image_loader
.set(image_loader)
.expect("image loader is uninitialized");
self.decoder
.set(decoder)
.expect("decoder should be uninitialized");
self.current_frame.replace(Some(first_frame));
if let Some(file) = file {
self.file.set(file).expect("file should be uninitialized");
}
self.update_animation();
}
@ -198,9 +202,9 @@ mod imp {
}
async fn load_next_frame_inner(&self) {
let image = self.image_loader().clone();
let decoder = self.decoder().clone();
let result = spawn_tokio!(async move { image.next_frame().await })
let result = spawn_tokio!(async move { decoder.next_frame().await })
.await
.unwrap();
@ -229,16 +233,16 @@ glib::wrapper! {
}
impl AnimatedImagePaintable {
/// Construct an `AnimatedImagePaintable` with the given loader and first
/// frame.
/// Construct an `AnimatedImagePaintable` with the given decoder, first
/// frame, and the file containing the image, if any.
pub(crate) fn new(
file: File,
image_loader: Arc<Image<'static>>,
decoder: Arc<TokioDrop<Image>>,
first_frame: Arc<Frame>,
file: Option<File>,
) -> Self {
let obj = glib::Object::new::<Self>();
obj.imp().init(file, image_loader, first_frame);
obj.imp().init(decoder, first_frame, file);
obj
}

View File

@ -59,6 +59,9 @@
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="label" translatable="yes">Fractal relies on a Secret Portal to manage your sensitive session information and an error occurred while we were trying to restore your sessions.</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -71,6 +74,9 @@
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="label" translatable="yes">Here are a few things that might help you fix issues with the Secret Portal:</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -88,6 +94,9 @@
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="label" translatable="yes">Make sure you have a Secret Portal Backend Provider installed, like gnome-keyring.</property>
<style>
<class name="body"/>
</style>
</object>
</child>
</object>
@ -111,6 +120,9 @@
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="label" translatable="yes">If you prefer to use a Secret Service Provider instead, you need to allow Fractal to interact with it, like this:</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -168,6 +180,9 @@
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="label" translatable="yes">Check that you have a default keyring and that it is unlocked.</property>
<style>
<class name="body"/>
</style>
</object>
</child>
</object>
@ -180,6 +195,9 @@
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="label" translatable="yes">Check the application logs and your distributions documentation for more details.</property>
<style>
<class name="body"/>
</style>
</object>
</child>
</object>

View File

@ -34,6 +34,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -33,6 +33,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -33,6 +33,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -39,6 +39,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -40,6 +40,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -34,6 +34,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -42,6 +45,9 @@
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<property name="label" translatable="yes">You can accept this verification from another session or decline it for all your sessions</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -40,6 +40,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -35,6 +35,9 @@
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<property name="label" translatable="yes">You are no longer in the room where the verification was taking place</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -33,6 +33,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -33,6 +33,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -49,6 +52,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -34,6 +34,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -42,6 +45,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -56,6 +62,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -80,6 +80,9 @@
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<property name="label" translatable="yes">This session is ready to send and receive secure messages</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -316,7 +316,7 @@ mod imp {
if let Some(send_state) = item.send_state() {
match send_state {
EventSendState::NotSentYet => return MessageState::Sending,
EventSendState::NotSentYet { .. } => return MessageState::Sending,
EventSendState::SendingFailed {
error,
is_recoverable,

View File

@ -58,10 +58,6 @@ pub enum SessionState {
Ready = 2,
}
#[derive(Clone, Debug, glib::Boxed)]
#[boxed_type(name = "BoxedClient")]
pub struct BoxedClient(Client);
mod imp {
use std::cell::{Cell, OnceCell, RefCell};
@ -70,9 +66,8 @@ mod imp {
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::Session)]
pub struct Session {
/// The Matrix client.
#[property(construct_only)]
client: TokioDrop<BoxedClient>,
/// The Matrix client for this session.
client: OnceCell<TokioDrop<Client>>,
/// The list model of the sidebar.
#[property(get = Self::sidebar_list_model)]
sidebar_list_model: OnceCell<SidebarListModel>,
@ -128,27 +123,6 @@ mod imp {
#[glib::derived_properties]
impl ObjectImpl for Session {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
self.ignored_users.set_session(Some(obj.clone()));
self.notifications.set_session(Some(obj.clone()));
self.user_sessions.init(&obj, obj.user_id().clone());
let monitor = gio::NetworkMonitor::default();
let handler_id = monitor.connect_network_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_, _| {
spawn!(async move {
imp.update_homeserver_reachable().await;
});
}
));
self.network_monitor_handler_id.replace(Some(handler_id));
}
fn dispose(&self) {
// Needs to be disconnected or else it may restart the sync
if let Some(handler_id) = self.network_monitor_handler_id.take() {
@ -176,9 +150,34 @@ mod imp {
}
impl Session {
// The Matrix client.
/// Set the Matrix client for this session.
pub(super) fn set_client(&self, client: Client) {
self.client
.set(TokioDrop::new(client))
.expect("client should be uninitialized");
let obj = self.obj();
self.ignored_users.set_session(Some(obj.clone()));
self.notifications.set_session(Some(obj.clone()));
self.user_sessions.init(&obj, obj.user_id().clone());
let monitor = gio::NetworkMonitor::default();
let handler_id = monitor.connect_network_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_, _| {
spawn!(async move {
imp.update_homeserver_reachable().await;
});
}
));
self.network_monitor_handler_id.replace(Some(handler_id));
}
/// The Matrix client for this session.
pub(super) fn client(&self) -> &Client {
&self.client.get().expect("session should be restored").0
self.client.get().expect("client should be initialized")
}
/// The list model of the sidebar.
@ -752,11 +751,13 @@ impl Session {
.await
.expect("task was not aborted")?;
Ok(glib::Object::builder()
let obj = glib::Object::builder::<Self>()
.property("info", stored_session)
.property("settings", settings)
.property("client", BoxedClient(client))
.build())
.build();
obj.imp().set_client(client);
Ok(obj)
}
/// Create a new session from the session of the given Matrix client.

View File

@ -33,6 +33,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -41,6 +44,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<style>
<class name="body"/>
</style>
</object>
</child>
</object>

View File

@ -44,6 +44,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -53,6 +56,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<style>
<class name="body"/>
</style>
</object>
</child>
</object>

View File

@ -43,6 +43,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -33,7 +33,6 @@
</child>
<child>
<object class="GtkLabel" id="description">
<property name="halign">start</property>
<property name="ellipsize">end</property>
<property name="lines">4</property>
<property name="wrap">True</property>
@ -41,6 +40,9 @@
<property name="xalign">0</property>
<property name="use-markup">True</property>
<property name="selectable">True</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -101,6 +101,7 @@
</binding>
<style>
<class name="dimmed"/>
<class name="body"/>
</style>
</object>
</child>
@ -108,6 +109,9 @@
<object class="LabelWithWidgets" id="inviter">
<property name="halign">center</property>
<property name="justify">center</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -95,6 +95,7 @@
</binding>
<style>
<class name="dimmed"/>
<class name="body"/>
</style>
</object>
</child>
@ -105,6 +106,9 @@
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="label" translatable="yes">You requested an invite to this room</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -57,6 +57,9 @@
</lookup>
</closure>
</binding>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
@ -93,6 +96,7 @@
<property name="label" translatable="yes">There are no members in this room</property>
<style>
<class name="dimmed"/>
<class name="body"/>
</style>
</object>
</child>

View File

@ -1,5 +1,6 @@
use adw::{prelude::*, subclass::prelude::*};
use gtk::{CompositeTemplate, gdk, glib, glib::clone};
use tracing::error;
mod audio;
mod caption;
@ -19,11 +20,13 @@ use self::{
message_state_stack::MessageStateStack, reaction_list::MessageReactionList,
sender_name::MessageSenderName,
};
use super::{ReadReceiptsList, SenderAvatar};
use super::ReadReceiptsList;
use crate::{
Application, gettext_f,
Application,
components::UserProfileDialog,
gettext_f,
prelude::*,
session::model::{Event, EventHeaderState},
session::model::{Event, EventHeaderState, Member},
system_settings::ClockFormat,
utils::BoundObject,
};
@ -42,7 +45,7 @@ mod imp {
#[properties(wrapper_type = super::MessageRow)]
pub struct MessageRow {
#[template_child]
avatar: TemplateChild<SenderAvatar>,
avatar_button: TemplateChild<gtk::Button>,
#[template_child]
header: TemplateChild<gtk::Box>,
#[template_child]
@ -62,6 +65,9 @@ mod imp {
/// The event that is presented.
#[property(get, set = Self::set_event, explicit_notify)]
event: BoundObject<Event>,
/// The sender of the event that is presented.
#[property(get = Self::sender)]
sender: PhantomData<Option<Member>>,
/// The texture of the image preview displayed by the descendant of this
/// widget, if any.
#[property(get = Self::texture)]
@ -76,6 +82,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
@ -133,6 +140,7 @@ mod imp {
impl WidgetImpl for MessageRow {}
impl BinImpl for MessageRow {}
#[gtk::template_callbacks]
impl MessageRow {
/// Set the event that is presented.
fn set_event(&self, event: Event) {
@ -145,7 +153,6 @@ mod imp {
}
let sender = event.sender();
self.avatar.set_sender(Some(sender.clone()));
self.display_name.set_sender(Some(sender));
let state_binding = event
@ -193,12 +200,18 @@ mod imp {
],
);
obj.notify_event();
obj.notify_sender();
self.update_content();
self.update_header();
self.update_timestamp();
}
/// The sender of the event that is presented.
fn sender(&self) -> Option<Member> {
self.event.obj().map(|event| event.sender())
}
/// Update the header for the current event.
fn update_header(&self) {
let Some(event) = self.event.obj() else {
@ -209,7 +222,7 @@ mod imp {
let avatar_name_visible = header_state == EventHeaderState::Full;
let header_visible = header_state != EventHeaderState::Hidden;
self.avatar.set_visible(avatar_name_visible);
self.avatar_button.set_visible(avatar_name_visible);
self.display_name.set_visible(avatar_name_visible);
self.header.set_visible(header_visible);
@ -258,6 +271,19 @@ mod imp {
pub(super) fn texture(&self) -> Option<gdk::Texture> {
self.content.texture()
}
/// View the profile of the sender.
#[template_callback]
fn view_sender_profile(&self) {
let Some(sender) = self.sender() else {
error!("Could not open profile for missing sender");
return;
};
let dialog = UserProfileDialog::new();
dialog.set_room_member(sender);
dialog.present(Some(&*self.obj()));
}
}
}

View File

@ -8,7 +8,26 @@
</style>
<property name="column-spacing">8</property>
<child>
<object class="ContentSenderAvatar" id="avatar">
<object class="GtkButton" id="avatar_button">
<property name="tooltip-text" translatable="yes">View Sender Profile</property>
<property name="valign">start</property>
<property name="child">
<object class="Avatar" id="avatar">
<property name="size">36</property>
<property name="accessible-role">presentation</property>
<binding name="data">
<lookup name="avatar-data">
<lookup name="sender">ContentMessageRow</lookup>
</lookup>
</binding>
</object>
</property>
<signal name="clicked" handler="view_sender_profile" swapped="yes"/>
<style>
<class name="circular"/>
<class name="flat"/>
<class name="sender-avatar"/>
</style>
<layout>
<property name="column">0</property>
<property name="row">0</property>

View File

@ -147,7 +147,7 @@ mod imp {
let prev_activatable = self.gesture_click.borrow().is_some();
let obj = self.obj();
obj.action_set_enabled("message-sender.mention", activatable);
obj.action_set_enabled("message-sender.activate", activatable);
if activatable == prev_activatable {
// Nothing to update.

View File

@ -283,6 +283,7 @@ mod imp {
let obj = self.obj();
let child = obj.child_or_default::<LabelWithWidgets>();
child.add_css_class("document");
child.set_ellipsize(ellipsize);
child.set_use_markup(true);
child.set_label_and_widgets(result, pills);

View File

@ -32,6 +32,7 @@ pub(super) fn new_message_label() -> gtk::Label {
.xalign(0.0)
.valign(gtk::Align::Start)
.use_markup(true)
.css_classes(["document"])
.build()
}
@ -190,6 +191,7 @@ fn label_for_inline_html(
}
}
let w = LabelWithWidgets::new();
w.add_css_class("document");
w.set_use_markup(true);
w.set_ellipsize(config.ellipsize);
w.set_label_and_widgets(text, widgets);
@ -318,7 +320,10 @@ impl ListType {
/// Construct the widget for the bullet of the current type at the given
/// position.
fn bullet(&self, position: usize) -> gtk::Label {
let bullet = gtk::Label::builder().valign(gtk::Align::Baseline).build();
let bullet = gtk::Label::builder()
.css_classes(["document"])
.valign(gtk::Align::Baseline)
.build();
match self {
ListType::Unordered => bullet.set_label(""),
@ -404,7 +409,7 @@ fn widget_for_preformatted_text(
let view = sourceview::View::builder()
.buffer(&buffer)
.editable(false)
.css_classes(["codeview", "frame"])
.css_classes(["codeview", "frame", "monospace"])
.hexpand(true)
.build();

View File

@ -813,8 +813,7 @@ mod imp {
}
future::Either::Right((response, _)) => {
// The linux location stream requires a tokio executor when dropped.
let stream_drop = TokioDrop::new();
let _ = stream_drop.set(location_stream);
let _ = TokioDrop::new(location_stream);
if response == gtk::ResponseType::Ok {
break;

View File

@ -19,7 +19,6 @@ mod member_timestamp;
mod message_row;
mod message_toolbar;
mod read_receipts_list;
mod sender_avatar;
mod state;
mod title;
mod typing_row;
@ -32,7 +31,6 @@ use self::{
message_row::MessageRow,
message_toolbar::MessageToolbar,
read_receipts_list::ReadReceiptsList,
sender_avatar::SenderAvatar,
state::{StateGroupRow, StateRow},
title::RoomHistoryTitle,
typing_row::TypingRow,
@ -71,8 +69,6 @@ mod imp {
#[template(resource = "/org/gnome/Fractal/ui/session/view/content/room_history/mod.ui")]
#[properties(wrapper_type = super::RoomHistory)]
pub struct RoomHistory {
#[template_child]
sender_menu_model: TemplateChild<gio::Menu>,
#[template_child]
pub(super) header_bar: TemplateChild<adw::HeaderBar>,
#[template_child]
@ -103,7 +99,6 @@ mod imp {
drag_overlay: TemplateChild<DragOverlay>,
/// The context menu for rows presenting an [`Event`].
event_context_menu: OnceCell<EventActionsContextMenu>,
sender_context_menu: OnceCell<gtk::PopoverMenu>,
/// The timeline currently displayed.
#[property(get, set = Self::set_timeline, explicit_notify, nullable)]
timeline: BoundObject<Timeline>,
@ -1127,21 +1122,6 @@ mod imp {
self.event_context_menu.get_or_init(Default::default)
}
/// The context menu for the sender avatars.
pub(super) fn sender_context_menu(&self) -> &gtk::PopoverMenu {
self.sender_context_menu.get_or_init(|| {
let popover = gtk::PopoverMenu::builder()
.has_arrow(false)
.halign(gtk::Align::Start)
.menu_model(&*self.sender_menu_model)
.build();
popover.update_property(&[gtk::accessible::Property::Label(&gettext(
"Sender Context Menu",
))]);
popover
})
}
/// Opens the room details with the given initial view.
fn open_room_details(&self, initial_view: room_details::InitialView) {
let Some(room) = self.room() else {
@ -1205,11 +1185,6 @@ impl RoomHistory {
fn event_context_menu(&self) -> &EventActionsContextMenu {
self.imp().event_context_menu()
}
/// The context menu for the sender avatars.
fn sender_context_menu(&self) -> &gtk::PopoverMenu {
self.imp().sender_context_menu()
}
}
/// Set the proper child of the given `GtkListItem` for the given

View File

@ -31,93 +31,6 @@
</item>
</section>
</menu>
<menu id="sender_menu_model">
<section>
<item>
<attribute name="custom">user-id</attribute>
</item>
</section>
<section>
<item>
<!-- Translators: In this string, 'Mention' is a verb. -->
<attribute name="label" translatable="yes">_Mention</attribute>
<attribute name="action">sender-avatar.mention</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<!-- Translators: In this string, 'Open' is a verb. -->
<attribute name="label" translatable="yes">_Open Direct Chat</attribute>
<attribute name="action">sender-avatar.open-direct-chat</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Copy _Link</attribute>
<attribute name="action">sender-avatar.permalink</attribute>
</item>
</section>
<section>
<item>
<!-- Translators: In this string, 'Invite' is a verb. -->
<attribute name="label" translatable="yes">_Invite</attribute>
<attribute name="action">sender-avatar.invite</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Revoke _Invite</attribute>
<attribute name="action">sender-avatar.revoke-invite</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<!-- Translators: In this string, 'Mute' is a verb, as in 'Mute room member'. -->
<attribute name="label" translatable="yes">M_ute</attribute>
<attribute name="action">sender-avatar.mute</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Unmute</attribute>
<attribute name="action">sender-avatar.unmute</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<!-- Translators: In this string, 'Kick' is a verb. -->
<attribute name="label" translatable="yes">_Kick</attribute>
<attribute name="action">sender-avatar.kick</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<!-- Translators: In this string, 'Ban' is a verb. -->
<attribute name="label" translatable="yes">_Ban</attribute>
<attribute name="action">sender-avatar.ban</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Un_ban</attribute>
<attribute name="action">sender-avatar.unban</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Remove Messages</attribute>
<attribute name="action">sender-avatar.remove-messages</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">I_gnore</attribute>
<attribute name="action">sender-avatar.ignore</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Stop I_gnoring</attribute>
<attribute name="action">sender-avatar.stop-ignoring</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">_View Details</attribute>
<attribute name="action">sender-avatar.view-details</attribute>
</item>
</section>
</menu>
<template class="ContentRoomHistory" parent="AdwBin">
<property name="vexpand">True</property>
<property name="hexpand">True</property>

View File

@ -1,817 +0,0 @@
use std::slice;
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::{gettext, ngettext};
use gtk::{CompositeTemplate, gdk, glib, glib::clone};
use ruma::{
Int, OwnedEventId,
events::room::power_levels::{PowerLevelUserAction, UserPowerLevel},
};
use crate::{
Window,
components::{
Avatar, RoomMemberDestructiveAction, UserProfileDialog, confirm_mute_room_member_dialog,
confirm_room_member_destructive_action_dialog,
},
gettext_f,
prelude::*,
session::{
model::{Member, MemberRole, Membership, User},
view::content::RoomHistory,
},
toast,
utils::{BoundObject, key_bindings},
};
mod imp {
use std::cell::{Cell, RefCell};
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/sender_avatar/mod.ui"
)]
#[properties(wrapper_type = super::SenderAvatar)]
pub struct SenderAvatar {
#[template_child]
avatar: TemplateChild<Avatar>,
#[template_child]
user_id_btn: TemplateChild<gtk::Button>,
/// Whether this avatar is active.
///
/// This avatar is active when the popover is displayed.
#[property(get)]
active: Cell<bool>,
direct_member_handler: RefCell<Option<glib::SignalHandlerId>>,
permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
/// The displayed member.
#[property(get, set = Self::set_sender, explicit_notify, nullable)]
sender: BoundObject<Member>,
/// The popover of this avatar.
popover: BoundObject<gtk::PopoverMenu>,
}
#[glib::object_subclass]
impl ObjectSubclass for SenderAvatar {
const NAME: &'static str = "ContentSenderAvatar";
type Type = super::SenderAvatar;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
klass.set_layout_manager_type::<gtk::BinLayout>();
klass.set_css_name("sender-avatar");
klass.set_accessible_role(gtk::AccessibleRole::ToggleButton);
klass.install_action("sender-avatar.copy-user-id", None, |obj, _, _| {
if let Some(popover) = obj.imp().popover.obj() {
popover.popdown();
}
let Some(sender) = obj.sender() else {
return;
};
obj.clipboard().set_text(sender.user_id().as_str());
toast!(obj, gettext("Matrix user ID copied to clipboard"));
});
klass.install_action("sender-avatar.mention", None, |obj, _, _| {
obj.imp().mention();
});
klass.install_action_async(
"sender-avatar.open-direct-chat",
None,
|obj, _, _| async move {
obj.imp().open_direct_chat().await;
},
);
klass.install_action("sender-avatar.permalink", None, |obj, _, _| {
let Some(sender) = obj.sender() else {
return;
};
obj.clipboard()
.set_text(&sender.matrix_to_uri().to_string());
toast!(obj, gettext("Link copied to clipboard"));
});
klass.install_action_async("sender-avatar.invite", None, |obj, _, _| async move {
obj.imp().invite().await;
});
klass.install_action_async(
"sender-avatar.revoke-invite",
None,
|obj, _, _| async move {
obj.imp().kick().await;
},
);
klass.install_action_async("sender-avatar.mute", None, |obj, _, _| async move {
obj.imp().toggle_muted().await;
});
klass.install_action_async("sender-avatar.unmute", None, |obj, _, _| async move {
obj.imp().toggle_muted().await;
});
klass.install_action_async("sender-avatar.kick", None, |obj, _, _| async move {
obj.imp().kick().await;
});
klass.install_action_async("sender-avatar.ban", None, |obj, _, _| async move {
obj.imp().ban().await;
});
klass.install_action_async("sender-avatar.unban", None, |obj, _, _| async move {
obj.imp().unban().await;
});
klass.install_action_async(
"sender-avatar.remove-messages",
None,
|obj, _, _| async move {
obj.imp().remove_messages().await;
},
);
klass.install_action_async("sender-avatar.ignore", None, |obj, _, _| async move {
obj.imp().toggle_ignored().await;
});
klass.install_action_async(
"sender-avatar.stop-ignoring",
None,
|obj, _, _| async move {
obj.imp().toggle_ignored().await;
},
);
klass.install_action("sender-avatar.view-details", None, |obj, _, _| {
obj.imp().view_details();
});
klass.install_action("sender-avatar.activate", None, |obj, _, _| {
obj.imp().show_popover(1, 0.0, 0.0);
});
key_bindings::add_activate_bindings(klass, "sender-avatar.activate");
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for SenderAvatar {
fn constructed(&self) {
self.parent_constructed();
self.set_pressed_state(false);
}
fn dispose(&self) {
self.disconnect_signals();
if let Some(popover) = self.popover.obj() {
popover.unparent();
popover.remove_child(&*self.user_id_btn);
}
self.avatar.unparent();
}
}
impl WidgetImpl for SenderAvatar {}
impl AccessibleImpl for SenderAvatar {
fn first_accessible_child(&self) -> Option<gtk::Accessible> {
// Hide the children in the a11y tree.
None
}
}
#[gtk::template_callbacks]
impl SenderAvatar {
/// Set the displayed member.
fn set_sender(&self, sender: Option<Member>) {
let prev_sender = self.sender.obj();
if prev_sender == sender {
return;
}
self.disconnect_signals();
if let Some(sender) = sender {
let room = sender.room();
let direct_member_handler = room.connect_direct_member_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
self.direct_member_handler
.replace(Some(direct_member_handler));
let permissions_handler = room.permissions().connect_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
self.permissions_handler.replace(Some(permissions_handler));
let display_name_handler = sender.connect_display_name_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_accessible_label();
}
));
let membership_handler = sender.connect_membership_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
let power_level_handler = sender.connect_power_level_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
let is_ignored_handler = sender.connect_is_ignored_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
self.sender.set(
sender,
vec![
display_name_handler,
membership_handler,
power_level_handler,
is_ignored_handler,
],
);
self.update_accessible_label();
self.update_actions();
}
self.obj().notify_sender();
}
/// Disconnect all the signals.
fn disconnect_signals(&self) {
if let Some(sender) = self.sender.obj() {
let room = sender.room();
if let Some(handler) = self.direct_member_handler.take() {
room.disconnect(handler);
}
if let Some(handler) = self.permissions_handler.take() {
room.permissions().disconnect(handler);
}
}
self.sender.disconnect_signals();
}
/// Update the accessible label for the current sender.
fn update_accessible_label(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let label = gettext_f("{user}s avatar", &[("user", &sender.display_name())]);
self.obj()
.update_property(&[gtk::accessible::Property::Label(&label)]);
}
/// Update the actions for the current state.
fn update_actions(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let obj = self.obj();
let room = sender.room();
let is_direct_chat = room.direct_member().is_some();
let permissions = room.permissions();
let membership = sender.membership();
let sender_id = sender.user_id();
let is_own_user = sender.is_own_user();
let power_level = sender.power_level();
let role = permissions.role(power_level);
obj.action_set_enabled(
"sender-avatar.mention",
!is_own_user && membership == Membership::Join && permissions.can_send_message(),
);
obj.action_set_enabled(
"sender-avatar.open-direct-chat",
!is_direct_chat && !is_own_user,
);
obj.action_set_enabled(
"sender-avatar.invite",
!is_own_user
&& matches!(membership, Membership::Leave | Membership::Knock)
&& permissions.can_do_to_user(sender_id, PowerLevelUserAction::Kick),
);
obj.action_set_enabled(
"sender-avatar.revoke-invite",
!is_own_user
&& membership == Membership::Invite
&& permissions.can_do_to_user(sender_id, PowerLevelUserAction::Kick),
);
obj.action_set_enabled(
"sender-avatar.mute",
!is_own_user
&& role != MemberRole::Muted
&& permissions.default_power_level() > permissions.mute_power_level()
&& permissions
.can_do_to_user(sender_id, PowerLevelUserAction::ChangePowerLevel),
);
obj.action_set_enabled(
"sender-avatar.unmute",
!is_own_user
&& role == MemberRole::Muted
&& permissions.default_power_level() > permissions.mute_power_level()
&& permissions
.can_do_to_user(sender_id, PowerLevelUserAction::ChangePowerLevel),
);
obj.action_set_enabled(
"sender-avatar.kick",
!is_own_user
&& membership == Membership::Join
&& permissions.can_do_to_user(sender_id, PowerLevelUserAction::Kick),
);
obj.action_set_enabled(
"sender-avatar.ban",
!is_own_user
&& membership != Membership::Ban
&& permissions.can_do_to_user(sender_id, PowerLevelUserAction::Ban),
);
obj.action_set_enabled(
"sender-avatar.unban",
!is_own_user
&& membership == Membership::Ban
&& permissions.can_do_to_user(sender_id, PowerLevelUserAction::Unban),
);
obj.action_set_enabled(
"sender-avatar.remove-messages",
!is_own_user && permissions.can_redact_other(),
);
obj.action_set_enabled("sender-avatar.ignore", !is_own_user && !sender.is_ignored());
obj.action_set_enabled(
"sender-avatar.stop-ignoring",
!is_own_user && sender.is_ignored(),
);
}
/// Set the popover of this avatar.
fn set_popover(&self, popover: Option<gtk::PopoverMenu>) {
let old_popover = self.popover.obj();
if old_popover == popover {
return;
}
// Reset the state.
if let Some(popover) = old_popover {
popover.unparent();
popover.remove_child(&*self.user_id_btn);
}
self.popover.disconnect_signals();
self.set_active(false);
if let Some(popover) = popover {
// We need to remove the popover from the previous button, if any.
if popover.parent().is_some() {
popover.unparent();
}
let parent_handler = popover.connect_parent_notify(clone!(
#[weak(rename_to = imp)]
self,
move |popover| {
if popover.parent().is_none_or(|w| w != *imp.obj()) {
imp.popover.disconnect_signals();
popover.remove_child(&*imp.user_id_btn);
}
}
));
let closed_handler = popover.connect_closed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.set_active(false);
}
));
popover.add_child(&*self.user_id_btn, "user-id");
popover.set_parent(&*self.obj());
self.popover
.set(popover, vec![parent_handler, closed_handler]);
}
}
/// Set whether this avatar is active.
fn set_active(&self, active: bool) {
if self.active.get() == active {
return;
}
self.active.set(active);
self.obj().notify_active();
self.set_pressed_state(active);
}
/// Set the CSS and a11 states.
fn set_pressed_state(&self, pressed: bool) {
let obj = self.obj();
if pressed {
obj.set_state_flags(gtk::StateFlags::CHECKED, false);
} else {
obj.unset_state_flags(gtk::StateFlags::CHECKED);
}
let tristate = if pressed {
gtk::AccessibleTristate::True
} else {
gtk::AccessibleTristate::False
};
obj.update_state(&[gtk::accessible::State::Pressed(tristate)]);
}
/// The `RoomHistory` that is an ancestor of this avatar.
fn room_history(&self) -> Option<RoomHistory> {
self.obj()
.ancestor(RoomHistory::static_type())
.and_downcast()
}
/// Handle a click on the container.
///
/// Shows a popover with the room member menu.
#[template_callback]
fn show_popover(&self, _n_press: i32, x: f64, y: f64) {
let Some(room_history) = self.room_history() else {
return;
};
self.set_active(true);
let popover = room_history.sender_context_menu();
self.set_popover(Some(popover.clone()));
popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 0, 0)));
popover.popup();
}
/// Add a mention of the sender to the message composer.
fn mention(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let Some(room_history) = self.room_history() else {
return;
};
room_history.message_toolbar().mention_member(&sender);
}
/// View the sender details.
fn view_details(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let dialog = UserProfileDialog::new();
dialog.set_room_member(sender);
dialog.present(Some(&*self.obj()));
}
/// Open a direct chat with the current sender.
///
/// If one doesn't exist already, it is created.
async fn open_direct_chat(&self) {
let Some(sender) = self.sender.obj().and_upcast::<User>() else {
return;
};
let obj = self.obj();
let room = if let Some(room) = sender.direct_chat() {
room
} else {
toast!(obj, &gettext("Creating a new Direct Chat…"));
if let Ok(room) = sender.get_or_create_direct_chat().await {
room
} else {
toast!(obj, &gettext("Could not create a new Direct Chat"));
return;
}
};
let Some(main_window) = obj.root().and_downcast::<Window>() else {
return;
};
main_window.session_view().select_room(room);
}
/// Invite the sender to the room.
async fn invite(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let obj = self.obj();
toast!(obj, gettext("Inviting user…"));
let room = sender.room();
let user_id = sender.user_id().clone();
if room.invite(&[user_id]).await.is_err() {
toast!(obj, gettext("Could not invite user"));
}
}
/// Kick the user from the room.
async fn kick(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let obj = self.obj();
let Some(response) = confirm_room_member_destructive_action_dialog(
&sender,
RoomMemberDestructiveAction::Kick,
&*obj,
)
.await
else {
return;
};
let membership = sender.membership();
let label = match membership {
Membership::Invite => gettext("Revoking invite…"),
_ => gettext("Kicking user…"),
};
toast!(obj, label);
let room = sender.room();
let user_id = sender.user_id().clone();
if room.kick(&[(user_id, response.reason)]).await.is_err() {
let error = match membership {
Membership::Invite => gettext("Could not revoke invite of user"),
_ => gettext("Could not kick user"),
};
toast!(obj, error);
}
}
/// (Un)mute the user in the room.
async fn toggle_muted(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let UserPowerLevel::Int(old_power_level) = sender.power_level() else {
// We cannot mute someone with an infinite power level.
return;
};
let old_power_level = i64::from(old_power_level);
let obj = self.obj();
let permissions = sender.room().permissions();
// Warn if user is muted but was not before.
let mute_power_level = permissions.mute_power_level();
let mute = old_power_level > mute_power_level;
if mute && !confirm_mute_room_member_dialog(slice::from_ref(&sender), &*obj).await {
return;
}
let user_id = sender.user_id().clone();
let (new_power_level, text) = if mute {
(mute_power_level, gettext("Muting member…"))
} else {
(
permissions.default_power_level(),
gettext("Unmuting member…"),
)
};
toast!(obj, text);
let text = if permissions
.set_user_power_level(user_id, Int::new_saturating(new_power_level))
.await
.is_ok()
{
if mute {
gettext("Member muted")
} else {
gettext("Member unmuted")
}
} else if mute {
gettext("Could not mute member")
} else {
gettext("Could not unmute member")
};
toast!(obj, text);
}
/// Ban the room member.
async fn ban(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let obj = self.obj();
let permissions = sender.room().permissions();
let redactable_events = if permissions.can_redact_other() {
sender.redactable_events()
} else {
vec![]
};
let Some(response) = confirm_room_member_destructive_action_dialog(
&sender,
RoomMemberDestructiveAction::Ban(redactable_events.len()),
&*obj,
)
.await
else {
return;
};
toast!(obj, gettext("Banning user…"));
let room = sender.room();
let user_id = sender.user_id().clone();
if room
.ban(&[(user_id, response.reason.clone())])
.await
.is_err()
{
toast!(obj, gettext("Could not ban user"));
}
if response.remove_events {
self.remove_known_messages_inner(&sender, redactable_events, response.reason)
.await;
}
}
/// Unban the room member.
async fn unban(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let obj = self.obj();
toast!(obj, gettext("Unbanning user…"));
let room = sender.room();
let user_id = sender.user_id().clone();
if room.unban(&[(user_id, None)]).await.is_err() {
toast!(obj, gettext("Could not unban user"));
}
}
/// Remove the known events of the room member.
async fn remove_messages(&self) {
let Some(sender) = self.sender.obj() else {
return;
};
let redactable_events = sender.redactable_events();
let Some(response) = confirm_room_member_destructive_action_dialog(
&sender,
RoomMemberDestructiveAction::RemoveMessages(redactable_events.len()),
&*self.obj(),
)
.await
else {
return;
};
self.remove_known_messages_inner(&sender, redactable_events, response.reason)
.await;
}
async fn remove_known_messages_inner(
&self,
sender: &Member,
events: Vec<OwnedEventId>,
reason: Option<String>,
) {
let obj = self.obj();
let n = u32::try_from(events.len()).unwrap_or(u32::MAX);
toast!(
obj,
ngettext(
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name.
"Removing 1 message sent by the user…",
"Removing {n} messages sent by the user…",
n,
),
n,
);
let room = sender.room();
if let Err(failed_events) = room.redact(&events, reason).await {
let n = u32::try_from(failed_events.len()).unwrap_or(u32::MAX);
toast!(
obj,
ngettext(
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name.
"Could not remove 1 message sent by the user",
"Could not remove {n} messages sent by the user",
n,
),
n,
);
}
}
/// Toggle whether the user is ignored or not.
async fn toggle_ignored(&self) {
let Some(sender) = self.sender.obj().and_upcast::<User>() else {
return;
};
let obj = self.obj();
let is_ignored = sender.is_ignored();
let label = if is_ignored {
gettext("Stop ignoring user…")
} else {
gettext("Ignoring user…")
};
toast!(obj, label);
if is_ignored {
if sender.stop_ignoring().await.is_err() {
toast!(obj, gettext("Could not stop ignoring user"));
}
} else if sender.ignore().await.is_err() {
toast!(obj, gettext("Could not ignore user"));
}
}
}
}
glib::wrapper! {
/// An avatar with a popover menu for room members.
pub struct SenderAvatar(ObjectSubclass<imp::SenderAvatar>)
@extends gtk::Widget, @implements gtk::Accessible;
}
impl SenderAvatar {
pub fn new() -> Self {
glib::Object::new()
}
}

View File

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="ContentSenderAvatar" parent="GtkWidget">
<accessibility>
<property name="description" translatable="yes">Open Sender Context Menu</property>
<property name="has-popup">true</property>
</accessibility>
<property name="focusable">true</property>
<property name="valign">start</property>
<child>
<object class="Avatar" id="avatar">
<property name="size">36</property>
<property name="accessible-role">presentation</property>
<binding name="data">
<lookup name="avatar-data">
<lookup name="sender">ContentSenderAvatar</lookup>
</lookup>
</binding>
</object>
</child>
<child>
<object class="GtkGestureClick">
<signal name="released" handler="show_popover" swapped="true"/>
</object>
</child>
</template>
<object class="GtkButton" id="user_id_btn">
<property name="tooltip-text" translatable="yes">Copy Matrix User ID</property>
<property name="action-name">sender-avatar.copy-user-id</property>
<style>
<class name="flat"/>
<class name="text-button" />
</style>
<property name="child">
<object class="GtkLabel">
<binding name="label">
<lookup name="user-id-string">
<lookup name="sender">ContentSenderAvatar</lookup>
</lookup>
</binding>
<property name="ellipsize">end</property>
<property name="xalign">0.0</property>
</object>
</property>
</object>
</interface>

View File

@ -15,6 +15,9 @@
<property name="halign">start</property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>

View File

@ -49,7 +49,7 @@
<property name="xalign">0</property>
<style>
<class name="heading"/>
</style>
</style>
</object>
</child>
<child>
@ -159,7 +159,7 @@
<property name="xalign">0</property>
<style>
<class name="heading"/>
</style>
</style>
</object>
</child>
<child>
@ -249,6 +249,9 @@
<property name="right-margin">12</property>
<property name="top-margin">12</property>
<property name="bottom-margin">12</property>
<style>
<class name="monospace"/>
</style>
</object>
</property>
</object>

View File

@ -130,7 +130,6 @@
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/read_receipts_list/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/read_receipts_list/read_receipts_popover.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/sender_avatar/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state/creation.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state/group_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state/row.ui</file>

View File

@ -28,7 +28,10 @@ pub(crate) use queue::{IMAGE_QUEUE, ImageRequestPriority};
use super::{FrameDimensions, MediaFileError};
use crate::{
DISABLE_GLYCIN_SANDBOX, RUNTIME, components::AnimatedImagePaintable, spawn_tokio, utils::File,
DISABLE_GLYCIN_SANDBOX, RUNTIME,
components::AnimatedImagePaintable,
spawn_tokio,
utils::{File, TokioDrop, save_data_to_tmp_file},
};
/// The maximum dimensions of a thumbnail in the timeline.
@ -64,63 +67,111 @@ const THUMBNAIL_DIMENSIONS_THRESHOLD: u32 = 200;
/// [supported image formats of glycin]: https://gitlab.gnome.org/GNOME/glycin/-/tree/main?ref_type=heads#supported-image-formats
const SUPPORTED_ANIMATED_IMAGE_MIME_TYPES: &[&str] = &["image/gif", "image/png", "image/webp"];
/// Get an image loader for the given file.
async fn image_loader(file: gio::File) -> Result<glycin::Image<'static>, glycin::ErrorCtx> {
let mut loader = glycin::Loader::new(file);
if DISABLE_GLYCIN_SANDBOX {
loader.sandbox_selector(glycin::SandboxSelector::NotSandboxed);
}
spawn_tokio!(async move { loader.load().await })
.await
.unwrap()
/// The source for decoding an image.
enum ImageDecoderSource {
/// The bytes containing the encoded image.
Data(Vec<u8>),
/// The file containing the encoded image.
File(File),
}
/// Load the given file as an image into a `GdkPaintable`.
///
/// Set `request_dimensions` if the image will be shown at specific dimensions.
/// To show the image at its natural size, set it to `None`.
async fn load_image(
file: File,
request_dimensions: Option<FrameDimensions>,
) -> Result<Image, glycin::ErrorCtx> {
let image_loader = image_loader(file.as_gfile()).await?;
impl ImageDecoderSource {
/// The maximum size of the `Data` variant. This is 1 MB.
const MAX_DATA_SIZE: usize = 1_048_576;
let frame_request = request_dimensions.map(|request| {
let image_info = image_loader.info();
let original_dimensions = FrameDimensions {
width: image_info.width,
height: image_info.height,
};
original_dimensions.to_image_loader_request(request)
});
spawn_tokio!(async move {
let first_frame = if let Some(frame_request) = frame_request {
image_loader.specific_frame(frame_request).await?
/// Construct an `ImageSource` from the given bytes.
///
/// If the size of the bytes are too big to be kept in memory, they are
/// written to a temporary file.
async fn with_bytes(bytes: Vec<u8>) -> Result<Self, MediaFileError> {
if bytes.len() > Self::MAX_DATA_SIZE {
Ok(Self::File(save_data_to_tmp_file(bytes).await?))
} else {
image_loader.next_frame().await?
Ok(Self::Data(bytes))
}
}
/// Convert this image source into a loader.
///
/// Returns the created loader, and the image file, if any.
fn into_loader(self) -> (glycin::Loader, Option<File>) {
let (mut loader, file) = match self {
Self::Data(bytes) => (glycin::Loader::new_vec(bytes), None),
Self::File(file) => (glycin::Loader::new(file.as_gfile()), Some(file)),
};
Ok(Image {
file,
loader: image_loader.into(),
first_frame: first_frame.into(),
if DISABLE_GLYCIN_SANDBOX {
loader.sandbox_selector(glycin::SandboxSelector::NotSandboxed);
}
(loader, file)
}
/// Decode this image source into an [`Image`].
///
/// Set `request_dimensions` if the image will be shown at specific
/// dimensions. To show the image at its natural size, set it to `None`.
async fn decode_image(
self,
request_dimensions: Option<FrameDimensions>,
) -> Result<Image, ImageError> {
let (loader, file) = self.into_loader();
let decoder = spawn_tokio!(async move { loader.load().await })
.await
.expect("task was not aborted")?;
let frame_request = request_dimensions.map(|request| {
let image_details = decoder.details();
let original_dimensions = FrameDimensions {
width: image_details.width(),
height: image_details.height(),
};
original_dimensions.to_image_loader_request(request)
});
spawn_tokio!(async move {
let first_frame = if let Some(frame_request) = frame_request {
decoder.specific_frame(frame_request).await?
} else {
decoder.next_frame().await?
};
Ok(Image {
file,
decoder: TokioDrop::new(decoder).into(),
first_frame: first_frame.into(),
})
})
})
.await
.expect("task was not aborted")
.await
.expect("task was not aborted")
}
}
impl From<File> for ImageDecoderSource {
fn from(value: File) -> Self {
Self::File(value)
}
}
impl From<gio::File> for ImageDecoderSource {
fn from(value: gio::File) -> Self {
Self::File(value.into())
}
}
/// An image that was just loaded.
#[derive(Clone)]
pub(crate) struct Image {
/// The file of the image.
file: File,
/// The image loader.
loader: Arc<glycin::Image<'static>>,
/// The file containing the image, if any.
///
/// We need to keep a strong reference to the temporary file or it will be
/// destroyed.
file: Option<File>,
/// The image decoder.
decoder: Arc<TokioDrop<glycin::Image>>,
/// The first frame of the image.
first_frame: Arc<glycin::Frame>,
}
@ -134,7 +185,7 @@ impl fmt::Debug for Image {
impl From<Image> for gdk::Paintable {
fn from(value: Image) -> Self {
if value.first_frame.delay().is_some() {
AnimatedImagePaintable::new(value.file, value.loader, value.first_frame).upcast()
AnimatedImagePaintable::new(value.decoder, value.first_frame, value.file).upcast()
} else {
value.first_frame.texture().upcast()
}
@ -157,9 +208,14 @@ impl ImageInfoLoader {
async fn into_first_frame(self) -> Option<Frame> {
match self {
Self::File(file) => {
let image_loader = image_loader(file).await.ok()?;
let handle = spawn_tokio!(async move { image_loader.next_frame().await });
Some(Frame::Glycin(handle.await.unwrap().ok()?))
let (loader, _) = ImageDecoderSource::from(file).into_loader();
let frame = spawn_tokio!(async move { loader.load().await?.next_frame().await })
.await
.expect("task was not aborted")
.ok()?;
Some(Frame::Glycin(frame))
}
Self::Texture(texture) => Some(Frame::Texture(texture)),
}

View File

@ -19,13 +19,12 @@ use tokio::{
};
use tracing::{debug, warn};
use super::{Image, ImageError, load_image};
use super::{Image, ImageDecoderSource, ImageError};
use crate::{
spawn_tokio,
utils::{
File,
media::{FrameDimensions, MediaFileError},
save_data_to_tmp_file,
},
};
@ -156,7 +155,7 @@ impl ImageRequestQueueInner {
}
/// Add the given request to the queue.
fn add_request(&mut self, request_id: ImageRequestId, request: ImageRequest) {
fn queue_request(&mut self, request_id: ImageRequestId, request: ImageRequest) {
let is_limit_reached = self.is_limit_reached();
if !is_limit_reached || request.priority == ImageRequestPriority::High {
// Spawn the request right away.
@ -175,6 +174,31 @@ impl ImageRequestQueueInner {
self.requests.insert(request_id, request);
}
/// Add the given image request.
///
/// If another request for the same image already exists, this will reuse
/// the same request.
fn add_request(
&mut self,
inner: ImageLoaderRequest,
priority: ImageRequestPriority,
) -> ImageRequestHandle {
let request_id = inner.source.request_id();
// If the request already exists, use the existing one.
if let Some(request) = self.requests.get(&request_id) {
let result_receiver = request.result_sender.subscribe();
return ImageRequestHandle::new(result_receiver);
}
// Build and add the request.
let (request, result_receiver) = ImageRequest::new(inner, priority);
self.queue_request(request_id.clone(), request);
ImageRequestHandle::new(result_receiver)
}
/// Add a request to download an image.
///
/// If another request for the same image already exists, this will reuse
@ -186,24 +210,13 @@ impl ImageRequestQueueInner {
dimensions: Option<FrameDimensions>,
priority: ImageRequestPriority,
) -> ImageRequestHandle {
let data = DownloadRequestData {
client,
settings,
dimensions,
};
let request_id = data.request_id();
// If the request already exists, use the existing one.
if let Some(request) = self.requests.get(&request_id) {
let result_receiver = request.result_sender.subscribe();
return ImageRequestHandle::new(result_receiver);
}
// Build and add the request.
let (request, result_receiver) = ImageRequest::new(data, priority);
self.add_request(request_id.clone(), request);
ImageRequestHandle::new(result_receiver)
self.add_request(
ImageLoaderRequest {
source: ImageRequestSource::Download(DownloadRequest { client, settings }),
dimensions,
},
priority,
)
}
/// Add a request to load an image from a file.
@ -215,23 +228,15 @@ impl ImageRequestQueueInner {
file: File,
dimensions: Option<FrameDimensions>,
) -> ImageRequestHandle {
let data = FileRequestData { file, dimensions };
let request_id = data.request_id();
// If the request already exists, use the existing one.
if let Some(request) = self.requests.get(&request_id) {
let result_receiver = request.result_sender.subscribe();
return ImageRequestHandle::new(result_receiver);
}
// Build and add the request.
// Always use high priority because file requests should always be for
// previewing a local image.
let (request, result_receiver) = ImageRequest::new(data, ImageRequestPriority::High);
self.add_request(request_id.clone(), request);
ImageRequestHandle::new(result_receiver)
self.add_request(
ImageLoaderRequest {
source: ImageRequestSource::File(file),
dimensions,
},
ImageRequestPriority::High,
)
}
/// Mark the request with the given ID as stalled.
@ -335,8 +340,8 @@ impl ImageRequestQueueInner {
/// A request for an image.
struct ImageRequest {
/// The data of the request.
data: ImageRequestData,
/// The request to the image loader.
inner: ImageLoaderRequest,
/// The priority of the request.
priority: ImageRequestPriority,
/// The sender of the channel to use to send the result.
@ -352,13 +357,13 @@ struct ImageRequest {
impl ImageRequest {
/// Construct an image request with the given data and priority.
fn new(
data: impl Into<ImageRequestData>,
inner: ImageLoaderRequest,
priority: ImageRequestPriority,
) -> (Self, broadcast::Receiver<Result<Image, ImageError>>) {
let (result_sender, result_receiver) = broadcast::channel(1);
(
Self {
data: data.into(),
inner,
priority,
result_sender,
retries_count: 0,
@ -379,14 +384,14 @@ impl ImageRequest {
/// Spawn this request.
fn spawn(&self) {
let data = self.data.clone();
let inner = self.inner.clone();
let result_sender = self.result_sender.clone();
let retries_count = self.retries_count;
let task_handle = self.task_handle.clone();
let stalled_timeout_source = self.stalled_timeout_source.clone();
let abort_handle = spawn_tokio!(async move {
let request_id = data.request_id();
let request_id = inner.source.request_id();
let stalled_timeout_source_clone = stalled_timeout_source.clone();
let request_id_clone = request_id.clone();
@ -404,7 +409,7 @@ impl ImageRequest {
source.remove();
}
let result = data.await;
let result = inner.await;
// Cancel the timeout.
if let Ok(Some(source)) = stalled_timeout_source.lock().map(|mut s| s.take()) {
@ -451,7 +456,7 @@ impl Drop for ImageRequest {
handle.abort();
// Broadcast that the request was aborted.
let request_id = self.data.request_id();
let request_id = self.inner.source.request_id();
let result_sender = self.result_sender.clone();
spawn_tokio!(async move {
if let Err(error) = result_sender.send(Err(ImageError::Aborted)) {
@ -462,26 +467,17 @@ impl Drop for ImageRequest {
}
}
/// The data of a request to download an image.
/// A request to download an image.
#[derive(Clone)]
struct DownloadRequestData {
struct DownloadRequest {
/// The Matrix client to use to make the request.
client: Client,
/// The settings of the request.
settings: MediaRequestParameters,
/// The dimensions to request.
dimensions: Option<FrameDimensions>,
}
impl DownloadRequestData {
/// The ID of the image request with this data.
fn request_id(&self) -> ImageRequestId {
ImageRequestId::Download(self.settings.unique_key())
}
}
impl IntoFuture for DownloadRequestData {
type Output = Result<File, MediaFileError>;
impl IntoFuture for DownloadRequest {
type Output = Result<ImageDecoderSource, MediaFileError>;
type IntoFuture = BoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
@ -491,155 +487,76 @@ impl IntoFuture for DownloadRequestData {
Box::pin(async move {
let media = client.media();
let data = match media.get_media_content(&settings, true).await {
Ok(data) => data,
Err(error) => {
return Err(MediaFileError::from(error));
}
};
let data = media
.get_media_content(&settings, true)
.await
.map_err(MediaFileError::from)?;
let file = ImageDecoderSource::with_bytes(data).await?;
let file = save_data_to_tmp_file(data).await?;
Ok(file)
})
}
}
/// The data of a request to load an image file into a paintable.
/// A request to the image loader.
#[derive(Clone)]
struct FileRequestData {
/// The image file to load.
file: File,
struct ImageLoaderRequest {
/// The source of the image data.
source: ImageRequestSource,
/// The dimensions to request.
dimensions: Option<FrameDimensions>,
}
impl FileRequestData {
/// The ID of the image request with this data.
fn request_id(&self) -> ImageRequestId {
ImageRequestId::File(self.file.path().expect("file has a path"))
}
}
impl IntoFuture for FileRequestData {
type Output = Result<Image, glycin::ErrorCtx>;
type IntoFuture = BoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
let Self { file, dimensions } = self;
Box::pin(async move { load_image(file, dimensions).await })
}
}
/// The data of an image request.
#[derive(Clone)]
enum ImageRequestData {
/// The data for a download request.
Download {
/// The data to download the image.
download_data: DownloadRequestData,
/// The data to load the image into a paintable, after it was
/// downloaded.
file_data: Option<FileRequestData>,
},
/// The data for a file request.
File(FileRequestData),
}
impl ImageRequestData {
/// The ID of the image request with this data.
fn request_id(&self) -> ImageRequestId {
match self {
ImageRequestData::Download { download_data, .. } => download_data.request_id(),
ImageRequestData::File(file_data) => file_data.request_id(),
}
}
/// The data for the next request with this image request data.
fn into_next_request_data(self) -> DownloadOrFileRequestData {
match self {
Self::Download {
download_data,
file_data,
} => {
if let Some(file_data) = file_data {
file_data.into()
} else {
download_data.into()
}
}
Self::File(file_data) => file_data.into(),
}
}
}
impl IntoFuture for ImageRequestData {
impl IntoFuture for ImageLoaderRequest {
type Output = Result<Image, ImageError>;
type IntoFuture = BoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
let file_data = match self.into_next_request_data() {
DownloadOrFileRequestData::Download(download_data) => {
let dimensions = download_data.dimensions;
// Load the data from the source.
let source = self.source.try_into_decoder_source().await?;
// Download the image to a file.
match download_data.await {
Ok(file) => FileRequestData { file, dimensions },
Err(error) => {
warn!("Could not retrieve image: {error}");
return Err(error.into());
}
}
}
DownloadOrFileRequestData::File(file_data) => file_data,
};
// Load the image from the file.
match file_data.clone().await {
Ok(image) => Ok(image),
Err(error) => {
warn!("Could not load image from file: {error}");
Err(error.into())
}
}
// Decode the image from the data.
source
.decode_image(self.dimensions)
.await
.inspect_err(|error| warn!("Could not decode image: {error}"))
})
}
}
impl From<DownloadRequestData> for ImageRequestData {
fn from(download_data: DownloadRequestData) -> Self {
Self::Download {
download_data,
file_data: None,
/// The source for an image request.
#[derive(Clone)]
enum ImageRequestSource {
/// The image must be downloaded from the media cache or the server.
Download(DownloadRequest),
/// The image is in the given file.
File(File),
}
impl ImageRequestSource {
/// The ID of the image request with this source.
fn request_id(&self) -> ImageRequestId {
match self {
Self::Download(download_request) => {
ImageRequestId::Download(download_request.settings.unique_key())
}
Self::File(file) => ImageRequestId::File(file.path().expect("file should have a path")),
}
}
}
impl From<FileRequestData> for ImageRequestData {
fn from(value: FileRequestData) -> Self {
Self::File(value)
}
}
/// The data of a download request or a file request.
#[derive(Clone)]
enum DownloadOrFileRequestData {
/// The data for a download request.
Download(DownloadRequestData),
/// The data for a file request.
File(FileRequestData),
}
impl From<DownloadRequestData> for DownloadOrFileRequestData {
fn from(download_data: DownloadRequestData) -> Self {
Self::Download(download_data)
}
}
impl From<FileRequestData> for DownloadOrFileRequestData {
fn from(value: FileRequestData) -> Self {
Self::File(value)
/// Try to download the image, if necessary.
async fn try_into_decoder_source(self) -> Result<ImageDecoderSource, ImageError> {
match self {
Self::Download(download_request) => {
// Download the image.
Ok(download_request
.await
.inspect_err(|error| warn!("Could not retrieve image: {error}"))?)
}
Self::File(data) => Ok(data.into()),
}
}
}

View File

@ -3,8 +3,9 @@
use std::{
borrow::Cow,
cell::{Cell, OnceCell, RefCell},
fmt, fs, io,
io::Write,
fmt, fs,
io::{self, Write},
ops::Deref,
path::{Path, PathBuf},
rc::{Rc, Weak},
sync::{Arc, LazyLock},
@ -384,36 +385,30 @@ impl<T> AsyncAction<T> {
}
}
/// A type that requires the tokio runtime to be running when dropped.
///
/// This is basically usable as a [`OnceCell`].
/// A wrapper that requires the tokio runtime to be running when dropped.
#[derive(Debug, Clone)]
pub struct TokioDrop<T>(OnceCell<T>);
pub struct TokioDrop<T>(Option<T>);
impl<T> TokioDrop<T> {
/// Create a new empty `TokioDrop`;
pub fn new() -> Self {
Self::default()
}
/// Gets a reference to the underlying value.
///
/// Returns `None` if the cell is empty.
pub fn get(&self) -> Option<&T> {
self.0.get()
}
/// Sets the contents of this cell to `value`.
///
/// Returns `Ok(())` if the cell was empty and `Err(value)` if it was full.
pub(crate) fn set(&self, value: T) -> Result<(), T> {
self.0.set(value)
/// Create a new `TokioDrop` wrapping the given type.
pub fn new(value: T) -> Self {
Self(Some(value))
}
}
impl<T> Default for TokioDrop<T> {
fn default() -> Self {
Self(Default::default())
impl<T> Deref for TokioDrop<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
self.0
.as_ref()
.expect("TokioDrop should always contain a value")
}
}
impl<T> From<T> for TokioDrop<T> {
fn from(value: T) -> Self {
Self::new(value)
}
}
@ -421,35 +416,12 @@ impl<T> Drop for TokioDrop<T> {
fn drop(&mut self) {
let _guard = RUNTIME.enter();
if let Some(inner) = self.0.take() {
drop(inner);
if let Some(value) = self.0.take() {
drop(value);
}
}
}
impl<T: glib::property::Property> glib::property::Property for TokioDrop<T> {
type Value = T::Value;
}
impl<T> glib::property::PropertyGet for TokioDrop<T> {
type Value = T;
fn get<R, F: Fn(&Self::Value) -> R>(&self, f: F) -> R {
f(self.get().unwrap())
}
}
impl<T> glib::property::PropertySet for TokioDrop<T> {
type SetValue = T;
fn set(&self, v: Self::SetValue) {
assert!(
self.set(v).is_ok(),
"TokioDrop value was already initialized"
);
}
}
/// The state of a resource that can be loaded.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, glib::Enum)]
#[enum_type(name = "LoadingState")]
@ -539,6 +511,7 @@ pub(crate) async fn save_data_to_tmp_file(data: Vec<u8>) -> Result<File, std::io
}
}
let mut file = NamedTempFile::new_in(dir)?;
tracing::debug!("Created new tmp file: {}", file.path().to_string_lossy());
file.write_all(&data)?;
Ok(file.into())