Des commentaires via le Fédiverse

Pouvoir commenter des articles sur un blog afin d’interagir avec la personne qui rédige, c’est un concept presque aussi vieux que les blogs eux-mêmes. Les moyens pour le faire sont nombreux. Je pense que je ne vous apprends rien.

Par contre, quand on parle de commentaires directement intégrés à la page d’un article, au lieu de s’échanger des mails qui seront publiés après-coup (avec autorisation), on tombe vite sur une situation compliquée lorsqu’il s’agit d’un site statique.

En effet à ce moment-là les options deviennent vite limitées, et si l’on veut respecter les données des visiteurs, on en revient vite au bon vieux mail uniquement. Même s’il existe des solutions comme Isso.

Ayant la flemme d’installer et d’administrer une instance Isso, et refusant de stocker le contenu ailleurs (coucou Disqus et autres), je me suis longtemps dit que j’allais laisser tomber et juste ouvrir ma boite mail de temps en temps.

Puis un beau jour, je suis tombé par hasard sur un article de blog de Carl Schwan intitulé « Adding comments to your static blog with Mastodon ». Ajouter des commentaires à votre blog statique via Mastodon. Tous les mots-clefs intéressants pour moi sont là. En plus je dispose de ma propre instance sur le Fédiverse.

Et si je testais ça ?

Techniquement, comment ça fonctionne ?

Lorsque l’on publie un pouet, ce dernier possède un identifiant unique sur l’instance. Lorsque des réponses y sont faites, ces pouets référence celui d’origine.

L’API de Mastodon, et les autres implémentations (notamment Pleroma) permettent de récupérer ce pouet ainsi que toutes ses réponses. À condition que ces publications soient publiques.

Il suffit donc de faire un petit appel API sur ce pouet, et de boucler sur les réponses.

Le fameux point d’API est : https:///{{ host }}/api/v1/statuses/{{ id }}/context

Avec cet appel, si notre pouet a des réponses, on récupère un json avec plein d’infos intéressantes. Notamment avec la clef descendants qui contient un tableau des pouets en réponse. De là, il suffit de boucler et d’extraire les champs qui nous intéressent.

Mon implémentation

Je me suis basé sur celle de Carl Schwan : j’ai fait un Partial dans mon thème Hugo et hop.

commentaires.html
{{ with .Params.comments.show }}

