Skip to content

feat: Show an email-avatar for email-contacts and email-chats #6916

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/icon-email-contact.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions assets/icon-email-contact.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
143 changes: 71 additions & 72 deletions src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1726,25 +1726,36 @@ impl Chat {

/// Returns profile image path for the chat.
pub async fn get_profile_image(&self, context: &Context) -> Result<Option<PathBuf>> {
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
if !image_rel.is_empty() {
return Ok(Some(get_abs_path(context, Path::new(&image_rel))));
}
} else if self.id.is_archived_link() {
if let Ok(image_rel) = get_archive_icon(context).await {
return Ok(Some(get_abs_path(context, Path::new(&image_rel))));
}
if self.id.is_archived_link() {
// This is not a real chat, but the "Archive" button
// that is shown at the top of the chats list
return Ok(Some(get_archive_icon(context).await?));
} else if self.is_device_talk() {
return Ok(Some(get_device_icon(context).await?));
} else if self.is_self_talk() {
return Ok(Some(get_saved_messages_icon(context).await?));
} else if self.typ == Chattype::Single {
// For 1:1 chats, we always use the same avatar as for the contact
// This is before the `self.is_encrypted()` check, because that function
// has two database calls, i.e. it's slow
let contacts = get_chat_contacts(context, self.id).await?;
if let Some(contact_id) = contacts.first() {
if let Ok(contact) = Contact::get_by_id(context, *contact_id).await {
return contact.get_profile_image(context).await;
}
let contact = Contact::get_by_id(context, *contact_id).await?;
return contact.get_profile_image(context).await;
}
} else if self.typ == Chattype::Broadcast {
if let Ok(image_rel) = get_broadcast_icon(context).await {
} else if !self.is_encrypted(context).await? {
// This is an email-contact chat, show a special avatar that marks it as such
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mimeparser supports setting group avatars even for unencrypted chats. Should we really unsupport this? Maybe it's still better to have that "email" character at the end of the chat/contact name, but add it artificially in the core (i agree that UIs shouldn't care about this)? Green verification checkmarks are shown at the end of the name as well, would be good to unify everything somehow. One more reason: let's say i have email contacts "Alice" and "Bob" which both don't have avatars. Currently they have different colors with "A" and "B" inside which help me distinguish them, but the PR suggests they look the same.

Copy link
Collaborator Author

@Hocuri Hocuri Jun 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mimeparser supports setting group avatars even for unencrypted chats. Should we really unsupport this?

It seems unlikely to me that this feature was in widespread use, but, sure, this is a downside of this approach.

Maybe it's still better to have that "email" character at the end of the chat/contact name, but add it artificially in the core

Like, an emoji?

The main reasoning for moving the icon into the avatar was minimizing visual clutter in the UI.

One more reason: let's say i have email contacts "Alice" and "Bob" which both don't have avatars. Currently they have different colors with "A" and "B" inside which help me distinguish them, but the PR suggests they look the same.

Yes, this is a downside.

All in all, I'm like +0.1 on this PR, because I do like the removal of visual clutter, but don't have a strong opinion on it. @r10s, who is our main UI person, after some real-life discussions around the topic, decided that we'll go for putting the icon into the avatar.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you remind me why we need this email pictogram in the UIs at all? In release versions we don't have it. And if we need it, maybe it's still possible to preserve colors and put the email unicode char / emoji inside? This gray icon looks boring, e.g. i have many email threads and i'm glad to see all this spectrum. Btw, green checkmarks also clutter the UI, e.g. in WhatsApp i don't see anything like this. Just putting in one more opinion, it's fine to merge if that's already agreed

return Ok(Some(get_abs_path(
context,
Path::new(&get_email_contact_icon(context).await?),
)));
} else if let Some(image_rel) = self.param.get(Param::ProfileImage) {
// Load the group avatar, or the device-chat / saved-messages icon
if !image_rel.is_empty() {
return Ok(Some(get_abs_path(context, Path::new(&image_rel))));
}
} else if self.typ == Chattype::Broadcast {
return Ok(Some(get_broadcast_icon(context).await?));
}
Ok(None)
}
Expand Down Expand Up @@ -2418,69 +2429,63 @@ pub struct ChatInfo {
// - [ ] email
}

pub(crate) async fn update_saved_messages_icon(context: &Context) -> Result<()> {
if let Some(ChatIdBlocked { id: chat_id, .. }) =
ChatIdBlocked::lookup_by_contact(context, ContactId::SELF).await?
{
let icon = include_bytes!("../assets/icon-saved-messages.png");
let blob =
BlobObject::create_and_deduplicate_from_bytes(context, icon, "saved-messages.png")?;
let icon = blob.as_name().to_string();

let mut chat = Chat::load_from_db(context, chat_id).await?;
chat.param.set(Param::ProfileImage, icon);
chat.update_param(context).await?;
async fn get_asset_icon(context: &Context, name: &str, bytes: &[u8]) -> Result<PathBuf> {
ensure!(name.starts_with("icon-"));
if let Some(icon) = context.sql.get_raw_config(name).await? {
return Ok(get_abs_path(context, Path::new(&icon)));
}
Ok(())
}

pub(crate) async fn update_device_icon(context: &Context) -> Result<()> {
if let Some(ChatIdBlocked { id: chat_id, .. }) =
ChatIdBlocked::lookup_by_contact(context, ContactId::DEVICE).await?
{
let icon = include_bytes!("../assets/icon-device.png");
let blob = BlobObject::create_and_deduplicate_from_bytes(context, icon, "device.png")?;
let icon = blob.as_name().to_string();
let blob =
BlobObject::create_and_deduplicate_from_bytes(context, bytes, &format!("{name}.png"))?;
let icon = blob.as_name().to_string();
context.sql.set_raw_config(name, Some(&icon)).await?;

let mut chat = Chat::load_from_db(context, chat_id).await?;
chat.param.set(Param::ProfileImage, &icon);
chat.update_param(context).await?;
Ok(get_abs_path(context, Path::new(&icon)))
}

let mut contact = Contact::get_by_id(context, ContactId::DEVICE).await?;
contact.param.set(Param::ProfileImage, icon);
contact.update_param(context).await?;
}
Ok(())
pub(crate) async fn get_saved_messages_icon(context: &Context) -> Result<PathBuf> {
get_asset_icon(
context,
"icon-saved-messages",
include_bytes!("../assets/icon-saved-messages.png"),
)
.await
}

pub(crate) async fn get_broadcast_icon(context: &Context) -> Result<String> {
if let Some(icon) = context.sql.get_raw_config("icon-broadcast").await? {
return Ok(icon);
}
pub(crate) async fn get_device_icon(context: &Context) -> Result<PathBuf> {
get_asset_icon(
context,
"icon-device",
include_bytes!("../assets/icon-device.png"),
)
.await
}

let icon = include_bytes!("../assets/icon-broadcast.png");
let blob = BlobObject::create_and_deduplicate_from_bytes(context, icon, "broadcast.png")?;
let icon = blob.as_name().to_string();
context
.sql
.set_raw_config("icon-broadcast", Some(&icon))
.await?;
Ok(icon)
pub(crate) async fn get_broadcast_icon(context: &Context) -> Result<PathBuf> {
get_asset_icon(
context,
"icon-broadcast",
include_bytes!("../assets/icon-broadcast.png"),
)
.await
}

pub(crate) async fn get_archive_icon(context: &Context) -> Result<String> {
if let Some(icon) = context.sql.get_raw_config("icon-archive").await? {
return Ok(icon);
}
pub(crate) async fn get_archive_icon(context: &Context) -> Result<PathBuf> {
get_asset_icon(
context,
"icon-archive",
include_bytes!("../assets/icon-archive.png"),
)
.await
}

let icon = include_bytes!("../assets/icon-archive.png");
let blob = BlobObject::create_and_deduplicate_from_bytes(context, icon, "archive.png")?;
let icon = blob.as_name().to_string();
context
.sql
.set_raw_config("icon-archive", Some(&icon))
.await?;
Ok(icon)
pub(crate) async fn get_email_contact_icon(context: &Context) -> Result<PathBuf> {
get_asset_icon(
context,
"icon-email-contact",
include_bytes!("../assets/icon-email-contact.png"),
)
.await
}

async fn update_special_chat_name(
Expand Down Expand Up @@ -2654,12 +2659,6 @@ impl ChatIdBlocked {
.await?;
}

match contact_id {
ContactId::SELF => update_saved_messages_icon(context).await?,
ContactId::DEVICE => update_device_icon(context).await?,
_ => (),
}

Ok(Self {
id: chat_id,
blocked: create_blocked,
Expand Down
51 changes: 32 additions & 19 deletions src/chat/chat_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::imex::{has_backup, imex, ImexMode};
use crate::message::{delete_msgs, MessengerMessage};
use crate::receive_imf::receive_imf;
use crate::test_utils::{sync, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
use pretty_assertions::assert_eq;
use strum::IntoEnumIterator;
use tokio::fs;

Expand All @@ -19,26 +20,34 @@ async fn test_chat_info() {
// Ensure we can serialize this.
println!("{}", serde_json::to_string_pretty(&info).unwrap());

let expected = r#"
{
"id": 10,
"type": 100,
"name": "bob",
"archived": false,
"param": "",
"gossiped_timestamp": 0,
"is_sending_locations": false,
"color": 35391,
"profile_image": "",
"draft": "",
"is_muted": false,
"ephemeral_timer": "Disabled"
}
"#;
let expected = format!(
r#"{{
"id": 10,
"type": 100,
"name": "bob",
"archived": false,
"param": "",
"is_sending_locations": false,
"color": 35391,
"profile_image": {},
"draft": "",
"is_muted": false,
"ephemeral_timer": "Disabled"
}}"#,
// We need to do it like this so that the test passes on Windows:
serde_json::to_string(
t.get_blobdir()
.join("9a17b32ad5ff71df91f7cfda9a62bb2.png")
.to_str()
.unwrap()
)
.unwrap()
);

// Ensure we can deserialize this.
let loaded: ChatInfo = serde_json::from_str(expected).unwrap();
assert_eq!(info, loaded);
serde_json::from_str::<ChatInfo>(&expected).unwrap();

assert_eq!(serde_json::to_string_pretty(&info).unwrap(), expected);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
Expand Down Expand Up @@ -907,7 +916,11 @@ async fn test_add_device_msg_labelled() -> Result<()> {
assert!(chat.why_cant_send(&t).await? == Some(CantSendReason::DeviceChat));

assert_eq!(chat.name, stock_str::device_messages(&t).await);
assert!(chat.get_profile_image(&t).await?.is_some());
let device_msg_icon = chat.get_profile_image(&t).await?.unwrap();
assert_eq!(
device_msg_icon.metadata()?.len(),
include_bytes!("../../assets/icon-device.png").len() as u64
);

// delete device message, make sure it is not added again
message::delete_msgs(&t, &[*msg1_id.as_ref().unwrap()]).await?;
Expand Down
21 changes: 19 additions & 2 deletions src/contact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result<Str
for id in contacts {
let c = Contact::get_by_id(context, *id).await?;
let key = c.public_key(context).await?.map(|k| k.to_base64());
let profile_image = match c.get_profile_image(context).await? {
let profile_image = match c.get_profile_image_ex(context, false).await? {
None => None,
Some(path) => tokio::fs::read(path)
.await
Expand Down Expand Up @@ -1489,11 +1489,28 @@ impl Contact {
/// This is the image set by each remote user on their own
/// using set_config(context, "selfavatar", image).
pub async fn get_profile_image(&self, context: &Context) -> Result<Option<PathBuf>> {
self.get_profile_image_ex(context, true).await
}

/// Get the contact's profile image.
/// This is the image set by each remote user on their own
/// using set_config(context, "selfavatar", image).
async fn get_profile_image_ex(
&self,
context: &Context,
show_fallback_icon: bool,
) -> Result<Option<PathBuf>> {
if self.id == ContactId::SELF {
if let Some(p) = context.get_config(Config::Selfavatar).await? {
return Ok(Some(PathBuf::from(p))); // get_config() calls get_abs_path() internally already
}
} else if let Some(image_rel) = self.param.get(Param::ProfileImage) {
} else if self.id == ContactId::DEVICE {
return Ok(Some(chat::get_device_icon(context).await?));
}
if show_fallback_icon && !self.id.is_special() && !self.is_pgp_contact() {
return Ok(Some(chat::get_email_contact_icon(context).await?));
}
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
if !image_rel.is_empty() {
return Ok(Some(get_abs_path(context, Path::new(image_rel))));
}
Expand Down
10 changes: 3 additions & 7 deletions src/sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use rusqlite::{config::DbConfig, types::ValueRef, Connection, OpenFlags, Row};
use tokio::sync::RwLock;

use crate::blob::BlobObject;
use crate::chat::{add_device_msg, update_device_icon, update_saved_messages_icon};
use crate::chat::add_device_msg;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::context::Context;
Expand Down Expand Up @@ -213,18 +213,14 @@ impl Sql {
// this should be done before updates that use high-level objects that
// rely themselves on the low-level structure.

let (update_icons, disable_server_delete, recode_avatar) = migrations::run(context, self)
// `update_icons` is not used anymore, since it's not necessary anymore to "update" icons:
let (_update_icons, disable_server_delete, recode_avatar) = migrations::run(context, self)
.await
.context("failed to run migrations")?;

// (2) updates that require high-level objects
// the structure is complete now and all objects are usable

if update_icons {
update_saved_messages_icon(context).await?;
update_device_icon(context).await?;
}

if disable_server_delete {
// We now always watch all folders and delete messages there if delete_server is enabled.
// So, for people who have delete_server enabled, disable it and add a hint to the devicechat:
Expand Down
2 changes: 1 addition & 1 deletion src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,7 @@ impl TestContext {
""
},
match sel_chat.get_profile_image(self).await.unwrap() {
Some(icon) => match icon.to_str() {
Some(icon) => match icon.strip_prefix(self.get_blobdir()).unwrap().to_str() {
Some(icon) => format!(" Icon: {icon}"),
_ => " Icon: Err".to_string(),
},
Expand Down
2 changes: 1 addition & 1 deletion test-data/golden/receive_imf_older_message_from_2nd_device
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Single#Chat#10: bob@example.net [bob@example.net]
Single#Chat#10: bob@example.net [bob@example.net] Icon: 9a17b32ad5ff71df91f7cfda9a62bb2.png
--------------------------------------------------------------------------------
Msg#10: Me (Contact#Contact#Self): We share this account √
Msg#11: Me (Contact#Contact#Self): I'm Alice too √
Expand Down
2 changes: 1 addition & 1 deletion test-data/golden/test_old_message_5
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Single#Chat#10: Bob [bob@example.net]
Single#Chat#10: Bob [bob@example.net] Icon: 9a17b32ad5ff71df91f7cfda9a62bb2.png
--------------------------------------------------------------------------------
Msg#10: Me (Contact#Contact#Self): Happy birthday, Bob! √
Msg#11: (Contact#Contact#10): Happy birthday to me, Alice! [FRESH]
Expand Down
2 changes: 1 addition & 1 deletion test-data/golden/test_outgoing_mua_msg
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Single#Chat#11: bob@example.net [bob@example.net]
Single#Chat#11: bob@example.net [bob@example.net] Icon: 9a17b32ad5ff71df91f7cfda9a62bb2.png
--------------------------------------------------------------------------------
Msg#12: Me (Contact#Contact#Self): One classical MUA message √
--------------------------------------------------------------------------------