Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Option for Fediverse (Mastodon) comments? #334

Open
grifferz opened this issue Jun 20, 2024 · 4 comments
Open

Option for Fediverse (Mastodon) comments? #334

grifferz opened this issue Jun 20, 2024 · 4 comments
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@grifferz
Copy link

Thanks for this theme. I am very much enjoying making sites with it!

Feature Request

Provide a means to embed a Fediverse (Mastodon) conversation in a page, typically the thread of comments related to a post about the article itself.

Summary

As an alternative to the four existing commenting systems, would you consider adding a view of comments from an associated Fediverse post, such as this existing implementation?

Motivation

All of the existing commenting systems that have been implemented by tabi require commenters to log in to a centralised service, for example GitHub or yet another third party system (even if it is one that the site owner self-hosts like Isso).

Although not everyone has a Fediverse account, it is at least possible for anyone to have one without signing up to a specific service. If a commenter really wants to they can even self-host a Fediverse instance.

It is common to post a notification link about a new blog post or article onto a service like Mastodon, so it's natural for people who became aware of the article to respond on Fedi with their thoughts and comments. If these then are displayed on the article as well then it can bring a fuller experience to site visitors who would ordinarily never go onto the Fediverse to see that conversation.

Detailed Description

Using the linked webcomponent as an example, the way I see it working is something like:

  1. Author writes article as normal
  2. Author posts link to Fedi either manually or by some automated means
  3. Author takes the toot id from that Fedi post and updates the article with whatever tabi shortcode that would be equivalent to the HTML:
<mastodon-comments host="fosstodon.org" user="dpecos" tootId="109574160582937075" style="width : 1024px"></mastodon-comments>

That would embed the display of the conversation rooted at https://fosstodon.org/@dpecos/109574160582937075

Of course, if host and user are set in configuration then the actual shortcode would be a lot smaller as the only variable that would need to change per-page would be the toot ID.

The need to update the post with a directive containing the toot ID is a bit clunky but conceptually similar to the problem you discuss with social media cards, in that you are there looking to update the card image path in the frontmatter after the initial post was made; here looking to update a toot ID after a Fedi post related to the post is made.

Perhaps instead of a shortcode it could be triggered by placing a setting in the [extra] section, and then the same technique of using a script to update the frontmatter could be used. That is probably more suited to automation than having to put a shortcode somewhere in the article, and I guess you would always expect the conversation to be at the bottom anyway.

So now that I think more about it, having it as a config setting is probably more sensible in most cases, but additionally having a shortcode for it would probably still be desirable as that would bring the nice possibility of being able to embed multiple Fedi posts and optionally their comment threads.

What do you think?

I'm afraid that doing this work myself is beyond me, so I will just end up using the suggested webcomponent directly from a shortcode. It just seemed a nice feature that could be built in to tabi.

@grifferz grifferz added the enhancement New feature or request label Jun 20, 2024
@welpo
Copy link
Owner

welpo commented Jun 21, 2024

All of the existing commenting systems that have been implemented by tabi require commenters to log in to a centralised service

Just noting Isso allows anonymous comments: all fields are optional by default, and no account is needed.

Perhaps instead of a shortcode it could be triggered by placing a setting in the [extra] section, and then the same technique of using a script to update the frontmatter could be used.

That would be preferred for maintenance, yeah. Also using a specific [extra] section in config.toml.

additionally having a shortcode for it would probably still be desirable as that would bring the nice possibility of being able to embed multiple Fedi posts and optionally their comment threads

Can you elaborate? My idea would be as you mentioned: publish a post, mention this on the Fediverse, and use that thread as the comments of the post. In what instance might one want two separate comment sections?


I would be happy to add this to tabi. Indeed, I almost implemented this myself, but I decided four comment systems were enough :)

However, I had bookmarked a nice guide for Hugo. This could serve as a nice reference to implement the feature on tabi.

I'm afraid that doing this work myself is beyond me

Even after looking at the post/guide above? I can't prioritise working (solo) on this feature right now, but I'd be happy to complete a PR or provide guidence along the way!

@grifferz
Copy link
Author

additionally having a shortcode for it would probably still be desirable as that would bring the nice possibility of being able to embed multiple Fedi posts and optionally their comment threads