<aside class="card" id="commentaires-fediverse">
    <h3>Commentaires</h3>
    <p id="fedi-comments-list"><button id="load-comment">Charger les commentaires</button></p>
    <noscript><p>JavaScript est nécessaire pour afficher les commentaires.</p></noscript>
    <p>Vous pouvez utiliser votre compte sur le Fédiverse pour commenter cet article en répondant <a class="link" href="https://{{ $.Params.comments.host }}/notice/{{ $.Params.comments.id }}">à ce pouet</a>.</p>
    <p>Vous pouvez également copier et coller l'URL ci-dessous dans le champ de recherche de votre application Fediverse
    ou dans l'interface web de votre instance.</p>
    <p>
    <label for="fediverseURL">Lien du pouet</label>:&nbsp;
    <input class="textbox" id="fediverseURL" type="text" readonly="" size="40" value="https://{{ $.Params.comments.host }}/notice/{{ $.Params.comments.id }}">
    <button class="button" id="fediverseCopyButton">Copier</button>
    </p>
    <p>
    Pour plus de détails à ce sujet, vous pouvez <a href="{{ ref $ "003-commentaires-via-fedi.md" }}">lire cet article</a>.
    </p>
    <script src="/js/purify.min.js"></script>
    <script type="text/javascript">

        const dateOptions = {
            year: "numeric",
            month: "numeric",
            day: "numeric",
            hour: "numeric",
            minute: "numeric",
        };

        function escapeHtml(unsafe) {
            return unsafe
                .replace(/&/g, "&amp;")
                .replace(/</g, "&lt;")
                .replace(/>/g, "&gt;")
                .replace(/"/g, "&quot;")
                    .replace(/'/g, "&#039;");
        }

        document.getElementById('fediverseCopyButton').addEventListener('click', () => {
            navigator.clipboard.writeText('https://{{ $.Params.comments.host }}/notice/{{ $.Params.comments.id }}');
        });

        document.getElementById("load-comment").addEventListener("click", function() {
            document.getElementById("load-comment").innerHTML = "Chargement...";
            fetch('https:///{{ $.Params.comments.host }}/api/v1/statuses/{{ $.Params.comments.id }}/context')
                .then(function(response) {
                    return response.json();
                })
                .then(function(data) {
                    if(data['descendants'] &&
                        Array.isArray(data['descendants']) && 
                        data['descendants'].length > 0) {
                        document.getElementById('fedi-comments-list').innerHTML = "";
                        data['descendants'].forEach(function(reply) {
                            reply.account.display_name = escapeHtml(reply.account.display_name);
                            reply.account.reply_class = reply.in_reply_to_id == "{{ $.Params.comments.id }}" ? "reply-original" : "reply-child";
                            reply.created_date = new Date(reply.created_at);
                            // remplacer émojis customs par image dans les pseudos
                            reply.account.emojis.forEach(emoji => {
                                reply.account.display_name = reply.account.display_name.replace(`:${emoji.shortcode}:`,
                                    `<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" height="20" width="20" />`);
                            });
                            // la même, mais dans les corps des pouets
                            reply.emojis.forEach(emoji => {
                                reply.content = reply.content.replace(`:${emoji.shortcode}:`,
                                    `<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" height="20" width="20" />`);

                               });
                            fediComment =
                                `
<div class="fedi-wrapper">
  <div class="comment-level ${reply.account.reply_class}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
    <path fill="currentColor" stroke="currentColor" d="m 307,477.17986 c -11.5,-5.1 -19,-16.6 -19,-29.2 v -64 H 176 C 78.8,383.97986 -4.6936293e-8,305.17986 -4.6936293e-8,207.97986 -4.6936293e-8,94.679854 81.5,44.079854 100.2,33.879854 c 2.5,-1.4 5.3,-1.9 8.1,-1.9 10.9,0 19.7,8.9 19.7,19.7 0,7.5 -4.3,14.4 -9.8,19.5 -9.4,8.8 -22.2,26.4 -22.2,56.700006 0,53 43,96 96,96 h 96 v -64 c 0,-12.6 7.4,-24.1 19,-29.2 11.6,-5.1 25,-3 34.4,5.4 l 160,144 c 6.7,6.2 10.6,14.8 10.6,23.9 0,9.1 -3.9,17.7 -10.6,23.8 l -160,144 c -9.4,8.5 -22.9,10.6 -34.4,5.4 z" />
  </svg></div>
  <div class="fedi-comment">
    <div class="comment">
      <div class="comment-avatar"><img src="${escapeHtml(reply.account.avatar_static)}" alt=""></div>
      <div class="comment-author">
        <div class="comment-author-name"><a href="${reply.account.url}" rel="nofollow">${reply.account.display_name}</a></div>
        <div class="comment-author-reply"><a href="${reply.account.url}" rel="nofollow">${escapeHtml(reply.account.acct)}</a></div>
      </div>
      <div class="comment-author-date"><a href="${reply.url}" rel="nofollow" title="Voir le pouet d'origine">${reply.created_date.toLocaleString(navigator.language, dateOptions)}</a></div>
    </div>
    <div class="comment-content">${reply.content}</div> 
  </div>
</div>
`;
                            document.getElementById('fedi-comments-list').appendChild(DOMPurify.sanitize(fediComment, {'RETURN_DOM_FRAGMENT': true}));
                        });
                    } else {
                        document.getElementById('fedi-comments-list').innerHTML = "<p>Aucun commentaire trouvé.</p>";
                    }
                });
        });
    </script>
</aside>
{{ end }}

Rien de bien compliqué. Dans un bloc séparé, si j’ai bien rempli l’en-tête de l’article, j’affiche le bouton pour charger les réponses à la demande ainsi qu’un petit texte explicatif. Je n’ai pas voulu faire un bouton avec une pop-up pour copier le lien du pouet et mettre les explications. Ensuite, le JS qui sse charge de récupérer le pouet et ses réponses, puis de boucler dessus pour générer le html qui sera ensuite inséré à la place du bouton.

Un poil de CSS également pour la forme et … C’est terminé !

Les règles CSS à rajouter
.fedi-comment {
  margin-bottom: 3rem;
  display: flex;
  gap: 1rem;
  flex-direction: column;
  flex-grow: 2;
}
#commentaires-fediverse {
	font-size: 0.8em;
}
.fedi-wrapper {
    display: flex;
    gap: 3rem;
    flex-direction: row;
    flex-direction: row;
}
.fedi-comment .comment {
  display: flex;
  flex-direction: row;
  gap: 1rem;
  flex-wrap: wrap;
}
.fedi-comment .comment-avatar img {
  width: 2rem;
}
.fedi-comment .content {
  flex-grow: 2;
}
.fedi-comment .comment-author {
  display: flex;
  flex-direction: column;
}
.fedi-comment .comment-author-name {
  font-weight: bold;
}
.fedi-comment .comment-author-name a {
  display: flex;
  align-items: center;
}
.fedi-comment .comment-author-date {
  margin-left: auto;
}
.fedi-comment-content p:first-child {
  margin-top: 0;
}
.comment-level {
    max-width: 1.5rem;
    min-width: 1.5rem;
}
.reply-original {
 display:none
}
#fediverseURL {
	background-color:var(--inner-blocks-bg-color);
	border: none;
    color:var(--inner-blocks-font-color);
}

