Intégrer un fil de discussion Mastodon à un blog

Intégrer un système de gestion de commentaires basé sur Mastodon dans un blog sans recourir à des services externes.

 10 nov 2024 ·   4 min ·  

Introduction

Pour la gestion des commentaires sur ce blog, je ne voulais pas utiliser de système tiers (ni en auto-hébergement, ni en mode SaaS, etc.), comme https://github.com/apps/giscus, https://commentbox.io/, etc.

En faisant quelques recherches, certains blogueurs ont eu l’idée d’utiliser Mastodon comme système de gestion des commentaires. J’ai trouvé une multitude de bouts de code qui permettent d’effectuer cette intégration, mais rien de pleinement satisfaisant.

Mes critères de choix sont :

  • N’avoir aucune maintenance à faire
  • Respecter la vie privée, le RGPD, etc. (pas de dépôt de cookies, etc.)
  • Ne pas dégrader les performances du site
  • Ne pas dégrader la vitesse de chargement des pages
  • Afficher les entrées du fil hiérarchiquement
  • Utiliser le framework CSS Bulma

Résultats :

  • Un bouton à la disposition de l’utilisateur pour charger le fil de discussion depuis Mastodon
  • Une vue hiérarchique du fil de discussion, sans limites de profondeur
  • Utilisation des classes et des styles du framework CSS Bulma (utilisé par ce site)

Mode opératoire : développement

Ajouter un réceptacle pour le fil de discussion à la page

<div id="mastodon-comments" class="content"></div>

Intégrer le bout de code JavaScript

<script>
class MastodonThreadViewer {
    constructor(instanceUrl) {
        this.instanceUrl = instanceUrl;
        this.isLoading = false;
    }

    // Créer le bouton et le conteneur
    createStructure(containerId) {
        const container = document.getElementById(containerId);
        if (!container) return;

        container.innerHTML = `
            <div class="field">
                <div class="control">
                    <button class="button" id="${containerId}-load-btn">
                        <span class="icon">
                            <i class="fas fa-comments"></i>
                        </span>
                        <span>Charger et afficher la discussion depuis Mastodon</span>
                    </button>
                </div>
            </div>
            <div id="${containerId}-thread" class="thread-container"></div>
        `;

        return {
            button: document.getElementById(`${containerId}-load-btn`),
            threadContainer: document.getElementById(`${containerId}-thread`)
        };
    }

    // Récupérer le fil complet
    async getFullThread(statusId) {
        try {
            // Récupérer le status initial
            const statusResponse = await fetch(`${this.instanceUrl}/api/v1/statuses/${statusId}`);
            const originalStatus = await statusResponse.json();

            // Récupérer le contexte (réponses)
            const contextResponse = await fetch(`${this.instanceUrl}/api/v1/statuses/${statusId}/context`);
            const context = await contextResponse.json();

            return {
                originalStatus,
                descendants: context.descendants
            };
        } catch (error) {
            console.error('Erreur lors de la récupération du fil :', error);
            throw error;
        }
    }

    // Organiser les réponses en structure hiérarchique
    organizeThread(originalStatus, replies) {
        const statusMap = new Map();
        
        // Ajouter le status original
        statusMap.set(originalStatus.id, {
            ...originalStatus,
            children: []
        });

        // Ajouter toutes les réponses
        replies.forEach(reply => {
            statusMap.set(reply.id, {
                ...reply,
                children: []
            });
        });

        // Construire la hiérarchie
        statusMap.forEach(status => {
            if (status.in_reply_to_id && statusMap.has(status.in_reply_to_id)) {
                const parent = statusMap.get(status.in_reply_to_id);
                parent.children.push(status);
            }
        });

        // Trier les enfants par date
        statusMap.forEach(status => {
            status.children.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
        });

        return statusMap.get(originalStatus.id);
    }