Can you elaborate? My idea would be as you mentioned: publish a post, mention this on the Fediverse, and use that thread as the comments of the post. In what instance might one want two separate comment sections?

Oh, it's a secondary thing and not really very important, it's just that as you would have access to settings for the mastodon server and user anyway, then when given a toot id you can request that from the mastodon API and just not iterate through all the comments on it. It would be for the purpose of citing a toot in the article.

Anyway it's not important, I was just thinking that once there is already code to request a toot and its comments from a mastodon API then there may as well be a shortcode to request just the toot text for citing.

I would be happy to add this to tabi. Indeed, I almost implemented this myself, but I decided four comment systems were enough :)

Excellent!

However, I had bookmarked a nice guide for Hugo. This could serve as a nice reference to implement the feature on tabi.

This is not loading for me just now, but I have read it before and I was under the impression that both the person from the link I provided and also Carl Schwan were working from the same example javascript, it's just that Daniel Pecos had written it up a bit more extensively. You can see that Daniel gives a credit to Carl near the end of his post about it here:

https://danielpecos.com/2022/12/25/mastodon-as-comment-system-for-your-static-blog/

I think they're going to be basically the same approach so that's all good.

I'm afraid that doing this work myself is beyond me

Even after looking at the post/guide above? I can't prioritise working (solo) on this feature right now, but I'd be happy to complete a PR or provide guidence along the way!

I understand. I'm glad to read you have been thinking about it already at least. If anyone else is able to help I am ready to help with some testing.

@welpo welpo added the help wanted Extra attention is needed label Jun 21, 2024
@welpo
Copy link
Owner

welpo commented Jun 21, 2024

It would be for the purpose of citing a toot in the article

Ah, gotcha! That would make sense as a shortcode, I think. I would do these (comments for a post / quoting a toot) separately.

@jskherman
Copy link

Hi, just wanted to add to this discussion that the Duckquill1 theme uses Mastodon for its comments and might be a useful reference. Their demo page for the comments can be found here.

