Skip to main content

fc_dioxus/components/
chat.rs

1//! The chat surface: a log of message groups, from chat.css.
2use crate::{
3    core::{
4        assets,
5        attrs::{
6            availability_attributes, component_attributes, full_text_title, truncated_attribute,
7            FcData, Kind,
8        },
9    },
10    prelude::*,
11    theme::use_stylesheets,
12};
13const CHAT: Bem = Bem::new("fc-chat");
14const MSG: Bem = Bem::new("fc-msg");
15const ACTIONS: Bem = Bem::new("fc-msg__actions");
16const PROSE: Bem = Bem::new("fc-prose");
17fc_modifier! { enum ActionsVisibility for ACTIONS { Hover => None, Persistent => "persistent" } }
18
19/// A conversation, as a `role="log"` so new messages are announced politely in order.
20#[component]
21pub fn Chat(
22    label: AccessibleName,
23    #[props(default)] class: Option<String>,
24    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
25    children: Element,
26) -> Element {
27    let attributes = component_attributes(
28        attributes,
29        ClassList::new(CHAT.block()).extend(class.as_deref()),
30        &["role", "aria-label"],
31    );
32    rsx! {
33        if use_stylesheets() {
34            document::Stylesheet { href: assets::COMPONENTS_CHAT_CSS }
35            // Message bodies are prose, styled by the editor's document rules.
36            document::Stylesheet { href: assets::COMPONENTS_EDITOR_CSS }
37        }
38        div { role: "log", aria_label: label.to_string(), ..attributes, {children} }
39    }
40}
41
42/// Consecutive messages shown together, e.g. one turn.
43#[component]
44pub fn ChatGroup(children: Element) -> Element {
45    rsx! { div { class: CHAT.element("group"), {children} } }
46}
47
48/// When a message was sent: a machine-readable instant and how people read it.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct SentAt {
51    /// The instant, written to `<time datetime>`.
52    pub at: Timestamp,
53    /// The displayed form, e.g. "09:12".
54    pub display: String,
55}
56
57/// One message: author, time, body, and optional actions.
58#[component]
59pub fn Message(
60    role: MessageRole,
61    state: Lifecycle,
62    author: Element,
63    #[props(default)] sent: Option<SentAt>,
64    /// [`MessageAction`]s.
65    #[props(default)]
66    actions: Option<Element>,
67    /// Keep actions visible instead of revealing them on hover and focus.
68    #[props(default)]
69    persistent_actions: bool,
70    /// The full text when the body shows only part of it; declares truncation.
71    #[props(default)]
72    clipped: Option<Clipped>,
73    #[props(default)] record: Record,
74    #[props(default)] class: Option<String>,
75    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
76    children: Element,
77) -> Element {
78    let mut attributes = component_attributes(
79        attributes,
80        ClassList::new(MSG.block()).extend(class.as_deref()),
81        &[],
82    );
83    attributes.extend(
84        FcData {
85            kind: Some(Kind::Message),
86            id: record.id.clone(),
87            seq: record.seq,
88            role: Some(role),
89            state: Some(state),
90            ts: record.ts.clone(),
91        }
92        .attributes(),
93    );
94    // The message declares truncation; the body it shortens carries the full text.
95    let title = clipped.as_ref().map(full_text_title).unwrap_or_default();
96    attributes.extend(
97        clipped
98            .as_ref()
99            .map(truncated_attribute)
100            .unwrap_or_default(),
101    );
102    let actions_class = ClassList::new(ACTIONS.block())
103        .with_modifier(
104            ACTIONS,
105            if persistent_actions {
106                ActionsVisibility::Persistent
107            } else {
108                ActionsVisibility::Hover
109            },
110        )
111        .to_string();
112    let body_class = ClassList::new(&MSG.element("body"))
113        .with(PROSE.block())
114        .to_string();
115    rsx! {
116        div { ..attributes,
117            div { class: MSG.element("meta"),
118                span { class: MSG.element("author"), {author} }
119                if let Some(sent) = sent { time { class: MSG.element("time"), datetime: "{sent.at}", "{sent.display}" } }
120            }
121            div { class: body_class, ..title, {children} }
122            if let Some(actions) = actions {
123                div { class: actions_class, role: "toolbar", aria_label: "Message actions", {actions} }
124            }
125        }
126    }
127}
128
129/// One action on a message. Icon-only actions take `icon_label`; otherwise the visible
130/// text is the name.
131#[component]
132pub fn MessageAction(
133    #[props(default)] icon_label: Option<AccessibleName>,
134    #[props(default)] pressed: Option<bool>,
135    #[props(default)] availability: Availability,
136    #[props(default)] onclick: Option<EventHandler<MouseEvent>>,
137    children: Element,
138) -> Element {
139    let onclick = availability.guard(onclick);
140    let state = availability_attributes(&availability);
141    rsx! {
142        button { r#type: "button", class: MSG.element("action"),
143            aria_label: icon_label.map(|l| l.to_string()),
144            aria_pressed: pressed.map(|p| p.to_string()),
145            onclick: move |event| if let Some(onclick) = onclick { onclick.call(event); },
146            ..state, {children}
147        }
148    }
149}
150
151/// One addressable unit of a message body: a text block, a code block, a tool call.
152#[component]
153pub fn MessagePart(children: Element) -> Element {
154    rsx! { div { class: MSG.element("part"), {children} } }
155}