update chat ui

This commit is contained in:
2026-09-15 20:05:27 +07:00
parent 2d7881e574
commit 8303b054f9
5 changed files with 467 additions and 41 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

+32
View File
@@ -0,0 +1,32 @@
use std::path::{Path, PathBuf};
use chat::FileAttachment;
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// A file attachment that has been uploaded, but not sent yet.
///
/// The local `path` is kept around so the composer can preview
/// the file without downloading and decrypting it again.
pub(crate) struct PendingFile {
pub file: FileAttachment,
pub path: PathBuf,
}
/// State of the encrypted file attachment of a message
pub(crate) enum DecryptedFile {
Loading,
Ready(PathBuf),
Failed(SharedString),
}
/// Result of an upload, either plain or encrypted
pub(crate) enum Uploaded {
Url(Url),
File(FileAttachment, PathBuf),
}
/// A `file://` url for a decrypted file, so it can be opened by the OS
pub(crate) fn file_url(path: &Path) -> String {
format!("file://{}", path.display())
}
+370 -37
View File
@@ -1,4 +1,5 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*; pub use actions::*;
@@ -21,7 +22,9 @@ use person::{Person, PersonRegistry};
use regex::Regex; use regex::Regex;
use settings::{AppSettings, SignerKind}; use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload}; use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
@@ -35,6 +38,7 @@ use ui::{
h_flex, v_flex, h_flex, v_flex,
}; };
use crate::file::*;
use crate::text::RenderedText; use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
@@ -46,6 +50,7 @@ static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap()); LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
mod actions; mod actions;
mod file;
mod text; mod text;
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> { pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
@@ -96,6 +101,12 @@ pub struct ChatPanel {
/// Media Attachment /// Media Attachment
attachments: Entity<Vec<Url>>, attachments: Entity<Vec<Url>>,
/// Uploaded, encrypted file attachments which are not sent yet
encrypted_attachments: Entity<Vec<PendingFile>>,
/// Decrypted attachments of file messages, by message id
decrypted_files: HashMap<EventId, DecryptedFile>,
/// Upload state /// Upload state
uploading: bool, uploading: bool,
@@ -110,6 +121,7 @@ impl ChatPanel {
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
// Define attachments and replies_to entities // Define attachments and replies_to entities
let attachments = cx.new(|_| vec![]); let attachments = cx.new(|_| vec![]);
let encrypted_attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new()); let replies_to = cx.new(|_| HashSet::new());
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new())); let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
@@ -185,6 +197,8 @@ impl ChatPanel {
subject_bar, subject_bar,
replies_to, replies_to,
attachments, attachments,
encrypted_attachments,
decrypted_files: HashMap::new(),
rendered_texts_by_id: BTreeMap::new(), rendered_texts_by_id: BTreeMap::new(),
reports_by_id, reports_by_id,
sent_ids: Arc::new(Mutex::new(Vec::new())), sent_ids: Arc::new(Mutex::new(Vec::new())),
@@ -370,21 +384,32 @@ impl ChatPanel {
} }
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get the message which includes all attachments // Get the message which includes all plain attachments
let content = self.get_input_value(cx); let content = self.get_input_value(cx);
// Get the replies to this message // Get the replies to this message
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect(); let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
// Return if message is empty // Uploaded files are sent as encrypted file messages
if content.trim().is_empty() { let files: Vec<FileAttachment> = self
.encrypted_attachments
.read(cx)
.iter()
.map(|pending| pending.file.clone())
.collect();
// Return if there is nothing to send
if content.trim().is_empty() && files.is_empty() {
window.push_notification("Cannot send an empty message", cx); window.push_notification("Cannot send an empty message", cx);
return; return;
} }
// If replying to exactly one message with only a valid emoji, // If replying to exactly one message with only a valid emoji,
// send as a reaction instead of a text message // send as a reaction instead of a text message
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty() if replies.len() == 1
&& EMOJI_RE.is_match(&content)
&& self.attachments.read(cx).is_empty()
&& files.is_empty()
{ {
for reply in &replies { for reply in &replies {
self.send_reaction(&content, reply, window, cx); self.send_reaction(&content, reply, window, cx);
@@ -393,7 +418,15 @@ impl ChatPanel {
return; return;
} }
self.send_message(&content, replies, false, window, cx); // Send the text part, including the plain attachment urls
if !content.trim().is_empty() {
self.send_message(&content, replies.clone(), false, window, cx);
}
// Send every file as its own encrypted file message
for file in files {
self.send_file(file, replies.clone(), window, cx);
}
} }
fn send_reaction( fn send_reaction(
@@ -426,29 +459,59 @@ impl ChatPanel {
return; return;
} }
let room = self.room.clone();
let content = value.to_string();
let sent_ids = self.sent_ids.clone();
// Upgrade room and create rumor + send task in a single read lock // Upgrade room and create rumor + send task in a single read lock
let Some(room_entity) = room.upgrade() else { let Some(room) = self.room.upgrade() else {
return; return;
}; };
// Create rumor and send task let outcome = room.read_with(cx, |room, cx| {
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| { let rumor = room.rumor(value, replies, reaction, cx)?;
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
let send_task = room.send(rumor.clone(), cx)?; let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task)) Some((rumor, send_task))
}) { });
Some(pair) => pair,
None => { match outcome {
window.push_notification("Failed to create message", cx); Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
return; None => window.push_notification("Failed to create message", cx),
} }
}
/// Send an encrypted file message (NIP-17 kind 15) to all members of the chat
fn send_file(
&mut self,
file: FileAttachment,
replies: Vec<EventId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(room) = self.room.upgrade() else {
return;
}; };
let outcome = room.read_with(cx, |room, cx| {
let rumor = room.file_rumor(file, replies, cx)?;
let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task))
});
match outcome {
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
None => window.push_notification("Failed to create message", cx),
}
}
/// Insert a rumor optimistically and track the send reports of its gift wraps
fn dispatch(
&mut self,
rumor: UnsignedEvent,
send_task: Task<Vec<SendReport>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = rumor.id.expect("rumor must have an id"); let id = rumor.id.expect("rumor must have an id");
let sent_ids = self.sent_ids.clone();
// Insert optimistic message and clear input // Insert optimistic message and clear input
if rumor.kind != Kind::Reaction { if rumor.kind != Kind::Reaction {
@@ -487,6 +550,10 @@ impl ChatPanel {
this.clear(); this.clear();
cx.notify(); cx.notify();
}); });
self.encrypted_attachments.update(cx, |this, cx| {
this.clear();
cx.notify();
});
self.replies_to.update(cx, |this, cx| { self.replies_to.update(cx, |this, cx| {
this.clear(); this.clear();
cx.notify(); cx.notify();
@@ -604,7 +671,7 @@ impl ChatPanel {
let Some(message) = self.message(id) else { let Some(message) = self.message(id) else {
return; return;
}; };
let content = message.content.to_string(); let content = message.preview().to_string();
let item = ClipboardItem::new_string(content); let item = ClipboardItem::new_string(content);
cx.write_to_clipboard(item); cx.write_to_clipboard(item);
@@ -630,6 +697,9 @@ impl ChatPanel {
// Get the user's configured blossom server // Get the user's configured blossom server
let server = AppSettings::get_file_server(cx); let server = AppSettings::get_file_server(cx);
// Encrypt attachments which are not part of a message being written
let encrypted = self.input.read(cx).value().trim().is_empty();
// Ask user for file upload // Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions { let path = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
@@ -646,24 +716,29 @@ impl ChatPanel {
let mut paths = path.await??.context("Not found")?; let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?; let path = paths.pop().context("No path")?;
// Upload via blossom client // Upload the file, encrypted when it is the whole message
match upload(server, path, cx).await { let result = if encrypted {
Ok(url) => { upload_encrypted(server, path.clone(), cx)
this.update_in(cx, |this, _window, cx| { .await
this.add_attachment(url, cx); .map(|file| Uploaded::File(file, path))
this.set_uploading(false, cx); } else {
})?; upload(server, path, cx).await.map(Uploaded::Url)
} };
Err(e) => {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx); this.set_uploading(false, cx);
match result {
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
Err(e) => {
window.push_notification( window.push_notification(
Notification::error(e.to_string()).autohide(false), Notification::error(e.to_string()).autohide(false),
cx, cx,
); );
})?; }
} }
} })?;
Ok(()) Ok(())
})); }));
@@ -690,6 +765,88 @@ impl ChatPanel {
}); });
} }
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
self.encrypted_attachments.update(cx, |this, cx| {
this.push(PendingFile { file, path });
cx.notify();
});
}
fn remove_pending_file(&mut self, url: &Url, cx: &mut Context<Self>) {
self.encrypted_attachments.update(cx, |this, cx| {
if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) {
this.remove(ix);
cx.notify();
}
});
}
/// Download and decrypt the attachment of a file message for preview
fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context<Self>) {
self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = download_and_decrypt_to_file(&file, cx).await;
this.update(cx, |this, cx| {
match result {
Ok(path) => {
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
}
Err(e) => {
this.decrypted_files
.insert(id, DecryptedFile::Failed(e.to_string().into()));
}
}
cx.notify();
})?;
Ok(())
}));
}
/// Decrypt the attachment of a file message and open it with the OS
fn open_file(
&mut self,
id: EventId,
file: FileAttachment,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.decrypted_files.get(&id) {
Some(DecryptedFile::Ready(path)) => {
cx.open_url(&file_url(path));
return;
}
Some(DecryptedFile::Loading) => return,
_ => {}
};
self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = download_and_decrypt_to_file(&file, cx).await;
this.update_in(cx, |this, _window, cx| {
match result {
Ok(path) => {
cx.open_url(&file_url(&path));
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
}
Err(e) => {
this.decrypted_files
.insert(id, DecryptedFile::Failed(e.to_string().into()));
}
}
cx.notify();
})?;
Ok(())
}));
}
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person { fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
persons.read(cx).get(public_key, cx) persons.read(cx).get(public_key, cx)
@@ -929,6 +1086,16 @@ impl ChatPanel {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let file = self.messages.get(ix).and_then(|message| {
let file = message.file.clone()?;
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
.then_some((message.id, file))
});
if let Some((id, file)) = file {
self.load_file(id, file, cx);
}
if let Some(message) = self.messages.get(ix) { if let Some(message) = self.messages.get(ix) {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let show_author = self.is_group_start(ix); let show_author = self.is_group_start(ix);
@@ -1016,8 +1183,11 @@ impl ChatPanel {
.when(has_replies, |this| { .when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx)) this.children(self.render_message_replies(replies, cx))
}) })
.child(rendered_text) .when(message.file.is_none(), |this| this.child(rendered_text))
.child(self.render_media(&message.media, cx)) .child(self.render_media(&message.media, cx))
.when_some(message.file.as_ref(), |this, file| {
this.child(self.render_message_file(&id, file, cx))
})
.when(has_reactions, |this| { .when(has_reactions, |this| {
this.child(self.render_reactions(&id, cx)) this.child(self.render_reactions(&id, cx))
}), }),
@@ -1123,7 +1293,7 @@ impl ChatPanel {
.w_full() .w_full()
.text_ellipsis() .text_ellipsis()
.line_clamp(1) .line_clamp(1)
.child(SharedString::from(&message.content)), .child(message.preview()),
) )
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click({ .on_click({
@@ -1464,6 +1634,168 @@ impl ChatPanel {
items items
} }
/// Render the encrypted file attachment of a message
fn render_message_file(
&self,
id: &EventId,
file: &FileAttachment,
cx: &Context<Self>,
) -> AnyElement {
let state = self.decrypted_files.get(id);
if let Some(path) = state
.and_then(|state| match state {
DecryptedFile::Ready(path) => Some(path),
_ => None,
})
.filter(|_| file.is_image())
{
return div()
.child(
img(path.clone())
.border_1()
.border_color(cx.theme().border_variant)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
)
.into_any_element();
}
let label = match state {
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
Some(DecryptedFile::Failed(error)) => error.clone(),
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
None => SharedString::from("Click to decrypt"),
};
self.render_file_chip(id, file, label, cx)
}
/// Render an encrypted file as a chip which decrypts and opens it on click
fn render_file_chip(
&self,
id: &EventId,
file: &FileAttachment,
label: SharedString,
cx: &Context<Self>,
) -> AnyElement {
div()
.id(SharedString::from(format!("file-{id}")))
.flex()
.items_center()
.gap_2()
.py_1()
.px_2()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().surface_background))
.child(
Icon::new(IconName::Lock)
.small()
.text_color(cx.theme().text_placeholder),
)
.child(
v_flex()
.flex_1()
.overflow_hidden()
.text_sm()
.child(
div()
.text_ellipsis()
.line_clamp(1)
.child(file.display_name()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(label),
),
)
.on_click({
let file = file.clone();
let id = *id;
cx.listener(move |this, _, window, cx| {
this.open_file(id, file.clone(), window, cx);
})
})
.into_any_element()
}
/// Render an uploaded, encrypted file which is not sent yet
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
let file = &pending.file;
div()
.id(SharedString::from(file.url.to_string()))
.relative()
.flex()
.items_center()
.gap_2()
.p_1()
.pr_2()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius)
.when(file.is_image(), |this| {
this.child(
img(pending.path.clone())
.size_8()
.rounded(cx.theme().radius)
.object_fit(ObjectFit::Cover),
)
})
.child(
v_flex()
.text_sm()
.child(
div()
.max_w(px(160.))
.text_ellipsis()
.line_clamp(1)
.child(file.display_name()),
)
.child(
h_flex()
.gap_1()
.items_center()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(Icon::new(IconName::Lock).size_2())
.child("End-to-end encrypted"),
),
)
.child(
Button::new(SharedString::from(format!("remove-{}", file.url)))
.icon(IconName::Close)
.xsmall()
.ghost()
.on_click({
let url = file.url.clone();
cx.listener(move |this, _, _, cx| {
this.remove_pending_file(&url, cx);
})
}),
)
}
fn render_pending_file_list(
&self,
_window: &Window,
cx: &Context<Self>,
) -> impl IntoIterator<Item = impl IntoElement> {
let mut items = vec![];
for pending in self.encrypted_attachments.read(cx).iter() {
items.push(self.render_pending_file(pending, cx));
}
items
}
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement { fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
if let Some(text) = self.message(id) { if let Some(text) = self.message(id) {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
@@ -1512,7 +1844,7 @@ impl ChatPanel {
.text_sm() .text_sm()
.text_ellipsis() .text_ellipsis()
.line_clamp(1) .line_clamp(1)
.child(SharedString::from(&text.content)), .child(text.preview()),
) )
} else { } else {
div() div()
@@ -1701,6 +2033,7 @@ impl Render for ChatPanel {
.w_full() .w_full()
.gap_1p5() .gap_1p5()
.children(self.render_attachment_list(window, cx)) .children(self.render_attachment_list(window, cx))
.children(self.render_pending_file_list(window, cx))
.children(self.render_reply_list(window, cx)) .children(self.render_reply_list(window, cx))
.child( .child(
h_flex() h_flex()
+60 -4
View File
@@ -9,16 +9,15 @@ use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_S
use futures::AsyncReadExt; use futures::AsyncReadExt;
use gpui::http_client::AsyncBody; use gpui::http_client::AsyncBody;
use gpui::{AsyncApp, SharedString}; use gpui::{AsyncApp, SharedString};
use nostr::nips::nip94::Sha256Hash;
use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio; use gpui_tokio::Tokio;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path; use mime_guess::from_path;
use nostr::nips::nip94::Sha256Hash;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use nostr_blossom::prelude::*; use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
pub const ALGORITHM: &str = "aes-gcm"; pub const ALGORITHM: &str = "aes-gcm";
@@ -243,6 +242,63 @@ pub async fn download_and_decrypt(
decrypt(&data, key, nonce) decrypt(&data, key, nonce)
} }
/// Download and decrypt a file attachment into a temporary file.
///
/// The same attachment always maps to the same path, so callers can render the
/// result directly (e.g. with `img`) without downloading it more than once.
#[cfg(not(target_arch = "wasm32"))]
pub async fn download_and_decrypt_to_file(
file: &FileAttachment,
cx: &AsyncApp,
) -> Result<PathBuf, Error> {
let name = file
.sha256
.clone()
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
let extension = mime_guess::get_mime_extensions_str(&file.mime)
.and_then(|extensions| extensions.first())
.copied()
.unwrap_or("bin");
let path = std::env::temp_dir()
.join("coop-files")
.join(format!("{name}.{extension}"));
if smol::fs::metadata(&path).await.is_ok() {
return Ok(path);
}
let data = download_and_decrypt(
&file.url,
&file.key,
&file.nonce,
file.sha256.as_deref(),
cx,
)
.await?;
let Some(parent) = path.parent() else {
bail!("Invalid file path");
};
smol::fs::create_dir_all(parent).await?;
// Write under a temporary name first, so an interrupted download is never reused
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
Ok(path)
}
#[cfg(target_arch = "wasm32")]
pub async fn download_and_decrypt_to_file(
_file: &FileAttachment,
_cx: &AsyncApp,
) -> Result<PathBuf, Error> {
Err(anyhow!("File download not supported on web"))
}
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
tags.iter() tags.iter()
.find(|tag| tag.kind() == name) .find(|tag| tag.kind() == name)
+2
View File
@@ -46,6 +46,7 @@ pub enum IconName {
InboxFill, InboxFill,
Link, Link,
Loader, Loader,
Lock,
Moon, Moon,
Plus, Plus,
PlusCircle, PlusCircle,
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
Self::InboxFill => "icons/inbox-fill.svg", Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg", Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg", Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Moon => "icons/moon.svg", Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg", Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg", Self::PlusCircle => "icons/plus-circle.svg",