comments.html
{#- Taken from https://carlschwan.eu/2020/12/29/adding-comments-to-your-static-blog-with-mastodon/ -#}

{# Set the Mastodon host, either from page-specific config or global config #}
{%- if page.extra.comments.host -%}
    {%- set host = page.extra.comments.host -%}
{%- else -%}
    {%- set host = config.extra.comments.host -%}
{%- endif -%}

{# Set the Mastodon username, either from page-specific config or global config #}
{%- if page.extra.comments.user -%}
    {%- set username = page.extra.comments.user -%}
{%- else %}
    {%- set username = config.extra.comments.user -%}
{%- endif -%}

{# Set the Mastodon post ID #}
{%- set id = page.extra.comments.id -%}

{# Set the locale for date formatting #}
{%- set date_locale = macros_translate::translate(key="date_locale", default="en-US", language_strings=language_strings) | replace(from="_", to="-") -%}

<section id="comments">
    {# Display QR code if enabled in config #}
    {%- if config.extra.comments.show_qr -%}
        <img id="qrcode" class="no-hover pixels" alt="QR code to a Mastodon post" src="https://api.qrserver.com/v1/create-qr-code/?data=https://{{ host }}/@{{ username }}/{{ id }}&format=gif" />
    {%- endif -%}
    
    <h2>{{ macros_translate::translate(key="comments", default="Comments", language_strings=language_strings) }}</h2>
    <p>{{ macros_translate::translate(key="comments_description", default="You can comment on this blog post by publicly replying to this post using a Mastodon or other ActivityPub/Fediverse account. Known non-private replies are displayed below.", language_strings=language_strings) }}</p>
    
    {# Buttons for loading comments and opening the original post #}
    <div class="dialog-buttons">
        <a id="load-comments" class="inline-button" onclick="loadComments()" onkeypress="loadComments()" tabindex="0">
            {{ macros_translate::translate(key="load_comments", default="Load Comments", language_strings=language_strings) }}
        </a>
        <a class="inline-button colored external" href="https://{{ host }}/@{{ username }}/{{ id }}">
            {{ macros_translate::translate(key="open_post", default="Open Post", language_strings=language_strings) }}
        </a>
    </div>
    
    {# Container for comments #}
    <div id="comments-wrapper">
        <noscript>
            <p>{{ macros_translate::translate(key="comments_noscript", default="Loading comments relies on JavaScript. Try enabling JavaScript and reloading, or visit the original post on Mastodon.", language_strings=language_strings) }}</p>
        </noscript>
    </div>
    
    <script>
        // Function to escape HTML special characters
        function escapeHtml(unsafe) {
            return unsafe
                .replace(/&/g, "&amp;")
                .replace(/</g, "&lt;")
                .replace(/>/g, "&gt;")
                .replace(/"/g, "&quot;")
                .replace(/'/g, "&#039;");
        }
        
        // Function to replace emoji shortcodes with actual emoji images
        function emojify(input, emojis) {
            let output = input;

            emojis.forEach((emoji) => {
                let picture = document.createElement("picture");

                let source = document.createElement("source");
                source.setAttribute("srcset", escapeHtml(emoji.url));
                source.setAttribute("media", "(prefers-reduced-motion: no-preference)");

                let img = document.createElement("img");
                img.className = "emoji";
                img.setAttribute("src", escapeHtml(emoji.static_url));
                img.setAttribute("alt", `:${emoji.shortcode}:`);
                img.setAttribute("title", `:${emoji.shortcode}:`);

                picture.appendChild(source);
                picture.appendChild(img);

                output = output.replace(`:${emoji.shortcode}:`, picture.outerHTML);
            });

            return output;
        }

        // Function to load and display comments
        function loadComments() {
            let commentsWrapper = document.getElementById("comments-wrapper");
            document.getElementById("load-comments").innerHTML = "{{ macros_translate::translate(key='loading', default='Loading', language_strings=language_strings) }}…";
            
            // Fetch comments from Mastodon API
            fetch("https://{{ host }}/api/v1/statuses/{{ id }}/context")
                .then(function (response) {
                    return response.json();
                })
                .then(function (data) {
                    let descendants = data["descendants"];
                    if (
                        descendants &&
                        Array.isArray(descendants) &&
                        descendants.length > 0
                    ) {
                        commentsWrapper.innerHTML = "";

                        // Process each comment
                        descendants.forEach(function (status) {
                            console.log(descendants);
                            
                            // Set display name
                            if (status.account.display_name.length > 0) {
                                status.account.display_name = escapeHtml(
                                    status.account.display_name
                                );
                                status.account.display_name = emojify(
                                    status.account.display_name,
                                    status.account.emojis
                                );
                            } else {
                                status.account.display_name = status.account.username;
                            }

                            // Determine instance
                            let instance = "";
                            if (status.account.acct.includes("@")) {
                                instance = status.account.acct.split("@")[1];
                            } else {
                                instance = "{{ host }}";
                            }

                            // Check if it's a reply
                            const isReply = status.in_reply_to_id !== "{{ id }}";

                            // Check if it's the original poster
                            let op = false;
                            if (status.account.acct == "{{ username }}") {
                                op = true;
                            }

                            // Emojify content
                            status.content = emojify(status.content, status.emojis);

                            // Create avatar element
                            let avatarSource = document.createElement("source");
                            avatarSource.setAttribute(
                                "srcset",
                                escapeHtml(status.account.avatar)
                            );
                            avatarSource.setAttribute(
                                "media",
                                "(prefers-reduced-motion: no-preference)"
                            );

                            let avatarImg = document.createElement("img");
                            avatarImg.className = "avatar";
                            avatarImg.setAttribute(
                                "src",
                                escapeHtml(status.account.avatar_static)
                            );
                            avatarImg.setAttribute(
                                "alt",
                                `@${status.account.username}@${instance} avatar`
                            );

                            let avatarPicture = document.createElement("picture");
                            avatarPicture.appendChild(avatarSource);
                            avatarPicture.appendChild(avatarImg);

                            let avatar = document.createElement("a");
                            avatar.className = "avatar-link";
                            avatar.setAttribute("href", status.account.url);
                            avatar.setAttribute("rel", "external nofollow");
                            avatar.setAttribute(
                                "title",
                                `{{ macros_translate::translate(key="view_profile", default="View profile at", language_strings=language_strings) }} @${status.account.username}@${instance}`
                            );
                            avatar.appendChild(avatarPicture);

                            // Create instance badge
                            let instanceBadge = document.createElement("a");
                            instanceBadge.className = "instance";
                            instanceBadge.setAttribute("href", status.account.url);
                            instanceBadge.setAttribute(
                                "title",
                                `@${status.account.username}@${instance}`
                            );
                            instanceBadge.setAttribute("rel", "external nofollow");
                            instanceBadge.textContent = instance;

                            // Create display name element
                            let display = document.createElement("span");
                            display.className = "display";
                            display.setAttribute("itemprop", "author");
                            display.setAttribute("itemtype", "http://schema.org/Person");
                            display.innerHTML = status.account.display_name;

                            // Create header element
                            let header = document.createElement("header");
                            header.className = "author";
                            header.appendChild(display);
                            header.appendChild(instanceBadge);

                            // Create permalink
                            let permalink = document.createElement("a");
                            permalink.setAttribute("href", status.url);
                            permalink.setAttribute("itemprop", "url");
                            permalink.setAttribute("title", `{{ macros_translate::translate(key="view_comment", default="View comment at", language_strings=language_strings) }} ${instance}`);
                            permalink.setAttribute("rel", "external nofollow");
                            permalink.textContent = new Date(
                                status.created_at
                            ).toLocaleString("{{ date_locale }}", {
                                dateStyle: "long",
                                timeStyle: "short",
                            });

                            // Create timestamp element
                            let timestamp = document.createElement("time");
                            timestamp.setAttribute("datetime", status.created_at);
                            timestamp.appendChild(permalink);

                            // Create main content element
                            let main = document.createElement("main");
                            main.setAttribute("itemprop", "text");
                            main.innerHTML = status.content;

                            // Create interactions footer
                            let interactions = document.createElement("footer");

                            // Add boosts count
                            let boosts = document.createElement("a");
                            boosts.className = "boosts";
                            boosts.setAttribute("href", `${status.url}/reblogs`);
                            boosts.setAttribute("title", `{{ macros_translate::translate(key="boosts_from", default="Boosts from", language_strings=language_strings) }} ${instance}`);

                            let boostsIcon = document.createElement("i");
                            boostsIcon.className = "icon";
                            boosts.appendChild(boostsIcon);
                            boosts.insertAdjacentHTML('beforeend', ` ${status.reblogs_count}`);
                            interactions.appendChild(boosts);

                            // Add favorites count
                            let faves = document.createElement("a");
                            faves.className = "faves";
                            faves.setAttribute("href", `${status.url}/favourites`);
                            faves.setAttribute("title", `{{ macros_translate::translate(key="faves_from", default="Favorites from", language_strings=language_strings) }} ${instance}`);

                            let favesIcon = document.createElement("i");
                            favesIcon.className = "icon";
                            faves.appendChild(favesIcon);
                            faves.insertAdjacentHTML('beforeend', ` ${status.favourites_count}`);
                            interactions.appendChild(faves);

                            // Create comment article
                            let comment = document.createElement("article");
                            comment.id = `comment-${status.id}`;
                            comment.className = isReply ? "comment comment-reply" : "comment";
                            comment.setAttribute("itemprop", "comment");
                            comment.setAttribute("itemtype", "http://schema.org/Comment");
                            comment.appendChild(avatar);
                            comment.appendChild(header);
                            comment.appendChild(timestamp);
                            comment.appendChild(main);
                            comment.appendChild(interactions);

                            // Add OP class if it's the original poster
                            if (op === true) {
                                comment.classList.add("op");

                                avatar.classList.add("op");
                                avatar.setAttribute(
                                    "title",
                                    "{{ macros_translate::translate(key='blog_post_author', default='Blog post author', language_strings=language_strings) }}: " + avatar.getAttribute("title")
                                );

                                instanceBadge.classList.add("op");
                                instanceBadge.setAttribute(
                                    "title",
                                    "{{ macros_translate::translate(key='blog_post_author', default='Blog post author', language_strings=language_strings) }}: " + instanceBadge.getAttribute("title")
                                );
                            }

                            // Add comment to the wrapper
                            commentsWrapper.innerHTML += comment.outerHTML;
                        });
                    }
                    // Remove the "Load Comments" button after loading
                    let loadCommentsButton = document.getElementById("load-comments");
                    loadCommentsButton.remove();
                });
        }
    </script>
</section>

Footnotes

  1. which by the way took inspiration from tabi in its i18n implementation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

3 participants