    // Générer le HTML pour un statut et ses réponses
    renderStatus(status, isRoot = false) {
        const date = new Date(status.created_at).toLocaleString();
        const html = `
            <article class="media ${isRoot ? 'root-status' : ''}">
                <figure class="media-left">
                    <p class="image is-rounded is-48x48">
                        <img src="${status.account.avatar}" alt="${status.account.username}">
                    </p>
                </figure>
                <div class="media-content">
                    <div class="content">
                        <p>
                            <strong>${status.account.display_name}</strong>
                            <small>@${status.account.username}</small>
                            <small>${date}</small>${isRoot ? '&nbsp;<span class="tag is-info is-small">Post original</span>' : ''}</p>
                            ${status.content}
                    </div>
                    ${status.children
                        .map(child => this.renderStatus(child))
                        .join('')}
                </div>
            </article>
        `;
        return html;
    }

    // Mettre à jour l'état du bouton
    updateButtonState(button, isLoading) {
        if (!button) return;
        
        const buttonText = button.querySelector('span:not(.icon)');
        if (isLoading) {
            button.classList.add('is-loading');
            button.disabled = true;
            if (buttonText) buttonText.textContent = 'Chargement...';
        } else {
            button.classList.remove('is-loading');
            button.disabled = false;
            if (buttonText) buttonText.textContent = 'Charger et afficher la discussion depuis Mastodon';
        }
    }

    // Initialiser le viewer avec le bouton de chargement
    initialize(statusId, containerId) {
        const elements = this.createStructure(containerId);
        if (!elements) return;

        const { button, threadContainer } = elements;

        // Ajouter le CSS pour le post original
        const style = document.createElement('style');
        style.textContent = `
            .root-status {
                border-left: 3px solid #3298dc;
                padding-left: 1rem;
                margin-bottom: 1.5rem;
            }
            .thread-container {
                margin-top: 1rem;
            }
        `;
        document.head.appendChild(style);

        button.addEventListener('click', async () => {
            if (this.isLoading) return;
            
            this.isLoading = true;
            this.updateButtonState(button, true);

            try {
                const { originalStatus, descendants } = await this.getFullThread(statusId);
                const organizedThread = this.organizeThread(originalStatus, descendants);

                if (!organizedThread) {
                    threadContainer.innerHTML = `
                        <div class="notification is-info">
                            Impossible de charger la discussion.
                        </div>
                    `;
                    return;
                }

                threadContainer.innerHTML = this.renderStatus(organizedThread, true);

            } catch (error) {
                console.error('Erreur lors du chargement de la discussion:', error);
                threadContainer.innerHTML = `
                    <div class="notification is-danger">
                        Une erreur est survenue lors du chargement de la discussion.
                    </div>
                `;
            } finally {
                this.isLoading = false;
                this.updateButtonState(button, false);
            }
        });
    }
}

// Exemple d'utilisation :
document.addEventListener('DOMContentLoaded', () => {
    const viewer = new MastodonThreadViewer('https://mastodon.social');
    viewer.initialize('{{ page.mastodonid }}', 'mastodon-comments');
});
</script>

Personnaliser les variables

La personnalisation s’effectue à partir de la ligne 188, dans cet extrait :

// Exemple d'utilisation :
document.addEventListener('DOMContentLoaded', () => {
    const viewer = new MastodonThreadViewer('https://mastodon.social');
    viewer.initialize('{{ page.mastodonid }}', 'mastodon-comments');
});

Où :

  • https://mastodon.social est l’URL de votre instance Mastodon
  • {{ page.mastodonid }} est l’ID du post Mastodon, racine du fil de discussion

Mode opératoire : opérationnel

  1. Publier l’article
  2. Publier un post sur Mastodon
  3. Récupérer l’ID du post
  4. Intégrer cet ID dans l’article
  5. Republier l’article

Résultat

  1. Au chargement de la page, un bouton apparaît (“Charger et afficher la discussion depuis Mastodon”).
  2. Au clic sur le bouton “Charger et afficher la discussion depuis Mastodon”, le fil de discussion se charge et s’affiche.

Testez directement le bouton “Charger et afficher la discussion depuis Mastodon” de cette page !


Articles similaires

Partage et commentaires

Partager cet article sur :  Mastodon  X (Twitter)  Facebook  LinkedIn  Reddit

Vous pouvez commenter cet article grâce à ce fil de discussion sur Mastodon (avec Mastodon ou un autre compte ActivityPub/​Fediverse).