1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
//! Helpers for making Pango-compatible strings from inline HTML.

use std::fmt::Write;

use ruma::html::{
    matrix::{AnchorUri, MatrixElement, SpanData},
    Children, NodeData, NodeRef,
};
use tracing::debug;

use crate::{
    components::Pill,
    prelude::*,
    session::model::Room,
    utils::string::{Linkifier, PangoStrMutExt},
};

/// Helper type to construct a Pango-compatible string from inline HTML nodes.
#[derive(Debug)]
pub(super) struct InlineHtmlBuilder<'a> {
    /// Whether this string should be on a single line.
    single_line: bool,
    /// Whether to append an ellipsis at the end of the string.
    ellipsis: bool,
    /// The mentions detection setting and results.
    mentions: MentionsMode<'a>,
    /// The inner string.
    inner: String,
    /// Whether this string was truncated because at the first newline.
    truncated: bool,
}

impl<'a> InlineHtmlBuilder<'a> {
    /// Constructs a new inline HTML string builder for the given room.
    ///
    /// If `single_line` is set to `true`, the string will be ellipsized at the
    /// first line break.
    ///
    /// If `ellipsis` is set to `true`, and ellipsis will be added at the end of
    /// the string.
    pub(super) fn new(single_line: bool, ellipsis: bool) -> Self {
        Self {
            single_line,
            ellipsis,
            mentions: MentionsMode::default(),
            inner: String::new(),
            truncated: false,
        }
    }

    /// Enable mentions detection in the given room.
    ///
    /// If `detect_at_room` is `true`, it will also try to detect `@room`
    /// mentions.
    pub(super) fn detect_mentions(mut self, room: &'a Room, detect_at_room: bool) -> Self {
        self.mentions = MentionsMode::WithMentions {
            room,
            pills: Vec::new(),
            detect_at_room,
        };
        self
    }

    /// Append and consume the given sender name for an emote, if it is set.
    pub(super) fn append_emote_with_name(mut self, name: &mut Option<&str>) -> Self {
        self.inner.maybe_append_emote_name(name);
        self
    }

    /// Export the Pango-compatible string and the [`Pill`]s that were
    /// constructed, if any.
    pub(super) fn build(self) -> (String, Option<Vec<Pill>>) {
        let mut inner = self.inner;
        let ellipsis = self.ellipsis | self.truncated;

        if ellipsis {
            inner.append_ellipsis();
        } else {
            inner.truncate_end_whitespaces();
        }

        let pills = if let MentionsMode::WithMentions { pills, .. } = self.mentions {
            (!pills.is_empty()).then_some(pills)
        } else {
            None
        };

        (inner, pills)
    }

    /// Construct the string with the given inline nodes by converting them to
    /// Pango markup.
    ///
    /// Returns the Pango-compatible string and the [`Pill`]s that were
    /// constructed, if any.
    pub(super) fn build_with_nodes(
        mut self,
        nodes: impl IntoIterator<Item = NodeRef<'a>>,
    ) -> (String, Option<Vec<Pill>>) {
        self.append_nodes(nodes, true);
        self.build()
    }

    /// Construct the string by traversing the nodes an returning only the text
    /// it contains.
    ///
    /// Node that markup contained in the text is not escaped and newlines are
    /// not removed.
    pub(super) fn build_with_nodes_text(
        mut self,
        nodes: impl IntoIterator<Item = NodeRef<'a>>,
    ) -> String {
        self.append_nodes_text(nodes);

        let (inner, _) = self.build();
        inner
    }

    /// Append the given inline node by converting it to Pango markup.
    fn append_node(&mut self, node: NodeRef<'a>, should_linkify: bool) {
        match node.data() {
            NodeData::Element(data) => {
                let data = data.to_matrix();
                match data.element {
                    MatrixElement::Del | MatrixElement::S => {
                        self.append_tags_and_children("s", node.children(), should_linkify);
                    }
                    MatrixElement::A(anchor) => {
                        // First, check if it's a mention, if we detect mentions.
                        if let Some(uri) = &anchor.href {
                            if let MentionsMode::WithMentions { pills, room, .. } =
                                &mut self.mentions
                            {
                                if let Some(pill) = self.inner.maybe_append_mention(uri, room) {
                                    pills.push(pill);

                                    return;
                                }
                            }
                        }

                        // It's not a mention, render the link, if it has a URI.
                        let mut has_opening_tag = false;

                        if let Some(uri) = &anchor.href {
                            has_opening_tag = self.append_link_opening_tag_from_anchor_uri(uri)
                        }

                        // Always render the children.
                        for node in node.children() {
                            // Don't try to linkify text if we render the element, it does not make
                            // sense to nest links.
                            self.append_node(node, !has_opening_tag && should_linkify);
                        }

                        if has_opening_tag {
                            self.inner.push_str("</a>");
                        }
                    }
                    MatrixElement::Sup => {
                        self.append_tags_and_children("sup", node.children(), should_linkify);
                    }
                    MatrixElement::Sub => {
                        self.append_tags_and_children("sub", node.children(), should_linkify);
                    }
                    MatrixElement::B | MatrixElement::Strong => {
                        self.append_tags_and_children("b", node.children(), should_linkify);
                    }
                    MatrixElement::I | MatrixElement::Em => {
                        self.append_tags_and_children("i", node.children(), should_linkify);
                    }
                    MatrixElement::U => {
                        self.append_tags_and_children("u", node.children(), should_linkify);
                    }
                    MatrixElement::Code(_) => {
                        // Don't try to linkify text, it does not make sense to detect links inside
                        // code.
                        self.append_tags_and_children("tt", node.children(), false);
                    }
                    MatrixElement::Br => {
                        if self.single_line {
                            self.truncated = true;
                        } else {
                            self.inner.push('\n');
                        }
                    }
                    MatrixElement::Span(span) => {
                        self.append_span(&span, node.children(), should_linkify);
                    }
                    element => {
                        debug!("Unexpected HTML inline element: {element:?}");
                        self.append_nodes(node.children(), should_linkify);
                    }
                }
            }
            NodeData::Text(text) => {
                let text = text.remove_newlines();

                if should_linkify {
                    if let MentionsMode::WithMentions {
                        pills,
                        room,
                        detect_at_room,
                    } = &mut self.mentions
                    {
                        Linkifier::new(&mut self.inner)
                            .detect_mentions(room, pills, *detect_at_room)
                            .linkify(&text);
                    } else {
                        Linkifier::new(&mut self.inner).linkify(&text);
                    }
                } else {
                    self.inner.push_str(&text.escape_markup());
                }
            }
            data => {
                debug!("Unexpected HTML node: {data:?}");
            }
        }
    }

