Skip to content

Commit 4eb1a5e

Browse files
authored
feat: Show an email-avatar for email-contacts and email-chats (#6916)
Mark email-contacts and email-chats as such by setting a special avatar. Then, the UIs can remove the letter-icon from next to the name again, and we have less overall clutter in Delta Chat. ![Screenshot_20250613-160033](https://github.com/user-attachments/assets/eca9e896-ba1e-4dab-9819-78083a07a18f)
1 parent e0607b3 commit 4eb1a5e

File tree

10 files changed

+176
-104
lines changed

10 files changed

+176
-104
lines changed

assets/icon-email-contact.png

3.41 KB
Loading

assets/icon-email-contact.svg

Lines changed: 47 additions & 0 deletions
Loading

src/chat.rs

Lines changed: 71 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1730,25 +1730,36 @@ impl Chat {
17301730

17311731
/// Returns profile image path for the chat.
17321732
pub async fn get_profile_image(&self, context: &Context) -> Result<Option<PathBuf>> {
1733-
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
1734-
if !image_rel.is_empty() {
1735-
return Ok(Some(get_abs_path(context, Path::new(&image_rel))));
1736-
}
1737-
} else if self.id.is_archived_link() {
1738-
if let Ok(image_rel) = get_archive_icon(context).await {
1739-
return Ok(Some(get_abs_path(context, Path::new(&image_rel))));
1740-
}
1733+
if self.id.is_archived_link() {
1734+
// This is not a real chat, but the "Archive" button
1735+
// that is shown at the top of the chats list
1736+
return Ok(Some(get_archive_icon(context).await?));
1737+
} else if self.is_device_talk() {
1738+
return Ok(Some(get_device_icon(context).await?));
1739+
} else if self.is_self_talk() {
1740+
return Ok(Some(get_saved_messages_icon(context).await?));
17411741
} else if self.typ == Chattype::Single {
1742+
// For 1:1 chats, we always use the same avatar as for the contact
1743+
// This is before the `self.is_encrypted()` check, because that function
1744+
// has two database calls, i.e. it's slow
17421745
let contacts = get_chat_contacts(context, self.id).await?;
17431746
if let Some(contact_id) = contacts.first() {
1744-
if let Ok(contact) = Contact::get_by_id(context, *contact_id).await {
1745-
return contact.get_profile_image(context).await;
1746-
}
1747+
let contact = Contact::get_by_id(context, *contact_id).await?;
1748+
return contact.get_profile_image(context).await;
17471749
}
1748-
} else if self.typ == Chattype::Broadcast {
1749-
if let Ok(image_rel) = get_broadcast_icon(context).await {
1750+
} else if !self.is_encrypted(context).await? {
1751+
// This is an email-contact chat, show a special avatar that marks it as such
1752+
return Ok(Some(get_abs_path(
1753+
context,
1754+
Path::new(&get_email_contact_icon(context).await?),
1755+
)));
1756+
} else if let Some(image_rel) = self.param.get(Param::ProfileImage) {
1757+
// Load the group avatar, or the device-chat / saved-messages icon
1758+
if !image_rel.is_empty() {
17501759
return Ok(Some(get_abs_path(context, Path::new(&image_rel))));
17511760
}
1761+
} else if self.typ == Chattype::Broadcast {
1762+
return Ok(Some(get_broadcast_icon(context).await?));
17521763
}
17531764
Ok(None)
17541765
}
@@ -2422,69 +2433,63 @@ pub struct ChatInfo {
24222433
// - [ ] email
24232434
}
24242435

2425-
pub(crate) async fn update_saved_messages_icon(context: &Context) -> Result<()> {
2426-
if let Some(ChatIdBlocked { id: chat_id, .. }) =
2427-
ChatIdBlocked::lookup_by_contact(context, ContactId::SELF).await?
2428-
{
2429-
let icon = include_bytes!("../assets/icon-saved-messages.png");
2430-
let blob =
2431-
BlobObject::create_and_deduplicate_from_bytes(context, icon, "saved-messages.png")?;
2432-
let icon = blob.as_name().to_string();
2433-
2434-
let mut chat = Chat::load_from_db(context, chat_id).await?;
2435-
chat.param.set(Param::ProfileImage, icon);
2436-
chat.update_param(context).await?;
2436+
async fn get_asset_icon(context: &Context, name: &str, bytes: &[u8]) -> Result<PathBuf> {
2437+
ensure!(name.starts_with("icon-"));
2438+
if let Some(icon) = context.sql.get_raw_config(name).await? {
2439+
return Ok(get_abs_path(context, Path::new(&icon)));
24372440
}
2438-
Ok(())
2439-
}
24402441

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

2449-
let mut chat = Chat::load_from_db(context, chat_id).await?;
2450-
chat.param.set(Param::ProfileImage, &icon);
2451-
chat.update_param(context).await?;
2447+
Ok(get_abs_path(context, Path::new(&icon)))
2448+
}
24522449

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

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

2465-
let icon = include_bytes!("../assets/icon-broadcast.png");
2466-
let blob = BlobObject::create_and_deduplicate_from_bytes(context, icon, "broadcast.png")?;
2467-
let icon = blob.as_name().to_string();
2468-
context
2469-
.sql
2470-
.set_raw_config("icon-broadcast", Some(&icon))
2471-
.await?;
2472-
Ok(icon)
2468+
pub(crate) async fn get_broadcast_icon(context: &Context) -> Result<PathBuf> {
2469+
get_asset_icon(
2470+
context,
2471+
"icon-broadcast",
2472+
include_bytes!("../assets/icon-broadcast.png"),
2473+
)
2474+
.await
24732475
}
24742476

2475-
pub(crate) async fn get_archive_icon(context: &Context) -> Result<String> {
2476-
if let Some(icon) = context.sql.get_raw_config("icon-archive").await? {
2477-
return Ok(icon);
2478-
}
2477+
pub(crate) async fn get_archive_icon(context: &Context) -> Result<PathBuf> {
2478+
get_asset_icon(
2479+
context,
2480+
"icon-archive",
2481+
include_bytes!("../assets/icon-archive.png"),
2482+
)
2483+
.await
2484+
}
24792485

2480-
let icon = include_bytes!("../assets/icon-archive.png");
2481-
let blob = BlobObject::create_and_deduplicate_from_bytes(context, icon, "archive.png")?;
2482-
let icon = blob.as_name().to_string();
2483-
context
2484-
.sql
2485-
.set_raw_config("icon-archive", Some(&icon))
2486-
.await?;
2487-
Ok(icon)
2486+
pub(crate) async fn get_email_contact_icon(context: &Context) -> Result<PathBuf> {
2487+
get_asset_icon(
2488+
context,
2489+
"icon-email-contact",
2490+
include_bytes!("../assets/icon-email-contact.png"),
2491+
)
2492+
.await
24882493
}
24892494

24902495
async fn update_special_chat_name(
@@ -2658,12 +2663,6 @@ impl ChatIdBlocked {
26582663
.await?;
26592664
}
26602665

2661-
match contact_id {
2662-
ContactId::SELF => update_saved_messages_icon(context).await?,
2663-
ContactId::DEVICE => update_device_icon(context).await?,
2664-
_ => (),
2665-
}
2666-
26672666
Ok(Self {
26682667
id: chat_id,
26692668
blocked: create_blocked,

src/chat/chat_tests.rs

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::imex::{has_backup, imex, ImexMode};
77
use crate::message::{delete_msgs, MessengerMessage};
88
use crate::receive_imf::receive_imf;
99
use crate::test_utils::{sync, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
10+
use pretty_assertions::assert_eq;
1011
use strum::IntoEnumIterator;
1112
use tokio::fs;
1213

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

22-
let expected = r#"
23-
{
24-
"id": 10,
25-
"type": 100,
26-
"name": "bob",
27-
"archived": false,
28-
"param": "",
29-
"gossiped_timestamp": 0,
30-
"is_sending_locations": false,
31-
"color": 35391,
32-
"profile_image": "",
33-
"draft": "",
34-
"is_muted": false,
35-
"ephemeral_timer": "Disabled"
36-
}
37-
"#;
23+
let expected = format!(
24+
r#"{{
25+
"id": 10,
26+
"type": 100,
27+
"name": "bob",
28+
"archived": false,
29+
"param": "",
30+
"is_sending_locations": false,
31+
"color": 35391,
32+
"profile_image": {},
33+
"draft": "",
34+
"is_muted": false,
35+
"ephemeral_timer": "Disabled"
36+
}}"#,
37+
// We need to do it like this so that the test passes on Windows:
38+
serde_json::to_string(
39+
t.get_blobdir()
40+
.join("9a17b32ad5ff71df91f7cfda9a62bb2.png")
41+
.to_str()
42+
.unwrap()
43+
)
44+
.unwrap()
45+
);
3846

3947
// Ensure we can deserialize this.
40-
let loaded: ChatInfo = serde_json::from_str(expected).unwrap();
41-
assert_eq!(info, loaded);
48+
serde_json::from_str::<ChatInfo>(&expected).unwrap();
49+
50+
assert_eq!(serde_json::to_string_pretty(&info).unwrap(), expected);
4251
}
4352

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

909918
assert_eq!(chat.name, stock_str::device_messages(&t).await);
910-
assert!(chat.get_profile_image(&t).await?.is_some());
919+
let device_msg_icon = chat.get_profile_image(&t).await?.unwrap();
920+
assert_eq!(
921+
device_msg_icon.metadata()?.len(),
922+
include_bytes!("../../assets/icon-device.png").len() as u64
923+
);
911924

912925
// delete device message, make sure it is not added again
913926
message::delete_msgs(&t, &[*msg1_id.as_ref().unwrap()]).await?;

src/contact.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result<Str
268268
for id in contacts {
269269
let c = Contact::get_by_id(context, *id).await?;
270270
let key = c.public_key(context).await?.map(|k| k.to_base64());
271-
let profile_image = match c.get_profile_image(context).await? {
271+
let profile_image = match c.get_profile_image_ex(context, false).await? {
272272
None => None,
273273
Some(path) => tokio::fs::read(path)
274274
.await
@@ -1493,11 +1493,28 @@ impl Contact {
14931493
/// This is the image set by each remote user on their own
14941494
/// using set_config(context, "selfavatar", image).
14951495
pub async fn get_profile_image(&self, context: &Context) -> Result<Option<PathBuf>> {
1496+
self.get_profile_image_ex(context, true).await
1497+
}
1498+
1499+
/// Get the contact's profile image.
1500+
/// This is the image set by each remote user on their own
1501+
/// using set_config(context, "selfavatar", image).
1502+
async fn get_profile_image_ex(
1503+
&self,
1504+
context: &Context,
1505+
show_fallback_icon: bool,
1506+
) -> Result<Option<PathBuf>> {
14961507
if self.id == ContactId::SELF {
14971508
if let Some(p) = context.get_config(Config::Selfavatar).await? {
14981509
return Ok(Some(PathBuf::from(p))); // get_config() calls get_abs_path() internally already
14991510
}
1500-
} else if let Some(image_rel) = self.param.get(Param::ProfileImage) {
1511+
} else if self.id == ContactId::DEVICE {
1512+
return Ok(Some(chat::get_device_icon(context).await?));
1513+
}
1514+
if show_fallback_icon && !self.id.is_special() && !self.is_pgp_contact() {
1515+
return Ok(Some(chat::get_email_contact_icon(context).await?));
1516+
}
1517+
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
15011518
if !image_rel.is_empty() {
15021519
return Ok(Some(get_abs_path(context, Path::new(image_rel))));
15031520
}

src/sql.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use rusqlite::{config::DbConfig, types::ValueRef, Connection, OpenFlags, Row};
88
use tokio::sync::RwLock;
99

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

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

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

223-
if update_icons {
224-
update_saved_messages_icon(context).await?;
225-
update_device_icon(context).await?;
226-
}
227-
228224
if disable_server_delete {
229225
// We now always watch all folders and delete messages there if delete_server is enabled.
230226
// So, for people who have delete_server enabled, disable it and add a hint to the devicechat:

src/test_utils.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,7 +976,7 @@ impl TestContext {
976976
""
977977
},
978978
match sel_chat.get_profile_image(self).await.unwrap() {
979-
Some(icon) => match icon.to_str() {
979+
Some(icon) => match icon.strip_prefix(self.get_blobdir()).unwrap().to_str() {
980980
Some(icon) => format!(" Icon: {icon}"),
981981
_ => " Icon: Err".to_string(),
982982
},

test-data/golden/receive_imf_older_message_from_2nd_device

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Single#Chat#10: bob@example.net [bob@example.net]
1+
Single#Chat#10: bob@example.net [bob@example.net] Icon: 9a17b32ad5ff71df91f7cfda9a62bb2.png
22
--------------------------------------------------------------------------------
33
Msg#10: Me (Contact#Contact#Self): We share this account √
44
Msg#11: Me (Contact#Contact#Self): I'm Alice too √

test-data/golden/test_old_message_5

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Single#Chat#10: Bob [bob@example.net]
1+
Single#Chat#10: Bob [bob@example.net] Icon: 9a17b32ad5ff71df91f7cfda9a62bb2.png
22
--------------------------------------------------------------------------------
33
Msg#10: Me (Contact#Contact#Self): Happy birthday, Bob! √
44
Msg#11: (Contact#Contact#10): Happy birthday to me, Alice! [FRESH]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Single#Chat#11: bob@example.net [bob@example.net]
1+
Single#Chat#11: bob@example.net [bob@example.net] Icon: 9a17b32ad5ff71df91f7cfda9a62bb2.png
22
--------------------------------------------------------------------------------
33
Msg#12: Me (Contact#Contact#Self): One classical MUA message √
44
--------------------------------------------------------------------------------

0 commit comments

Comments
 (0)