Compare commits
5 Commits
release/gt
...
release/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 32d2fdc55f | |||
| 7c23717897 | |||
| 3a113e3169 | |||
| 0173be356e | |||
| a3dfedcfd0 |
@@ -52,7 +52,7 @@ if [[ "$found" -ne 1 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
package_group="${RPM_PACKAGE_GROUP:-${RPM_DISTRO_NAME}/${RPM_DISTRO_VERSION}}"
|
||||
package_group="${RPM_PACKAGE_GROUP:-}"
|
||||
|
||||
{
|
||||
printf 'RELEASE_VERSION=%s\n' "$version"
|
||||
|
||||
@@ -21,8 +21,8 @@ jobs:
|
||||
image: fedora:43
|
||||
|
||||
steps:
|
||||
# Build inside Fedora so the RPM package repository grouping matches the
|
||||
# Fedora release we publish to.
|
||||
# Build inside Fedora so rpmbuild and generated dependencies match the
|
||||
# Fedora release we publish for.
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
3
core/Cargo.lock
generated
3
core/Cargo.lock
generated
@@ -1274,7 +1274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kordophoned"
|
||||
version = "1.3.0"
|
||||
version = "1.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1313,6 +1313,7 @@ dependencies = [
|
||||
"block",
|
||||
"dbus",
|
||||
"dbus-codegen",
|
||||
"keyring",
|
||||
"log",
|
||||
"xpc-connection",
|
||||
"xpc-connection-sys",
|
||||
|
||||
@@ -10,6 +10,7 @@ log = "0.4.22"
|
||||
# D-Bus dependencies only on Linux
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
dbus = "0.9.7"
|
||||
keyring = { version = "3.6.3", features = ["sync-secret-service"] }
|
||||
|
||||
# D-Bus codegen only on Linux
|
||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod platform;
|
||||
mod worker;
|
||||
|
||||
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};
|
||||
|
||||
pub use worker::{
|
||||
spawn_worker, Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage,
|
||||
ConversationSummary, Event, Request, SettingsSnapshot,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use crate::worker::{ChatMessage, ConversationSummary, DaemonClient, Event};
|
||||
use crate::worker::{
|
||||
Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage,
|
||||
ConversationSummary, DaemonClient, Event, SettingsSnapshot,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use dbus::arg::{PropMap, RefArg};
|
||||
use dbus::arg::{cast, PropMap, RefArg};
|
||||
use dbus::blocking::{Connection, Proxy};
|
||||
use dbus::channel::Token;
|
||||
use std::sync::mpsc::Sender;
|
||||
@@ -17,6 +20,7 @@ mod dbus_interface {
|
||||
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
|
||||
}
|
||||
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
|
||||
use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings;
|
||||
|
||||
pub(crate) struct DBusClient {
|
||||
conn: Connection,
|
||||
@@ -31,7 +35,7 @@ impl DBusClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn proxy(&self) -> Proxy<&Connection> {
|
||||
fn proxy(&self) -> Proxy<'_, &Connection> {
|
||||
self.conn
|
||||
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
|
||||
}
|
||||
@@ -52,6 +56,10 @@ fn get_u32(map: &PropMap, key: &str) -> u32 {
|
||||
get_i64(map, key).try_into().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn get_bool(map: &PropMap, key: &str) -> bool {
|
||||
map.get(key).and_then(|v| v.0.as_i64()).unwrap_or(0) != 0
|
||||
}
|
||||
|
||||
fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
|
||||
map.get(key)
|
||||
.and_then(|v| v.0.as_iter())
|
||||
@@ -62,6 +70,122 @@ fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn clone_prop_map(map: &PropMap) -> PropMap {
|
||||
map.iter()
|
||||
.map(|(key, value)| (key.clone(), dbus::arg::Variant(value.0.box_clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_prop_map(map: &PropMap, key: &str) -> Option<PropMap> {
|
||||
let value = map.get(key)?;
|
||||
|
||||
if let Some(prop_map) = cast::<PropMap>(&value.0) {
|
||||
return Some(clone_prop_map(prop_map));
|
||||
}
|
||||
|
||||
let mut iter = value.0.as_iter()?;
|
||||
let mut out = PropMap::new();
|
||||
loop {
|
||||
let Some(key) = iter.next().and_then(|arg| arg.as_str()) else {
|
||||
break;
|
||||
};
|
||||
let Some(value) = iter.next() else {
|
||||
break;
|
||||
};
|
||||
|
||||
out.insert(key.to_string(), dbus::arg::Variant(value.box_clone()));
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn get_vec_prop_map(map: &PropMap, key: &str) -> Vec<PropMap> {
|
||||
let Some(value) = map.get(key) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
if let Some(items) = cast::<Vec<PropMap>>(&value.0) {
|
||||
return items.iter().map(clone_prop_map).collect();
|
||||
}
|
||||
|
||||
value
|
||||
.0
|
||||
.as_iter()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|item| {
|
||||
let mut item_iter = item.as_iter()?;
|
||||
let mut out = PropMap::new();
|
||||
loop {
|
||||
let Some(key) = item_iter.next().and_then(|arg| arg.as_str()) else {
|
||||
break;
|
||||
};
|
||||
let Some(value) = item_iter.next() else {
|
||||
break;
|
||||
};
|
||||
|
||||
out.insert(key.to_string(), dbus::arg::Variant(value.box_clone()));
|
||||
}
|
||||
Some(out)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn attachment_from_prop_map(map: PropMap) -> Attachment {
|
||||
let metadata = get_prop_map(&map, "metadata").map(|metadata_map| {
|
||||
let attribution_info =
|
||||
get_prop_map(&metadata_map, "attribution_info").map(|info_map| AttributionInfo {
|
||||
width: info_map
|
||||
.get("width")
|
||||
.and_then(|v| v.0.as_i64())
|
||||
.and_then(|v| v.try_into().ok()),
|
||||
height: info_map
|
||||
.get("height")
|
||||
.and_then(|v| v.0.as_i64())
|
||||
.and_then(|v| v.try_into().ok()),
|
||||
});
|
||||
|
||||
AttachmentMetadata { attribution_info }
|
||||
});
|
||||
|
||||
Attachment {
|
||||
guid: get_string(&map, "guid"),
|
||||
downloaded: get_bool(&map, "downloaded"),
|
||||
preview_downloaded: get_bool(&map, "preview_downloaded"),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
fn password_for_username(username: &str) -> Result<String> {
|
||||
if username.trim().is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let entry = keyring::Entry::new("net.buzzert.kordophonecd", username)?;
|
||||
match entry.get_password() {
|
||||
Ok(password) => Ok(password),
|
||||
Err(keyring::Error::NoEntry) => Ok(String::new()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_password_for_username(username: &str, password: &str) -> Result<()> {
|
||||
if username.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entry = keyring::Entry::new("net.buzzert.kordophonecd", username)?;
|
||||
if password.is_empty() {
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
entry.set_password(password).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonClient for DBusClient {
|
||||
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
||||
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
|
||||
@@ -83,6 +207,7 @@ impl DaemonClient for DBusClient {
|
||||
id,
|
||||
title,
|
||||
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
|
||||
participants,
|
||||
unread_count: get_u32(&conv, "unread_count"),
|
||||
date_unix: get_i64(&conv, "date"),
|
||||
}
|
||||
@@ -107,20 +232,37 @@ impl DaemonClient for DBusClient {
|
||||
Ok(messages
|
||||
.into_iter()
|
||||
.map(|msg| ChatMessage {
|
||||
id: get_string(&msg, "id"),
|
||||
sender: get_string(&msg, "sender"),
|
||||
text: get_string(&msg, "text"),
|
||||
date_unix: get_i64(&msg, "date"),
|
||||
attachments: get_vec_prop_map(&msg, "attachments")
|
||||
.into_iter()
|
||||
.map(attachment_from_prop_map)
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
||||
let attachment_guids: Vec<&str> = vec![];
|
||||
self.send_message_with_attachments(conversation_id, text, Vec::new())
|
||||
}
|
||||
|
||||
fn send_message_with_attachments(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
text: String,
|
||||
attachment_guids: Vec<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let attachment_guid_refs = attachment_guids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
let outgoing_id = KordophoneRepository::send_message(
|
||||
&self.proxy(),
|
||||
&conversation_id,
|
||||
&text,
|
||||
attachment_guids,
|
||||
attachment_guid_refs,
|
||||
)?;
|
||||
Ok(Some(outgoing_id))
|
||||
}
|
||||
@@ -135,6 +277,61 @@ impl DaemonClient for DBusClient {
|
||||
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}"))
|
||||
}
|
||||
|
||||
fn sync_conversation_list(&mut self) -> Result<()> {
|
||||
KordophoneRepository::sync_conversation_list(&self.proxy())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to sync conversation list: {e}"))
|
||||
}
|
||||
|
||||
fn sync_all_conversations(&mut self) -> Result<()> {
|
||||
KordophoneRepository::sync_all_conversations(&self.proxy())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to sync all conversations: {e}"))
|
||||
}
|
||||
|
||||
fn download_attachment(&mut self, attachment_guid: String, preview: bool) -> Result<()> {
|
||||
KordophoneRepository::download_attachment(&self.proxy(), &attachment_guid, preview)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to download attachment: {e}"))
|
||||
}
|
||||
|
||||
fn get_attachment_info(&mut self, attachment_guid: String) -> Result<AttachmentInfo> {
|
||||
let (path, preview_path, downloaded, preview_downloaded) =
|
||||
KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_guid)?;
|
||||
|
||||
Ok(AttachmentInfo {
|
||||
path,
|
||||
preview_path,
|
||||
downloaded,
|
||||
preview_downloaded,
|
||||
})
|
||||
}
|
||||
|
||||
fn upload_attachment(&mut self, path: String) -> Result<String> {
|
||||
KordophoneRepository::upload_attachment(&self.proxy(), &path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to upload attachment: {e}"))
|
||||
}
|
||||
|
||||
fn get_settings(&mut self) -> Result<SettingsSnapshot> {
|
||||
let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default();
|
||||
let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default();
|
||||
let password = password_for_username(&username)?;
|
||||
|
||||
Ok(SettingsSnapshot {
|
||||
server_url,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
fn save_settings(
|
||||
&mut self,
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<()> {
|
||||
KordophoneSettings::set_server(&self.proxy(), &server_url, &username)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save daemon settings: {e}"))?;
|
||||
set_password_for_username(&username, &password)
|
||||
}
|
||||
|
||||
fn install_signal_handlers(&mut self, event_tx: Sender<Event>) -> Result<()> {
|
||||
let conversations_tx = event_tx.clone();
|
||||
let t1 = self
|
||||
@@ -164,7 +361,7 @@ impl DaemonClient for DBusClient {
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
|
||||
|
||||
let reconnected_tx = event_tx;
|
||||
let reconnected_tx = event_tx.clone();
|
||||
let t3 = self
|
||||
.proxy()
|
||||
.match_signal(
|
||||
@@ -177,7 +374,54 @@ impl DaemonClient for DBusClient {
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
|
||||
|
||||
self.signal_tokens.extend([t1, t2, t3]);
|
||||
let download_tx = event_tx.clone();
|
||||
let t4 = self
|
||||
.proxy()
|
||||
.match_signal(
|
||||
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted,
|
||||
_: &Connection,
|
||||
_: &dbus::message::Message| {
|
||||
let _ = download_tx.send(Event::AttachmentDownloaded {
|
||||
attachment_guid: s.attachment_id,
|
||||
});
|
||||
true
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentDownloadCompleted: {e}"))?;
|
||||
|
||||
let failed_tx = event_tx.clone();
|
||||
let t5 = self
|
||||
.proxy()
|
||||
.match_signal(
|
||||
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed,
|
||||
_: &Connection,
|
||||
_: &dbus::message::Message| {
|
||||
let _ = failed_tx.send(Event::AttachmentDownloadFailed {
|
||||
attachment_guid: s.attachment_id,
|
||||
error: s.error_message,
|
||||
});
|
||||
true
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentDownloadFailed: {e}"))?;
|
||||
|
||||
let upload_tx = event_tx;
|
||||
let t6 = self
|
||||
.proxy()
|
||||
.match_signal(
|
||||
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted,
|
||||
_: &Connection,
|
||||
_: &dbus::message::Message| {
|
||||
let _ = upload_tx.send(Event::AttachmentUploaded {
|
||||
upload_guid: s.upload_guid,
|
||||
attachment_guid: s.attachment_guid,
|
||||
});
|
||||
true
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentUploadCompleted: {e}"))?;
|
||||
|
||||
self.signal_tokens.extend([t1, t2, t3, t4, t5, t6]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -186,4 +430,3 @@ impl DaemonClient for DBusClient {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ impl DaemonClient for XpcClient {
|
||||
id,
|
||||
title,
|
||||
preview: preview.replace('\n', " "),
|
||||
participants,
|
||||
unread_count,
|
||||
date_unix,
|
||||
});
|
||||
@@ -180,9 +181,11 @@ impl DaemonClient for XpcClient {
|
||||
for item in items {
|
||||
let Message::Dictionary(msg) = item else { continue };
|
||||
messages.push(ChatMessage {
|
||||
id: Self::get_string(msg, "id").unwrap_or_default(),
|
||||
sender: Self::get_string(msg, "sender").unwrap_or_default(),
|
||||
text: Self::get_string(msg, "text").unwrap_or_default(),
|
||||
date_unix: Self::get_i64_from_str(msg, "date"),
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(messages)
|
||||
@@ -230,4 +233,3 @@ impl DaemonClient for XpcClient {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,23 +8,98 @@ pub struct ConversationSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub preview: String,
|
||||
pub participants: Vec<String>,
|
||||
pub unread_count: u32,
|
||||
pub date_unix: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub sender: String,
|
||||
pub text: String,
|
||||
pub date_unix: i64,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn from_me(&self) -> bool {
|
||||
self.sender == "(Me)"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AttributionInfo {
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AttachmentMetadata {
|
||||
pub attribution_info: Option<AttributionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Attachment {
|
||||
pub guid: String,
|
||||
pub downloaded: bool,
|
||||
pub preview_downloaded: bool,
|
||||
pub metadata: Option<AttachmentMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AttachmentInfo {
|
||||
pub path: String,
|
||||
pub preview_path: String,
|
||||
pub downloaded: bool,
|
||||
pub preview_downloaded: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SettingsSnapshot {
|
||||
pub server_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub enum Request {
|
||||
RefreshConversations,
|
||||
RefreshMessages { conversation_id: String },
|
||||
SendMessage { conversation_id: String, text: String },
|
||||
MarkRead { conversation_id: String },
|
||||
SyncConversation { conversation_id: String },
|
||||
RefreshMessages {
|
||||
conversation_id: String,
|
||||
},
|
||||
SendMessage {
|
||||
conversation_id: String,
|
||||
text: String,
|
||||
},
|
||||
SendMessageWithAttachments {
|
||||
conversation_id: String,
|
||||
text: String,
|
||||
attachment_guids: Vec<String>,
|
||||
},
|
||||
MarkRead {
|
||||
conversation_id: String,
|
||||
},
|
||||
SyncConversation {
|
||||
conversation_id: String,
|
||||
},
|
||||
SyncConversationList,
|
||||
SyncAllConversations,
|
||||
DownloadAttachment {
|
||||
attachment_guid: String,
|
||||
preview: bool,
|
||||
},
|
||||
GetAttachmentInfo {
|
||||
attachment_guid: String,
|
||||
},
|
||||
UploadAttachment {
|
||||
path: String,
|
||||
},
|
||||
LoadSettings,
|
||||
SaveSettings {
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
@@ -38,9 +113,40 @@ pub enum Event {
|
||||
outgoing_id: Option<String>,
|
||||
},
|
||||
MarkedRead,
|
||||
ConversationSyncTriggered { conversation_id: String },
|
||||
ConversationSyncTriggered {
|
||||
conversation_id: String,
|
||||
},
|
||||
ConversationListSyncTriggered,
|
||||
AllConversationsSyncTriggered,
|
||||
AttachmentDownloadQueued {
|
||||
attachment_guid: String,
|
||||
preview: bool,
|
||||
},
|
||||
AttachmentDownloaded {
|
||||
attachment_guid: String,
|
||||
},
|
||||
AttachmentDownloadFailed {
|
||||
attachment_guid: String,
|
||||
error: String,
|
||||
},
|
||||
AttachmentUploaded {
|
||||
upload_guid: String,
|
||||
attachment_guid: String,
|
||||
},
|
||||
AttachmentUploadQueued {
|
||||
upload_guid: String,
|
||||
path: String,
|
||||
},
|
||||
AttachmentInfo {
|
||||
attachment_guid: String,
|
||||
info: AttachmentInfo,
|
||||
},
|
||||
SettingsLoaded(SettingsSnapshot),
|
||||
SettingsSaved,
|
||||
ConversationsUpdated,
|
||||
MessagesUpdated { conversation_id: String },
|
||||
MessagesUpdated {
|
||||
conversation_id: String,
|
||||
},
|
||||
UpdateStreamReconnected,
|
||||
Error(String),
|
||||
}
|
||||
@@ -59,16 +165,18 @@ pub fn spawn_worker(
|
||||
};
|
||||
|
||||
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
|
||||
let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}")));
|
||||
let _ = event_tx.send(Event::Error(format!(
|
||||
"Failed to install daemon signals: {e}"
|
||||
)));
|
||||
}
|
||||
|
||||
loop {
|
||||
match request_rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(req) => {
|
||||
let res = match req {
|
||||
Request::RefreshConversations => client
|
||||
.get_conversations(200, 0)
|
||||
.map(Event::Conversations),
|
||||
Request::RefreshConversations => {
|
||||
client.get_conversations(200, 0).map(Event::Conversations)
|
||||
}
|
||||
Request::RefreshMessages { conversation_id } => client
|
||||
.get_messages(conversation_id.clone(), None)
|
||||
.map(|messages| Event::Messages {
|
||||
@@ -78,8 +186,24 @@ pub fn spawn_worker(
|
||||
Request::SendMessage {
|
||||
conversation_id,
|
||||
text,
|
||||
} => {
|
||||
client
|
||||
.send_message(conversation_id.clone(), text)
|
||||
.map(|outgoing_id| Event::MessageSent {
|
||||
conversation_id,
|
||||
outgoing_id,
|
||||
})
|
||||
}
|
||||
Request::SendMessageWithAttachments {
|
||||
conversation_id,
|
||||
text,
|
||||
attachment_guids,
|
||||
} => client
|
||||
.send_message(conversation_id.clone(), text)
|
||||
.send_message_with_attachments(
|
||||
conversation_id.clone(),
|
||||
text,
|
||||
attachment_guids,
|
||||
)
|
||||
.map(|outgoing_id| Event::MessageSent {
|
||||
conversation_id,
|
||||
outgoing_id,
|
||||
@@ -90,6 +214,38 @@ pub fn spawn_worker(
|
||||
Request::SyncConversation { conversation_id } => client
|
||||
.sync_conversation(conversation_id.clone())
|
||||
.map(|_| Event::ConversationSyncTriggered { conversation_id }),
|
||||
Request::SyncConversationList => client
|
||||
.sync_conversation_list()
|
||||
.map(|_| Event::ConversationListSyncTriggered),
|
||||
Request::SyncAllConversations => client
|
||||
.sync_all_conversations()
|
||||
.map(|_| Event::AllConversationsSyncTriggered),
|
||||
Request::DownloadAttachment {
|
||||
attachment_guid,
|
||||
preview,
|
||||
} => client
|
||||
.download_attachment(attachment_guid.clone(), preview)
|
||||
.map(|_| Event::AttachmentDownloadQueued {
|
||||
attachment_guid,
|
||||
preview,
|
||||
}),
|
||||
Request::GetAttachmentInfo { attachment_guid } => client
|
||||
.get_attachment_info(attachment_guid.clone())
|
||||
.map(|info| Event::AttachmentInfo {
|
||||
attachment_guid,
|
||||
info,
|
||||
}),
|
||||
Request::UploadAttachment { path } => client
|
||||
.upload_attachment(path.clone())
|
||||
.map(|upload_guid| Event::AttachmentUploadQueued { upload_guid, path }),
|
||||
Request::LoadSettings => client.get_settings().map(Event::SettingsLoaded),
|
||||
Request::SaveSettings {
|
||||
server_url,
|
||||
username,
|
||||
password,
|
||||
} => client
|
||||
.save_settings(server_url, username, password)
|
||||
.map(|_| Event::SettingsSaved),
|
||||
};
|
||||
|
||||
match res {
|
||||
@@ -120,8 +276,43 @@ pub(crate) trait DaemonClient {
|
||||
last_message_id: Option<String>,
|
||||
) -> Result<Vec<ChatMessage>>;
|
||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
|
||||
fn send_message_with_attachments(
|
||||
&mut self,
|
||||
conversation_id: String,
|
||||
text: String,
|
||||
attachment_guids: Vec<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let _ = attachment_guids;
|
||||
self.send_message(conversation_id, text)
|
||||
}
|
||||
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
|
||||
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
|
||||
fn sync_conversation_list(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn sync_all_conversations(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn download_attachment(&mut self, _attachment_guid: String, _preview: bool) -> Result<()> {
|
||||
anyhow::bail!("Attachment downloads are not supported on this platform")
|
||||
}
|
||||
fn get_attachment_info(&mut self, _attachment_guid: String) -> Result<AttachmentInfo> {
|
||||
anyhow::bail!("Attachment info is not supported on this platform")
|
||||
}
|
||||
fn upload_attachment(&mut self, _path: String) -> Result<String> {
|
||||
anyhow::bail!("Attachment uploads are not supported on this platform")
|
||||
}
|
||||
fn get_settings(&mut self) -> Result<SettingsSnapshot> {
|
||||
anyhow::bail!("Settings are not supported on this platform")
|
||||
}
|
||||
fn save_settings(
|
||||
&mut self,
|
||||
_server_url: String,
|
||||
_username: String,
|
||||
_password: String,
|
||||
) -> Result<()> {
|
||||
anyhow::bail!("Settings are not supported on this platform")
|
||||
}
|
||||
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -130,4 +321,3 @@ pub(crate) trait DaemonClient {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kordophoned"
|
||||
version = "1.3.1"
|
||||
version = "1.3.3"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0"
|
||||
description = "Client daemon for the Kordophone chat protocol"
|
||||
|
||||
@@ -394,12 +394,38 @@ fn run_app(
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
}
|
||||
daemon::Event::MarkedRead => {}
|
||||
daemon::Event::MarkedRead
|
||||
| daemon::Event::ConversationListSyncTriggered
|
||||
| daemon::Event::AllConversationsSyncTriggered
|
||||
| daemon::Event::AttachmentDownloadQueued { .. }
|
||||
| daemon::Event::AttachmentUploadQueued { .. }
|
||||
| daemon::Event::AttachmentUploaded { .. }
|
||||
| daemon::Event::AttachmentInfo { .. }
|
||||
| daemon::Event::SettingsLoaded(_)
|
||||
| daemon::Event::SettingsSaved => {}
|
||||
daemon::Event::ConversationSyncTriggered { conversation_id } => {
|
||||
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
||||
app.status = "Syncing…".to_string();
|
||||
}
|
||||
}
|
||||
daemon::Event::AttachmentDownloaded { .. } => {
|
||||
if let Some(cid) = app.active_conversation_id.clone() {
|
||||
if !app.refresh_messages_in_flight {
|
||||
request_tx
|
||||
.send(daemon::Request::RefreshMessages {
|
||||
conversation_id: cid,
|
||||
})
|
||||
.ok();
|
||||
app.refresh_messages_in_flight = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
daemon::Event::AttachmentDownloadFailed {
|
||||
attachment_guid,
|
||||
error,
|
||||
} => {
|
||||
app.status = format!("Attachment {attachment_guid} failed: {error}");
|
||||
}
|
||||
daemon::Event::ConversationsUpdated => {
|
||||
if !app.refresh_conversations_in_flight {
|
||||
request_tx.send(daemon::Request::RefreshConversations).ok();
|
||||
|
||||
2
cosmic/.gitignore
vendored
Normal file
2
cosmic/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
/screenshots/
|
||||
6879
cosmic/Cargo.lock
generated
Normal file
6879
cosmic/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
cosmic/Cargo.toml
Normal file
29
cosmic/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "kordophone-cosmic"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.93"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
kordophoned-client = { path = "../core/kordophoned-client" }
|
||||
open = "5"
|
||||
regex = "1"
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
url = "2"
|
||||
|
||||
[dependencies.libcosmic]
|
||||
path = "/home/buzzert/src/cosmic/libcosmic"
|
||||
default-features = false
|
||||
features = [
|
||||
"advanced-shaping",
|
||||
"multi-window",
|
||||
"tokio",
|
||||
"winit",
|
||||
"wgpu",
|
||||
"xdg-portal",
|
||||
]
|
||||
1041
cosmic/src/app.rs
Normal file
1041
cosmic/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
21
cosmic/src/main.rs
Normal file
21
cosmic/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
mod app;
|
||||
mod transcript;
|
||||
|
||||
use app::{App, Flags};
|
||||
use cosmic::app::Settings;
|
||||
use cosmic::iced::Size;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_timer(tracing_subscriber::fmt::time::uptime())
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "warn,kordophone_cosmic=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let flags = Flags::from_env();
|
||||
let settings = Settings::default().size(Size::new(1120.0, 780.0));
|
||||
cosmic::app::run::<App>(settings, flags)?;
|
||||
Ok(())
|
||||
}
|
||||
633
cosmic/src/transcript.rs
Normal file
633
cosmic/src/transcript.rs
Normal file
@@ -0,0 +1,633 @@
|
||||
use chrono::{Local, TimeZone};
|
||||
use cosmic::iced::advanced::image::{self, Renderer as ImageRenderer};
|
||||
use cosmic::iced::advanced::layout::{self, Layout};
|
||||
use cosmic::iced::advanced::renderer;
|
||||
use cosmic::iced::advanced::text;
|
||||
use cosmic::iced::advanced::text::{Paragraph as _, Renderer as _};
|
||||
use cosmic::iced::advanced::widget::{self, Widget};
|
||||
use cosmic::iced::advanced::{Clipboard, Renderer as _, Shell};
|
||||
use cosmic::iced::border;
|
||||
use cosmic::iced::mouse;
|
||||
use cosmic::iced::{
|
||||
Background, Color, Event, Length, Pixels, Point, Rectangle, Size, Vector, alignment,
|
||||
};
|
||||
use cosmic::{Element, Renderer, Theme};
|
||||
use regex::Regex;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::ops::Range;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
use tracing::Level;
|
||||
|
||||
const BUBBLE_MARGIN: f32 = 24.0;
|
||||
const MAX_BUBBLE_WIDTH: f32 = 0.74;
|
||||
const IMAGE_WIDTH_FACTOR: f32 = 0.70;
|
||||
const RADIUS: f32 = 16.0;
|
||||
const TEXT_X_PADDING: f32 = 14.0;
|
||||
const TEXT_Y_PADDING: f32 = 8.0;
|
||||
const DATE_PADDING: f32 = 36.0;
|
||||
const SENDER_PADDING: f32 = 6.0;
|
||||
const TRANSCRIPT_TOP_PADDING: f32 = 10.0;
|
||||
|
||||
type Paragraph = <Renderer as text::Renderer>::Paragraph;
|
||||
|
||||
#[derive(Clone, Debug, Hash)]
|
||||
pub struct TranscriptMessage {
|
||||
pub id: String,
|
||||
pub sender: String,
|
||||
pub text: String,
|
||||
pub date_unix: i64,
|
||||
pub from_me: bool,
|
||||
pub should_animate: bool,
|
||||
pub attachments: Vec<TranscriptAttachment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash)]
|
||||
pub struct TranscriptAttachment {
|
||||
pub guid: String,
|
||||
pub preview_path: Option<String>,
|
||||
pub downloaded: bool,
|
||||
pub preview_downloaded: bool,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TranscriptAction {
|
||||
CopyText(String),
|
||||
OpenUrl(String),
|
||||
OpenAttachment(String),
|
||||
}
|
||||
|
||||
pub struct Transcript<'a, Message> {
|
||||
messages: Vec<TranscriptMessage>,
|
||||
show_sender: bool,
|
||||
fingerprint: u64,
|
||||
on_action: Box<dyn Fn(TranscriptAction) -> Message + 'a>,
|
||||
}
|
||||
|
||||
impl<'a, Message> Transcript<'a, Message> {
|
||||
pub fn new(
|
||||
messages: Vec<TranscriptMessage>,
|
||||
show_sender: bool,
|
||||
on_action: impl Fn(TranscriptAction) -> Message + 'a,
|
||||
) -> Self {
|
||||
let fingerprint = messages_fingerprint(&messages);
|
||||
Self {
|
||||
messages,
|
||||
show_sender,
|
||||
fingerprint,
|
||||
on_action: Box::new(on_action),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn messages_fingerprint(messages: &[TranscriptMessage]) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
messages.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum DisplayKind {
|
||||
Date {
|
||||
paragraph: Paragraph,
|
||||
},
|
||||
Sender {
|
||||
paragraph: Paragraph,
|
||||
},
|
||||
Text {
|
||||
paragraph: Paragraph,
|
||||
text: String,
|
||||
from_me: bool,
|
||||
},
|
||||
Image {
|
||||
attachment_guid: String,
|
||||
preview_handle: Option<image::Handle>,
|
||||
from_me: bool,
|
||||
downloaded: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DisplayItem {
|
||||
bounds: Rectangle,
|
||||
content_bounds: Rectangle,
|
||||
kind: DisplayKind,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DisplayList {
|
||||
items: Vec<DisplayItem>,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct DisplayListKey {
|
||||
width_px: u32,
|
||||
show_sender: bool,
|
||||
fingerprint: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TranscriptState {
|
||||
cache_key: Option<DisplayListKey>,
|
||||
display_list: DisplayList,
|
||||
}
|
||||
|
||||
impl DisplayList {
|
||||
fn hit(&self, point: Point) -> Option<&DisplayItem> {
|
||||
let start = self
|
||||
.items
|
||||
.partition_point(|item| item.bounds.y + item.bounds.height < point.y);
|
||||
|
||||
self.items[start..]
|
||||
.iter()
|
||||
.take_while(|item| item.bounds.y <= point.y)
|
||||
.find(|item| item.bounds.contains(point))
|
||||
}
|
||||
|
||||
fn visible_range(&self, viewport: Rectangle) -> Range<usize> {
|
||||
let top = viewport.y;
|
||||
let bottom = viewport.y + viewport.height;
|
||||
let start = self
|
||||
.items
|
||||
.partition_point(|item| item.bounds.y + item.bounds.height < top);
|
||||
let end = start
|
||||
+ self.items[start..]
|
||||
.iter()
|
||||
.take_while(|item| item.bounds.y <= bottom)
|
||||
.count();
|
||||
|
||||
start..end
|
||||
}
|
||||
}
|
||||
|
||||
fn url_regex() -> &'static Regex {
|
||||
static URL_RE: OnceLock<Regex> = OnceLock::new();
|
||||
URL_RE.get_or_init(|| {
|
||||
Regex::new(r"https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&//=]*")
|
||||
.expect("valid URL regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn first_url(text: &str) -> Option<String> {
|
||||
url_regex().find(text).map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
fn is_attachment_marker(text: &str) -> bool {
|
||||
text == "\u{FFFC}"
|
||||
}
|
||||
|
||||
fn date_label(timestamp: i64) -> String {
|
||||
Local
|
||||
.timestamp_opt(timestamp, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%b %d, %Y at %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown date".to_string())
|
||||
}
|
||||
|
||||
fn paragraph(
|
||||
renderer: &Renderer,
|
||||
content: &str,
|
||||
width: f32,
|
||||
size: f32,
|
||||
line_height: f32,
|
||||
align_x: text::Alignment,
|
||||
) -> Paragraph {
|
||||
Paragraph::with_text(text::Text {
|
||||
content,
|
||||
bounds: Size::new(width, f32::INFINITY),
|
||||
size: Pixels(size),
|
||||
line_height: text::LineHeight::Relative(line_height),
|
||||
font: renderer.default_font(),
|
||||
align_x,
|
||||
align_y: alignment::Vertical::Top,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::WordOrGlyph,
|
||||
ellipsize: text::Ellipsize::None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_display_list(
|
||||
messages: &[TranscriptMessage],
|
||||
show_sender: bool,
|
||||
width: f32,
|
||||
renderer: &Renderer,
|
||||
) -> DisplayList {
|
||||
let max_width = (width * MAX_BUBBLE_WIDTH).max(220.0);
|
||||
let max_image_width = max_width * IMAGE_WIDTH_FACTOR;
|
||||
let mut items = Vec::new();
|
||||
let mut y = TRANSCRIPT_TOP_PADDING;
|
||||
let mut last_date: Option<i64> = None;
|
||||
let mut last_sender: Option<&str> = None;
|
||||
|
||||
for message in messages {
|
||||
if last_date.is_some_and(|date| message.date_unix - date > 60 * 60) {
|
||||
let text = date_label(message.date_unix);
|
||||
let paragraph = paragraph(
|
||||
renderer,
|
||||
&text,
|
||||
max_width,
|
||||
12.0,
|
||||
1.2,
|
||||
text::Alignment::Center,
|
||||
);
|
||||
let height = paragraph.min_height() + DATE_PADDING;
|
||||
let x = ((width - max_width) / 2.0).max(BUBBLE_MARGIN);
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(max_width, height));
|
||||
items.push(DisplayItem {
|
||||
bounds,
|
||||
content_bounds: bounds,
|
||||
kind: DisplayKind::Date { paragraph },
|
||||
});
|
||||
y += height;
|
||||
last_sender = None;
|
||||
}
|
||||
|
||||
if show_sender && !message.from_me && last_sender != Some(message.sender.as_str()) {
|
||||
let paragraph = paragraph(
|
||||
renderer,
|
||||
&message.sender,
|
||||
max_width,
|
||||
12.0,
|
||||
1.2,
|
||||
text::Alignment::Left,
|
||||
);
|
||||
let height = paragraph.min_height() + SENDER_PADDING;
|
||||
let x = BUBBLE_MARGIN;
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(max_width, height));
|
||||
items.push(DisplayItem {
|
||||
bounds,
|
||||
content_bounds: bounds,
|
||||
kind: DisplayKind::Sender { paragraph },
|
||||
});
|
||||
y += height;
|
||||
}
|
||||
|
||||
if !message.text.is_empty() && !is_attachment_marker(&message.text) {
|
||||
let text_available_width = max_width - TEXT_X_PADDING * 2.0;
|
||||
let paragraph = paragraph(
|
||||
renderer,
|
||||
&message.text,
|
||||
text_available_width,
|
||||
14.0,
|
||||
1.18,
|
||||
text::Alignment::Left,
|
||||
);
|
||||
let text_bounds = paragraph.min_bounds();
|
||||
let bubble_width = (text_bounds.width + TEXT_X_PADDING * 2.0).min(max_width);
|
||||
let bubble_height = text_bounds.height + TEXT_Y_PADDING * 2.0;
|
||||
let x = if message.from_me {
|
||||
width - bubble_width - BUBBLE_MARGIN
|
||||
} else {
|
||||
BUBBLE_MARGIN
|
||||
};
|
||||
let vertical_padding = if last_sender == Some(message.sender.as_str()) {
|
||||
4.0
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(bubble_width, bubble_height));
|
||||
let content_bounds = Rectangle::new(
|
||||
Point::new(x + TEXT_X_PADDING, y + TEXT_Y_PADDING),
|
||||
Size::new(text_available_width, text_bounds.height),
|
||||
);
|
||||
items.push(DisplayItem {
|
||||
bounds,
|
||||
content_bounds,
|
||||
kind: DisplayKind::Text {
|
||||
paragraph,
|
||||
text: message.text.clone(),
|
||||
from_me: message.from_me,
|
||||
},
|
||||
});
|
||||
y += bubble_height + vertical_padding;
|
||||
}
|
||||
|
||||
for attachment in &message.attachments {
|
||||
let intrinsic_width = attachment.width.unwrap_or(200) as f32;
|
||||
let intrinsic_height = attachment.height.unwrap_or(150) as f32;
|
||||
let scale = (max_image_width / intrinsic_width).min(1.0);
|
||||
let image_width = (intrinsic_width * scale).max(200.0).min(max_image_width);
|
||||
let image_height = (intrinsic_height * scale).max(100.0);
|
||||
let x = if message.from_me {
|
||||
width - image_width - BUBBLE_MARGIN
|
||||
} else {
|
||||
BUBBLE_MARGIN
|
||||
};
|
||||
let bounds = Rectangle::new(Point::new(x, y), Size::new(image_width, image_height));
|
||||
items.push(DisplayItem {
|
||||
bounds,
|
||||
content_bounds: bounds,
|
||||
kind: DisplayKind::Image {
|
||||
attachment_guid: attachment.guid.clone(),
|
||||
preview_handle: attachment
|
||||
.preview_path
|
||||
.as_ref()
|
||||
.map(image::Handle::from_path),
|
||||
from_me: message.from_me,
|
||||
downloaded: attachment.downloaded || attachment.preview_downloaded,
|
||||
},
|
||||
});
|
||||
y += image_height + 8.0;
|
||||
}
|
||||
|
||||
last_sender = Some(&message.sender);
|
||||
last_date = Some(message.date_unix);
|
||||
}
|
||||
|
||||
DisplayList {
|
||||
items,
|
||||
height: y.max(200.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn incoming_color() -> Color {
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
|
||||
}
|
||||
|
||||
fn outgoing_color(theme: &Theme) -> Color {
|
||||
theme.cosmic().accent.base.into()
|
||||
}
|
||||
|
||||
fn draw_bubble_background(
|
||||
renderer: &mut Renderer,
|
||||
bounds: Rectangle,
|
||||
_from_me: bool,
|
||||
color_value: Color,
|
||||
) {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: border::rounded(RADIUS),
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(color_value),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_placeholder(renderer: &mut Renderer, bounds: Rectangle, downloaded: bool) {
|
||||
let color_value = if downloaded {
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.14)
|
||||
} else {
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
|
||||
};
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: border::rounded(RADIUS),
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(color_value),
|
||||
);
|
||||
}
|
||||
|
||||
impl<Message> Widget<Message, Theme, Renderer> for Transcript<'_, Message>
|
||||
where
|
||||
Message: Clone,
|
||||
{
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<TranscriptState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(TranscriptState::default())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: Length::Fill,
|
||||
height: Length::Shrink,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
let width = limits.max().width.max(320.0);
|
||||
let state = tree.state.downcast_mut::<TranscriptState>();
|
||||
let key = DisplayListKey {
|
||||
width_px: width.round() as u32,
|
||||
show_sender: self.show_sender,
|
||||
fingerprint: self.fingerprint,
|
||||
};
|
||||
if state.cache_key != Some(key) {
|
||||
let trace_start =
|
||||
tracing::enabled!(target: "kordophone_cosmic::transcript", Level::TRACE)
|
||||
.then(Instant::now);
|
||||
state.display_list =
|
||||
build_display_list(&self.messages, self.show_sender, width, renderer);
|
||||
state.cache_key = Some(key);
|
||||
if let Some(start) = trace_start {
|
||||
tracing::trace!(
|
||||
target: "kordophone_cosmic::transcript",
|
||||
elapsed_us = start.elapsed().as_micros(),
|
||||
messages = self.messages.len(),
|
||||
items = state.display_list.items.len(),
|
||||
width_px = key.width_px,
|
||||
"rebuilt transcript display list"
|
||||
);
|
||||
}
|
||||
}
|
||||
layout::Node::new(Size::new(width, state.display_list.height))
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let Event::Mouse(mouse::Event::ButtonPressed(button)) = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(position) = cursor.position_in(layout.bounds()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let state = tree.state.downcast_mut::<TranscriptState>();
|
||||
if state.display_list.items.is_empty() {
|
||||
state.display_list = build_display_list(
|
||||
&self.messages,
|
||||
self.show_sender,
|
||||
layout.bounds().width,
|
||||
renderer,
|
||||
);
|
||||
}
|
||||
let display_list = &state.display_list;
|
||||
let point = Point::new(position.x, position.y);
|
||||
let Some(item) = display_list.hit(point) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let action = match (&item.kind, button) {
|
||||
(DisplayKind::Text { text, .. }, mouse::Button::Right) => {
|
||||
Some(TranscriptAction::CopyText(text.clone()))
|
||||
}
|
||||
(DisplayKind::Text { text, .. }, mouse::Button::Left) => {
|
||||
first_url(text).map(TranscriptAction::OpenUrl)
|
||||
}
|
||||
(
|
||||
DisplayKind::Image {
|
||||
attachment_guid, ..
|
||||
},
|
||||
mouse::Button::Left,
|
||||
) => Some(TranscriptAction::OpenAttachment(attachment_guid.clone())),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(action) = action {
|
||||
shell.publish((self.on_action)(action));
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let display_list = &tree.state.downcast_ref::<TranscriptState>().display_list;
|
||||
let trace_start = tracing::enabled!(target: "kordophone_cosmic::transcript", Level::TRACE)
|
||||
.then(Instant::now);
|
||||
let offset = Vector::new(layout.bounds().x, layout.bounds().y);
|
||||
let local_viewport = Rectangle::new(
|
||||
Point::new(viewport.x - offset.x, viewport.y - offset.y),
|
||||
viewport.size(),
|
||||
);
|
||||
let visible_range = display_list.visible_range(local_viewport);
|
||||
let visible_count = visible_range.len();
|
||||
renderer.with_translation(offset, |renderer| {
|
||||
for item in &display_list.items[visible_range] {
|
||||
let item_rect =
|
||||
Rectangle::new(Point::new(item.bounds.x, item.bounds.y), item.bounds.size());
|
||||
|
||||
match &item.kind {
|
||||
DisplayKind::Date { paragraph } => {
|
||||
renderer.fill_paragraph(
|
||||
paragraph,
|
||||
Point::new(item_rect.x, item_rect.y + DATE_PADDING / 2.0),
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.48),
|
||||
local_viewport,
|
||||
);
|
||||
}
|
||||
DisplayKind::Sender { paragraph } => {
|
||||
renderer.fill_paragraph(
|
||||
paragraph,
|
||||
Point::new(item_rect.x + TEXT_X_PADDING, item_rect.y + SENDER_PADDING),
|
||||
Color::from_rgba(1.0, 1.0, 1.0, 0.78),
|
||||
local_viewport,
|
||||
);
|
||||
}
|
||||
DisplayKind::Text {
|
||||
paragraph, from_me, ..
|
||||
} => {
|
||||
let bubble_color = if *from_me {
|
||||
outgoing_color(theme)
|
||||
} else {
|
||||
incoming_color()
|
||||
};
|
||||
draw_bubble_background(renderer, item_rect, *from_me, bubble_color);
|
||||
renderer.fill_paragraph(
|
||||
paragraph,
|
||||
Point::new(item.content_bounds.x, item.content_bounds.y),
|
||||
if *from_me {
|
||||
theme.cosmic().accent.on.into()
|
||||
} else {
|
||||
style.text_color
|
||||
},
|
||||
local_viewport,
|
||||
);
|
||||
}
|
||||
DisplayKind::Image {
|
||||
preview_handle,
|
||||
from_me,
|
||||
downloaded,
|
||||
..
|
||||
} => {
|
||||
let bubble_color = if *from_me {
|
||||
outgoing_color(theme)
|
||||
} else {
|
||||
incoming_color()
|
||||
};
|
||||
draw_bubble_background(renderer, item_rect, *from_me, bubble_color);
|
||||
if let Some(handle) = preview_handle {
|
||||
renderer.draw_image(
|
||||
image::Image {
|
||||
handle: handle.clone(),
|
||||
border_radius: border::Radius::from(RADIUS),
|
||||
filter_method: image::FilterMethod::Linear,
|
||||
rotation: cosmic::iced::Radians(0.0),
|
||||
opacity: 1.0,
|
||||
snap: true,
|
||||
},
|
||||
item_rect,
|
||||
item_rect,
|
||||
);
|
||||
} else {
|
||||
draw_placeholder(renderer, item_rect, *downloaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(start) = trace_start {
|
||||
tracing::trace!(
|
||||
target: "kordophone_cosmic::transcript",
|
||||
elapsed_us = start.elapsed().as_micros(),
|
||||
visible_items = visible_count,
|
||||
total_items = display_list.items.len(),
|
||||
viewport_y = local_viewport.y,
|
||||
viewport_height = local_viewport.height,
|
||||
"drew transcript visible range"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let Some(position) = cursor.position_in(layout.bounds()) else {
|
||||
return mouse::Interaction::default();
|
||||
};
|
||||
let display_list = &tree.state.downcast_ref::<TranscriptState>().display_list;
|
||||
if display_list
|
||||
.hit(Point::new(position.x, position.y))
|
||||
.is_some()
|
||||
{
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message> From<Transcript<'a, Message>> for Element<'a, Message>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
{
|
||||
fn from(value: Transcript<'a, Message>) -> Self {
|
||||
Element::new(value)
|
||||
}
|
||||
}
|
||||
2
gtk/dist/rpm/kordophone.spec
vendored
2
gtk/dist/rpm/kordophone.spec
vendored
@@ -1,5 +1,5 @@
|
||||
Name: kordophone
|
||||
Version: %{?app_version}%{!?app_version:1.3.0}
|
||||
Version: %{?app_version}%{!?app_version:1.4.5}
|
||||
Release: 1%{?dist}
|
||||
Summary: GTK4/Libadwaita client for Kordophone
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
project('kordophone', 'vala',
|
||||
version : '1.0.2',
|
||||
version : '1.4.5',
|
||||
meson_version : '>=0.56.0',
|
||||
default_options : ['warning_level=2']
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user