feat: rewrite the nip-4e implementation (#1)
Some checks are pending
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

Make NIP-4e a core feature, not an optional feature.

Note:
- The UI is broken and needs to be updated in a separate PR.

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-01-13 16:00:08 +08:00
parent bb455871e5
commit 75c3783522
50 changed files with 2818 additions and 3458 deletions

View File

@@ -9,8 +9,6 @@ state = { path = "../state" }
ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
account = { path = "../account" }
encryption = { path = "../encryption" }
person = { path = "../person" }
chat = { path = "../chat" }
settings = { path = "../settings" }

View File

@@ -1,4 +1,3 @@
use encryption::SignerKind;
use gpui::Action;
use nostr_sdk::prelude::*;
use serde::Deserialize;
@@ -7,10 +6,6 @@ use serde::Deserialize;
#[action(namespace = chat, no_json)]
pub struct SeenOn(pub EventId);
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub struct SetSigner(pub SignerKind);
/// Define a open public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]

View File

@@ -2,22 +2,21 @@ use std::collections::HashSet;
use std::time::Duration;
pub use actions::*;
use chat::{Message, RenderedMessage, Room, RoomKind, RoomSignal, SendOptions, SendReport};
use common::{nip96_upload, RenderedProfile, RenderedTimestamp};
use encryption::SignerKind;
use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport};
use common::{nip96_upload, RenderedTimestamp};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext,
ClipboardItem, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit,
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, Window,
ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement,
PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement,
Styled, StyledImage, Subscription, Task, WeakEntity, Window,
};
use gpui_tokio::Tokio;
use indexset::{BTreeMap, BTreeSet};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use person::{Person, PersonRegistry};
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::fs;
@@ -28,7 +27,6 @@ use ui::button::{Button, ButtonVariants};
use ui::context_menu::ContextMenuExt;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::notification::Notification;
use ui::popup_menu::PopupMenuExt;
use ui::{
@@ -41,43 +39,54 @@ use crate::text::RenderedText;
mod actions;
mod emoji;
mod subject;
mod text;
pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx))
}
/// Chat Panel
pub struct ChatPanel {
// Chat Room
room: Entity<Room>,
// Messages
list_state: ListState,
messages: BTreeSet<Message>,
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
reports_by_id: BTreeMap<EventId, Vec<SendReport>>,
// New Message
input: Entity<InputState>,
options: Entity<SendOptions>,
replies_to: Entity<HashSet<EventId>>,
// Media Attachment
attachments: Entity<Vec<Url>>,
uploading: bool,
// Panel
id: SharedString,
focus_handle: FocusHandle,
image_cache: Entity<RetainAllImageCache>,
_subscriptions: SmallVec<[Subscription; 3]>,
_tasks: SmallVec<[Task<()>; 2]>,
/// Chat Room
room: WeakEntity<Room>,
/// Message list state
list_state: ListState,
/// All messages
messages: BTreeSet<Message>,
/// Mapping message ids to their rendered texts
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
/// Mapping message ids to their reports
reports_by_id: BTreeMap<EventId, Vec<SendReport>>,
/// Input state
input: Entity<InputState>,
/// Replies to
replies_to: Entity<HashSet<EventId>>,
/// Media Attachment
attachments: Entity<Vec<Url>>,
/// Upload state
uploading: bool,
/// Async operations
tasks: SmallVec<[Task<()>; 2]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl ChatPanel {
pub fn new(room: Entity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Message...")
@@ -88,45 +97,64 @@ impl ChatPanel {
let attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new());
let options = cx.new(|_| SendOptions::default());
let id = room.read(cx).id.to_string().into();
let messages = BTreeSet::from([Message::system()]);
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
let connect = room.read(cx).connect(cx);
let get_messages = room.read(cx).get_messages(cx);
let id: SharedString = room
.read_with(cx, |this, _cx| this.id.to_string().into())
.unwrap_or("Unknown".into());
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
tasks.push(
// Get messaging relays and encryption keys announcement for each member
cx.background_spawn(async move {
if let Err(e) = connect.await {
log::error!("Failed to initialize room: {}", e);
}
}),
);
if let Ok(connect) = room.read_with(cx, |this, cx| this.connect(cx)) {
tasks.push(
// Get messaging relays and encryption keys announcement for each member
cx.background_spawn(async move {
if let Err(e) = connect.await {
log::error!("Failed to initialize room: {}", e);
}
}),
);
};
tasks.push(
// Load all messages belonging to this room
cx.spawn_in(window, async move |this, cx| {
let result = get_messages.await;
if let Ok(get_messages) = room.read_with(cx, |this, cx| this.get_messages(cx)) {
tasks.push(
// Load all messages belonging to this room
cx.spawn_in(window, async move |this, cx| {
let result = get_messages.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(events) => {
this.insert_messages(events, cx);
this.update_in(cx, |this, window, cx| {
match result {
Ok(events) => {
this.insert_messages(&events, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
}),
);
}
if let Some(room) = room.upgrade() {
subscriptions.push(
// Subscribe to room events
cx.subscribe_in(&room, window, move |this, _room, event, window, cx| {
match event {
RoomEvent::Incoming(message) => {
this.insert_message(message, false, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
RoomEvent::Reload => {
this.load_messages(window, cx);
}
};
})
.ok();
}),
);
}),
);
}
subscriptions.push(
// Subscribe to input events
@@ -141,47 +169,6 @@ impl ChatPanel {
),
);
subscriptions.push(
// Subscribe to room events
cx.subscribe_in(&room, window, move |this, _, signal, window, cx| {
match signal {
RoomSignal::NewMessage((gift_wrap_id, event)) => {
let nostr = NostrRegistry::global(cx);
let tracker = nostr.read(cx).tracker();
let gift_wrap_id = gift_wrap_id.to_owned();
let message = Message::user(event.clone());
cx.spawn_in(window, async move |this, cx| {
let tracker = tracker.read().await;
this.update_in(cx, |this, _window, cx| {
if !tracker.sent_ids().contains(&gift_wrap_id) {
this.insert_message(message, false, cx);
}
})
.ok();
})
.detach();
}
RoomSignal::Refresh => {
this.load_messages(window, cx);
}
};
}),
);
subscriptions.push(
// Observe when user close chat panel
cx.on_release_in(window, move |this, window, cx| {
this.messages.clear();
this.rendered_texts_by_id.clear();
this.reports_by_id.clear();
this.image_cache.update(cx, |this, cx| {
this.clear(window, cx);
});
}),
);
Self {
id,
messages,
@@ -190,30 +177,26 @@ impl ChatPanel {
input,
replies_to,
attachments,
options,
rendered_texts_by_id: BTreeMap::new(),
reports_by_id: BTreeMap::new(),
uploading: false,
image_cache: RetainAllImageCache::new(cx),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
_tasks: tasks,
tasks,
}
}
/// Load all messages belonging to this room
fn load_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let get_messages = self.room.read(cx).get_messages(cx);
self._tasks.push(
// Run the task in the background
cx.spawn_in(window, async move |this, cx| {
if let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = get_messages.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(events) => {
this.insert_messages(events, cx);
this.insert_messages(&events, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
@@ -221,12 +204,13 @@ impl ChatPanel {
};
})
.ok();
}),
);
}));
}
}
/// Get user input content and merged all attachments
fn input_content(&self, cx: &Context<Self>) -> String {
// Get input's value
let mut content = self.input.read(cx).value().trim().to_string();
// Get all attaches and merge its with message
@@ -260,26 +244,20 @@ impl ChatPanel {
return;
}
// Temporary disable the message input
self.input.update(cx, |this, cx| {
this.set_loading(false, cx);
this.set_disabled(false, cx);
this.set_value("", window, cx);
});
// Get the current room entity
let Some(room) = self.room.upgrade().map(|this| this.read(cx)) else {
return;
};
// Get replies_to if it's present
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
// Get the current room entity
let room = self.room.read(cx);
let opts = self.options.read(cx);
// Create a temporary message for optimistic update
let rumor = room.create_message(&content, replies.as_ref(), cx);
let rumor_id = rumor.id.unwrap();
// Create a task for sending the message in the background
let send_message = room.send_message(&rumor, opts, cx);
let send_message = room.send_message(&rumor, cx);
// Optimistically update message list
cx.spawn_in(window, async move |this, cx| {
@@ -290,29 +268,32 @@ impl ChatPanel {
// Update the message list and reset the states
this.update_in(cx, |this, window, cx| {
this.insert_message(Message::user(rumor), true, cx);
this.remove_all_replies(cx);
this.remove_all_attachments(cx);
// Reset the input to its default state
this.input.update(cx, |this, cx| {
this.set_loading(false, cx);
this.set_disabled(false, cx);
this.set_value("", window, cx);
});
// Update the message list
this.insert_message(&rumor, true, cx);
})
.ok();
})
.detach();
self._tasks.push(
// Continue sending the message in the background
cx.spawn_in(window, async move |this, cx| {
let result = send_message.await;
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = send_message.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(reports) => {
// Update room's status
this.room.update(cx, |this, cx| {
this.update_in(cx, |this, window, cx| {
match result {
Ok(reports) => {
// Update room's status
this.room
.update(cx, |this, cx| {
if this.kind != RoomKind::Ongoing {
// Update the room kind to ongoing,
// but keep the room kind if send failed
@@ -321,50 +302,21 @@ impl ChatPanel {
cx.notify();
}
}
});
})
.ok();
// Insert the sent reports
this.reports_by_id.insert(rumor_id, reports);
// Insert the sent reports
this.reports_by_id.insert(rumor_id, reports);
cx.notify();
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
cx.notify();
}
})
.ok();
}),
);
}
/// Resend a failed message
#[allow(dead_code)]
fn resend_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
if let Some(reports) = self.reports_by_id.get(id).cloned() {
let id_clone = id.to_owned();
let resend = self.room.read(cx).resend_message(reports, cx);
cx.spawn_in(window, async move |this, cx| {
let result = resend.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(reports) => {
this.reports_by_id.entry(id_clone).and_modify(|this| {
*this = reports;
});
cx.notify();
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
Err(e) => {
window.push_notification(e.to_string(), cx);
}
}
})
.detach();
}
.ok();
}));
}
/// Insert a message into the chat panel
@@ -390,21 +342,13 @@ impl ChatPanel {
}
/// Convert and insert a vector of nostr events into the chat panel
fn insert_messages(&mut self, events: Vec<UnsignedEvent>, cx: &mut Context<Self>) {
for event in events {
let m = Message::user(event);
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
for event in events.iter() {
// Bulk inserting messages, so no need to scroll to the latest message
self.insert_message(m, false, cx);
self.insert_message(event, false, cx);
}
}
/// Insert a warning message into the chat panel
#[allow(dead_code)]
fn insert_warning(&mut self, content: impl Into<String>, cx: &mut Context<Self>) {
let m = Message::warning(content.into());
self.insert_message(m, true, cx);
}
/// Check if a message failed to send by its ID
fn is_sent_failed(&self, id: &EventId) -> bool {
self.reports_by_id
@@ -436,15 +380,6 @@ impl ChatPanel {
})
}
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Profile {
let persons = PersonRegistry::global(cx);
persons.read(cx).get_person(public_key, cx)
}
fn signer_kind(&self, cx: &App) -> SignerKind {
self.options.read(cx).signer_kind
}
fn scroll_to(&self, id: EventId) {
if let Some(ix) = self.messages.iter().position(|m| {
if let Message::User(msg) = m {
@@ -511,25 +446,19 @@ impl ChatPanel {
Some(url)
});
if let Ok(task) = upload {
if let Ok(task) = upload.await {
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})
.ok();
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
this.update_in(cx, |this, window, cx| {
match result {
Ok(Some(url)) => {
this.update_in(cx, |this, _window, cx| {
match task {
Some(url) => {
this.add_attachment(url, cx);
this.set_uploading(false, cx);
}
Ok(None) => {
this.set_uploading(false, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
None => {
this.set_uploading(false, cx);
}
};
@@ -570,6 +499,11 @@ impl ChatPanel {
});
}
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(public_key, cx)
}
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
v_flex()
.id(ix)
@@ -660,7 +594,6 @@ impl ChatPanel {
text: AnyElement,
cx: &Context<Self>,
) -> AnyElement {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
let id = message.id;
@@ -691,7 +624,7 @@ impl ChatPanel {
this.child(
div()
.id(SharedString::from(format!("{ix}-avatar")))
.child(Avatar::new(author.avatar(proxy)).size(rems(2.)))
.child(Avatar::new(author.avatar()).size(rems(2.)))
.context_menu(move |this, _window, _cx| {
let view = Box::new(OpenPublicKey(public_key));
let copy = Box::new(CopyPublicKey(public_key));
@@ -716,7 +649,7 @@ impl ChatPanel {
div()
.font_semibold()
.text_color(cx.theme().text)
.child(author.display_name()),
.child(author.name()),
)
.child(message.created_at.to_human_time())
.when_some(is_sent_success, |this, status| {
@@ -773,7 +706,7 @@ impl ChatPanel {
.child(
div()
.text_color(cx.theme().text_accent)
.child(author.display_name()),
.child(author.name()),
)
.child(
div()
@@ -854,9 +787,9 @@ impl ChatPanel {
fn render_report(report: &SendReport, cx: &App) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get_person(&report.receiver, cx);
let name = profile.display_name();
let avatar = profile.avatar(true);
let profile = persons.read(cx).get(&report.receiver, cx);
let name = profile.name();
let avatar = profile.avatar();
v_flex()
.gap_2()
@@ -1116,7 +1049,7 @@ impl ChatPanel {
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
if let Some(text) = self.message(id) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get_person(&text.author, cx);
let profile = persons.read(cx).get(&text.author, cx);
div()
.w_full()
@@ -1139,7 +1072,7 @@ impl ChatPanel {
.child(
div()
.text_color(cx.theme().text_accent)
.child(profile.display_name()),
.child(profile.name()),
),
)
.child(
@@ -1181,126 +1114,6 @@ impl ChatPanel {
items
}
fn subject_button(&self, cx: &App) -> Button {
let room = self.room.downgrade();
let subject = self
.room
.read(cx)
.subject
.as_ref()
.map(|subject| subject.to_string());
Button::new("subject")
.icon(IconName::Edit)
.tooltip("Change the subject of the conversation")
.on_click(move |_, window, cx| {
let view = subject::init(subject.clone(), window, cx);
let room = room.clone();
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
let room = room.clone();
let weak_view = weak_view.clone();
this.confirm()
.title("Change the subject of the conversation")
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text("Change"))
.on_ok(move |_, _window, cx| {
if let Ok(subject) =
weak_view.read_with(cx, |this, cx| this.new_subject(cx))
{
room.update(cx, |this, cx| {
this.set_subject(subject, cx);
})
.ok();
}
// true to close the modal
true
})
});
})
}
fn reload_button(&self, _cx: &App) -> Button {
let room = self.room.downgrade();
Button::new("reload")
.icon(IconName::Refresh)
.tooltip("Reload")
.on_click(move |_ev, window, cx| {
_ = room.update(cx, |this, cx| {
this.emit_refresh(cx);
window.push_notification("Reloaded", cx);
});
})
}
fn on_open_seen_on(&mut self, ev: &SeenOn, window: &mut Window, cx: &mut Context<Self>) {
let id = ev.0;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let tracker = nostr.read(cx).tracker();
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let tracker = tracker.read().await;
let mut relays: Vec<RelayUrl> = vec![];
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.event(id)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
if let Some(Ok(id)) = event.tags.identifier().map(EventId::parse) {
if let Some(urls) = tracker.seen_on_relays.get(&id).cloned() {
relays.extend(urls);
}
}
}
Ok(relays)
});
cx.spawn_in(window, async move |_, cx| {
if let Ok(urls) = task.await {
cx.update(|window, cx| {
window.open_modal(cx, move |this, _window, cx| {
this.show_close(true)
.title(SharedString::from("Seen on"))
.child(v_flex().pb_4().gap_2().children({
let mut items = Vec::with_capacity(urls.len());
for url in urls.clone().into_iter() {
items.push(
h_flex()
.h_8()
.px_2()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.font_semibold()
.text_xs()
.child(SharedString::from(url.to_string())),
)
}
items
}))
});
})
.ok();
}
})
.detach();
}
fn on_set_encryption(&mut self, ev: &SetSigner, _: &mut Window, cx: &mut Context<Self>) {
self.options.update(cx, move |this, cx| {
this.signer_kind = ev.0;
cx.notify();
});
}
}
impl Panel for ChatPanel {
@@ -1309,24 +1122,18 @@ impl Panel for ChatPanel {
}
fn title(&self, cx: &App) -> AnyElement {
self.room.read_with(cx, |this, cx| {
let proxy = AppSettings::get_proxy_user_avatars(cx);
let label = this.display_name(cx);
let url = this.display_image(proxy, cx);
self.room
.read_with(cx, |this, cx| {
let label = this.display_name(cx);
let url = this.display_image(cx);
h_flex()
.gap_1p5()
.child(Avatar::new(url).size(rems(1.25)))
.child(label)
.into_any()
})
}
fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec<Button> {
let subject_button = self.subject_button(cx);
let reload_button = self.reload_button(cx);
vec![subject_button, reload_button]
h_flex()
.gap_1p5()
.child(Avatar::new(url).size(rems(1.25)))
.child(label)
.into_any_element()
})
.unwrap_or(div().child("Unknown").into_any_element())
}
}
@@ -1340,11 +1147,7 @@ impl Focusable for ChatPanel {
impl Render for ChatPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let kind = self.signer_kind(cx);
v_flex()
.on_action(cx.listener(Self::on_open_seen_on))
.on_action(cx.listener(Self::on_set_encryption))
.image_cache(self.image_cache.clone())
.size_full()
.child(
@@ -1399,31 +1202,7 @@ impl Render for ChatPanel {
.large(),
),
)
.child(TextInput::new(&self.input))
.child(
Button::new("encryptions")
.icon(IconName::Encryption)
.ghost()
.large()
.popup_menu(move |this, _window, _cx| {
this.label("Encrypt by:")
.menu_with_check(
"Encryption Key",
matches!(kind, SignerKind::Encryption),
Box::new(SetSigner(SignerKind::Encryption)),
)
.menu_with_check(
"User's Identity",
matches!(kind, SignerKind::User),
Box::new(SetSigner(SignerKind::User)),
)
.menu_with_check(
"Auto",
matches!(kind, SignerKind::Auto),
Box::new(SetSigner(SignerKind::Auto)),
)
}),
),
.child(TextInput::new(&self.input)),
),
),
)

View File

@@ -1,7 +1,6 @@
use std::ops::Range;
use std::sync::Arc;
use common::RenderedProfile;
use gpui::{
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
StyledText, UnderlineStyle, Window,
@@ -254,8 +253,8 @@ fn render_pubkey(
cx: &App,
) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get_person(&public_key, cx);
let display_name = format!("@{}", profile.display_name());
let profile = persons.read(cx).get(&public_key, cx);
let display_name = format!("@{}", profile.name());
text.replace_range(range.clone(), &display_name);