    /// Append the given inline nodes, converted to Pango markup.
    fn append_nodes(&mut self, nodes: impl IntoIterator<Item = NodeRef<'a>>, should_linkify: bool) {
        for node in nodes {
            self.append_node(node, should_linkify);

            if self.truncated {
                // Stop as soon as the string is truncated.
                break;
            }
        }
    }

    /// Append the given inline children, converted to Pango markup, surrounded
    /// by tags with the given name.
    fn append_tags_and_children(
        &mut self,
        tag_name: &str,
        children: Children<'a>,
        should_linkify: bool,
    ) {
        let _ = write!(self.inner, "<{tag_name}>");

        self.append_nodes(children, should_linkify);

        let _ = write!(self.inner, "</{tag_name}>");
    }

    /// Append the opening Pango markup link tag of the given anchor URI.
    ///
    /// The URI is also used as a title, so users can preview the link on hover.
    ///
    /// Returns `true` if the opening tag was successfully constructed.
    fn append_link_opening_tag_from_anchor_uri(&mut self, uri: &AnchorUri) -> bool {
        match uri {
            AnchorUri::Matrix(uri) => {
                self.inner.append_link_opening_tag(uri.to_string());
                true
            }
            AnchorUri::MatrixTo(uri) => {
                self.inner.append_link_opening_tag(uri.to_string());
                true
            }
            AnchorUri::Other(uri) => {
                self.inner.append_link_opening_tag(uri);
                true
            }
            uri => {
                debug!("Unsupported anchor URI format: {uri:?}");
                false
            }
        }
    }

    /// Append the span with the given data and inline children as Pango Markup.
    ///
    /// Whether we are an inside an anchor or not decides if we try to linkify
    /// the text contained in the children nodes.
    fn append_span(&mut self, span: &SpanData, children: Children<'a>, should_linkify: bool) {
        self.inner.push_str("<span");

        if let Some(bg_color) = &span.bg_color {
            let _ = write!(self.inner, r#" bgcolor="{bg_color}""#);
        }
        if let Some(color) = &span.color {
            let _ = write!(self.inner, r#" color="{color}""#);
        }

        self.inner.push('>');

        self.append_nodes(children, should_linkify);

        self.inner.push_str("</span>");
    }

    /// Append the text contained in the nodes to the string.
    ///
    /// Returns `true` if the text was ellipsized.
    fn append_nodes_text(&mut self, nodes: impl IntoIterator<Item = NodeRef<'a>>) {
        for node in nodes.into_iter() {
            match node.data() {
                NodeData::Text(t) => {
                    let t = t.as_ref();

                    if self.single_line {
                        if let Some(newline) = t.find(|c: char| c == '\n') {
                            self.truncated = true;

                            self.inner.push_str(&t[..newline]);
                            self.inner.append_ellipsis();

                            break;
                        }
                    }

                    self.inner.push_str(t);
                }
                NodeData::Element(data) => {
                    if data.name.local.as_ref() == "br" {
                        if self.single_line {
                            self.truncated = true;
                            break;
                        }

                        self.inner.push('\n');
                    } else {
                        self.append_nodes_text(node.children());
                    }
                }
                _ => {}
            }

            if self.truncated {
                // Stop as soon as the string is truncated.
                break;
            }
        }
    }
}

/// The mentions mode of the [`InlineHtmlBuilder`].
#[derive(Debug, Default)]
enum MentionsMode<'a> {
    /// The builder will not detect mentions.
    #[default]
    NoMentions,
    /// The builder will detect mentions.
    WithMentions {
        /// The pills for the detected mentions.
        pills: Vec<Pill>,
        /// The room containing the mentions.
        room: &'a Room,
        /// Whether to detect `@room` mentions.
        detect_at_room: bool,
    },
}