A long list of message with virtual DOM

Following discussions in various issues (
Add pagination to ChatFeed · Issue #6021 · holoviz/panel · GitHub) about listing a long list of messages while keeping usage of resources low ; and being able to jump to any message “quickly”, I came up with this reactiveHTML component. It works with tens of thousand of messages.

Perhaps useful to others. I can’t use Panel’s ChatMessage due to the generation of the messages on DOM side on the fly based on a given dictionary, so I came up with an own format.

import param
import datetime
import panel as pn
from panel.reactive import ReactiveHTML

class MessageViewer(ReactiveHTML):
    messages = param.List(default=[])  # List of message dictionaries
    message_index = param.Integer(default=None)  # Index to scroll to

    _template = """

    <div id="message-container" style="overflow-y: auto; height: 90vh; background: #1f77b4;">
        {% for message in messages %}
        <div class="message" style="height: 50px;" id="message-{{ loop.index0 }}" data-index="{{ loop.index0 }}">
        </div>
        {% endfor %}
    </div>
    """

    _scripts = {
        'message_index': """
            if (data.message_index !== null) {
                Array.from(message_container.childNodes).forEach(child => {
                    if (child.classList && child.classList.contains('message') && (child.getAttribute('data-index')==data.message_index)) {
                        console.log(child);
                        child.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
                    }
                });
            };
        """,
        'after_layout': """
            const observer = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    const messageElement = entry.target;
                    const index = messageElement.getAttribute('data-index');
                    const messageData = data.messages[index];

                    if (entry.isIntersecting) {
                        messageElement.innerHTML = `
                            <div class='inner-message' style="padding: 10px; display: flex; justify-content: space-between; align-items: center; background: white; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
                                <div>
                                    <p style="margin: 0; font-weight: 500;">${messageData.text}</p>
                                    <small style="color: gray;">${new Date(messageData.timestamp * 1000).toLocaleString()}</small>
                                </div>
                                <div style="display: flex; align-items: center;">
                                    <div style="margin-right: 16px;">
                                        ${messageData.tags.map(tag => `<span style="background: #e0e0e0; border-radius: 16px; padding: 4px 8px; margin-right: 4px; font-size: 0.8em;">${tag}</span>`).join('')}
                                    </div>
                                    <small style="color: gray;">#${messageData.index}</small>
                                </div>
                            </div>
                        `;
                    } else {
                        // Clear the innerHTML when the message is out of view
                        messageElement.innerHTML = '';
                    }
                });
            }, { rootMargin: '50px' });

            Array.from(message_container.childNodes).forEach(child => {
                if (child.classList && child.classList.contains('message')) {
                    observer.observe(child);
                }
            });
            self.message_index();
        """
    }

    def __init__(self, **params):
        super().__init__(**params)

# Example usage
messages = [
    {'text': f'Message {i}', 'timestamp': 123456789 + i*360, 'tags': [f'tag{i%3}'], 'index': i}
    for i in range(1000)
]

message_viewer = MessageViewer(messages=messages, message_index=0, sizing_mode='stretch_both')




try:
    s.stop()
except:
    pass
s = message_viewer.show(port = 5006, open=False)

Then you can jump to any message id with message_viewer.message_index = 30

2 Likes