DEV Community

Cover image for Clientside Webmentions in Gatsby
Sung M. Kim
Sung M. Kim

Posted on • Originally published at sung.codes on

Clientside Webmentions in Gatsby

Webmention is not new and I liked the idea after reading @swyx's post, Clientside Webmentions - Joining the IndieWeb with Svelte.

Introduction

When I created a GitHub issue, I intended to follow Getting started with Webmentions in Gatsby by Knut Melvær.

The article used gatsby-plugin-webmention, which exposes webmention data during build time.

So I decided to follow @swyx 's implementation (in Svelte) to provide a client-side implementation.

Please follow Knut's post if you want to provide a better experience (, which I will, to add Microformat2.

Assumption

This post assumes you've read @swyx 's post and the prerequisite mentioned in it.

(Setting up webmention.io, brid.gy etc.)

This post will focus on adding client-side webmention.

Webmention Component

Webmention component accepts a target URL. It wraps the component with ErrorBoundary in case it fails and to show the error message.

In a gist, Webmention accepts a target URL to show count & replies.

I used react-error-boundary by Brian Vaughn, a core React team member for convenience.

const WebmentionFallbackComponent = ({ componentStack, error }) => (...)

function Webmention({ target }) {
  return (
    <ErrorBoundary FallbackComponent={WebmentionFallbackComponent}>
      <Heading as="h2" mb={[2, 2, 3, 3]}>
        Webmentions
      </Heading>
      <WebmentionCount target={target} />
      <WebmentionReplies target={target} />
    </ErrorBoundary>
  )
}

You can replace Heading with h1~6 as I am using Theme-UI and Heading comes from that library.

Now let's dive into the implementation of WebmentionCount & WebmentionReplies.

Adding Counts

WebmentionCount component has the following structure.

  1. initialCounts has the "shape" of what's returned from Webmention endpoint.
  2. useEffect runs on target change to load Webmentions
  3. return displays loading status, error message, and counts.
const initialCounts = {
  count: 0,
  type: {
    like: 0,
    mention: 0,
    reply: 0,
    repost: 0,
  },
}

function WebmentionCount({ target }) {
  const [counts, setCounts] = useState(initialCounts)

  // Get counts on `target` change.
  useEffect(() => {
    async function getCounts() {}

    getCounts()
  }, [target])

  return (
    <>
      {counts === initialCounts && <p>Loading counts...</p>}
      {counts === undefined && <p>Failed to load counts...</p>}
      {counts && (
        <div>
          <span role="img" aria-label="emoji">
            ❀️
          </span>{" "}
          {counts.type.like + counts.type.repost || 0}{" "}
          <span role="img" aria-label="emoji">
            πŸ’¬
          </span>{" "}
          {counts.type.mention + counts.type.reply || 0}
        </div>
      )}
    </>
  )
}

The interesting part happens inside the useEffect hook, which fetches webmetions.

useEffect(() => {
  async function getCounts() {
    const url = `https://webmention.io/api/count.json?target=${target}`
    const responseCounts = await fetch(url).then(response => response.json())

    setCounts(previousCounts => {
      return {
        ...previousCounts,
        ...responseCounts,
        type: {
          ...previousCounts.type,
          ...responseCounts.type,
        },
      }
    })
  }

  getCounts()
}, [target])

The endpoint is https://webmention.io/api/count.json?target=${target}.

@swyx had an issue with a warning that,

This is the endpoint to hit: https://webmention.io/api/count.json?target=URL_TO_YOUR_POST/. ⚠️ NOTE: You will need that trailing slash for this request to work! I probably wasted 2 hours figuring this out.-- Clientside Webmentions - Simple Count

In my case, the trailing / was already added from a slug, so there was no need for me to add it. So make sure that your endpoint ends with /

setCounts merges existing counts with counts retrieved from webmention.io.

Paginated Mentions

I've translated most of @swyx 's Svelte code in React here.

WebmentionsReplies loads only 30 replies per page. You can load more with fetch more button below and when there is no more reply, it shows a message.

The following code snippet shows the structure of WebmentionReplies Component.

function Replies({ replies }) {...}

function WebmentionReplies({ target }) {
  const [page, setPage] = useState(0)
  const [fetchState, setFetchState] = useState("fetching")

  const mergeReplies = (oldReplies, newReplies) => [
    ...oldReplies,
    ...newReplies,
  ]
  const [replies, setReplies] = useReducer(mergeReplies, [])
  const perPage = 30

  const getMentions = () => fetch(...)
  const incrementPage = () => setPage(previousPage => previousPage + 1)
  const fetchMore = () => ...

  // Load initial comments once
  useEffect(() => {
    getMentions()
      .then(newReplies => {
        setReplies(newReplies)
        setFetchState("done")
      })
      .then(incrementPage)
  }, [])

  return (
    <>
      {fetchState === "fetching" && <Text>Fetching Replies...</Text>}
      <Replies replies={replies} />
      {fetchState !== "nomore" ? (
        <Button onClick={fetchMore}>
          Fetch More...
        </Button>
      ) : (
        <Text>
          No further replies found.{" "}
          <ExternalLink
            to={`https://twitter.com/intent/tweet/?text=My%20thoughts%20on%20${target}`}
          >
            Tweet about this post
          </ExternalLink>{" "}
          and it will show up here!
        </Text>
      )}
    </>
  )
}