Enfin presque, il manque un petit paramétrage en en-tête de l’article :

[comments]
  show = true
  host = "mon.instance.pleroma"
  id = "id_du_pouet_principal"

Tant que j’y suis, je pré-paramètre ça dans l’archétype également (à false, oui, le temps d’écrire tranquillement) :

[comments]
  show = false
  host = "mon.instance.pleroma"
  id = "a_changer"

Et maintenant ?

Maintenant ? Il n’y a plus qu’à continuer de publier. Vu mon rythme, je ne pense pas voir la différence, mais je me dis que cela peut être plus sympa si jamais quelqu’un ayant déjà un compte sur une instance veut échanger avec moi, sans avoir à ouvrir son client mail.

Le vrai soucis avec cette méthode, c’est que ça m’ajoute quelques opérations manuelles :

Je finirais bien par réfléchir à une autre solution un jour, après tout je ne publie pas si souvent que cela. Et je suis content de proposer facilement un autre moyen d’échanger sur ce que j’écris. Qui sait, peut-être que j’aurais un jour plus de retours, ce qui me permettra de m’améliorer. C’est toujours ça.

Par contre, ça va également me forcer à faire des pouets pour présenter mes nouveaux articles. Moi qui laissais le flux RSS faire son travail de l’ombre, ça me fait bizarre de me dire que je vais de moi-même pousser publiquement l’apparition d’un nouveau contenu. D’autres le font déjà très bien, certes, mais pour moi c’est quelque chose de tout nouveau.

Dernier inconvénient : pour les anciens articles, il faudrait démarrer un fil sur le fédiverse, et, vous savez maintenant : récupérer les id de ces fils, et les paramétrer, pousser la mise à jour sur le blog, etc.

Réflexions finales

Section ajoutée le 06/07/2023

La modération

Après quelques échanges avec koala3k, est venue tardivement la question de la modération des commentaires. Sujet épineux s’il en est, et pourtant je l’avais complètement mise de côté. Je dois dire qu’étant admin de ma propre instance, je ne devrais pas avoir trop de difficultés. Mais ce point est important à prendre en compte si ça n’est pas votre cas.

Il peut y avoir de nombreuses raisons, allant d’une instance qui s’en fiche à une équipe de modération débordée. Dans tous les cas, la solution que j’ai mise en place aujourd’hui, et qui sera la vôte si vous prenez ma démarche telle quelle, ne permet pas de modération en amont. Elle ne sera qu’à postériori, si elle arrive (cf. phrases précédentes).

Cependant, koala3k a eu une idée que je tenterai un jour : regarder dans le JSON si la réponse a été mise en favori par l’auteur du pouet d’origine. Ça semble potentiellement possible. Gardons toutefois à l’esprit que ça rajouterait une couche de complexité non négligeable au code qui se veut simple et que ce n’est pas une solution parfaite (tout comme celle d’origine, restons intellectuellement honnêtes).

Les commentaires sont éphémères

Enfin, il y a aussi le souci de la disparition dans le futur de certains pouets formant le fil. Je pense d’abord aux miens, mais également à ceux formant les réponses. Il y a de nombreuses personnes et instances (dont moi) qui ont activé une purge automatique de leurs pouets. Un jour, les conversations en commentaire seront donc à trous. C’est une certitude.

Cependant, il faut se dire que quoi qu’il arrive, à la base, lorsque l’on échange sur le fédiverse, il faut s’attendre à perdre la moindre information que l’on a pas rappatriée sur un support plus fiable. Ça fait partie du jeu. Personnellement, je l’accepte sans soucis. C’est pour cela que j’ai mis cet article à jour également, pour fiabiliser et faciliter l’accès aux autres à ces réflexions que j’ai pu avoir à l’aide de personnes qui sont justement venues commenter.

Les liens

Plus de contenu ?
Commentaires