It's longer than WebmentionCounts but the structure is similar.

WebmentionReplies keeps track of three states.

  1. page - Not shown to the site visitor
  2. fetchState - Track whether replies are being loaded or an error occurred or we are done.
  3. replies - Merges loaded replies with newly retrieved replies.

🎈 Why "useReducer"?

The last replies needs some explanation as it looks "different" from setCount used in WebcomponentCounts component.

With useReducer, one normally destructures an array as

const [state, dispatch] = useReducer(reducer, initialState)

useReducer is a way for you to specify "how" to merge the state with a reducer. To make setReplies call easier, the reducer function, mergeReplies simply merges old replies with the new replies.

There is a nice article by Lee Warrick Bridging the Gap between React's useState, useReducer, and Redux, if you want to find out more.

Doing so would let me merge replies like setReplies(newReplies) without having to specify old replies.

useEffect(() => {
  getMentions()
    .then(newReplies => {
      // This merges old replies witht he new ones
      setReplies(newReplies)
      setFetchState("done")
    })
    .then(incrementPage)
}, [])

We now know states involved, let's see how to get replies.

🎈 Getting Replies

⚠: I wrongly named the method getMentions (instead of getReplies).

The same gotcha applies for the URL, which should end with a trailing / here (but my slug/target already contains / so not used here).

const getMentions = () =>
  fetch(
    `https://webmention.io/api/mentions?page=${page}&per-page=${perPage}&target=${target}`
  )
    .then(response => response.json())
    .then(json => [...json.links])

The endpoint contains an object of links array (of the following shape), which is what's saved.

links: [
  {
    source: "https://...",
    id: 757399,
    data: {
      author: {
        name: "Sung M. Kim",
        url: "https://twitter.com/dance2die",
        photo: "https://webmention.io/....jpg"
      },
      url: "https://twitter.com...",
      name: null,
      content: null,
      published: null,
      published_ts: null
    },
    activity: {
      type: "like",
      sentence: "Sung M. Kim favorited ...",
      sentence_html: '<a href="">...</a>'
    },
    target: "https://sung.codes/blog..."
  },
]

The button in return fetches more if there are more records to retrieve.

<Button onClick={fetchMore}>Fetch More...</Button>

fetchMore event handler merges new replies if there were more to retrieve.

In the end, the page number is increment with incrementPage, which causes the next render caused by a button click to use a new page number.

const fetchMore = () =>
  getMentions()
    .then(newReplies => {
      if (newReplies.length) {
        setReplies(newReplies)
      } else {
        setFetchState("nomore")
      }
    })
    .then(incrementPage)

🎈 Replies Component

This component simply iterates replies and making it look pretty.

Most of the components (Flex, Box, Text) used are from Theme-UI so you can use div or other elements to structure and style it.

function Replies({ replies }) {
  const replyElements = replies.map(link => (
    <li key={link.id} sx={{ margin: "1.6rem 0" }}>
      <Flex direcition="row">
        <ExternalLink
          to={link.data.author.url}
          sx={{ flexShrink: 0, cursor: "pointer" }}
        >
          <Image
            sx={{ borderRadius: "50%" }}
            width={40}
            src={link.data.author.photo || AvatarPlaceholder}
            alt={`avatar of ${link.data.author.name}`}
          />
        </ExternalLink>
        <Dangerous
          sx={{ padding: "0 1rem 0" }}
          html={link.activity.sentence_html}
          as="article"
        />
      </Flex>
    </li>
  ))

  return (
    <Box my={[2, 2, 3, 3]}>
      {replies && replies.length ? (
        <ul sx={{ listStyle: "none" }}>{replyElements}</ul>
      ) : (
        <Text>There is no reply...</Text>
      )}
    </Box>
  )

One thing to mention is Dangerous component, which is just a wrapper for dangerouslySetInnerHTML.

It needs to be sanitized (I haven't done it yet) or use different properties not to use raw HTML.

(as it's a security issue).

Source Code

The full source for the components above is listed below.

Parting Words

I am considering to remove "Disqus" at the bottom when "webmention" is all set up properly

That's all folks. If there are any mentions for this post, you can see it πŸ‘‡

If not scroll down to in this post to see webmentions.


Image by Gerd Altmann from Pixabay

Top comments (0)