utterance / utterances

:crystal_ball: A lightweight comments widget built on GitHub issues
https://utteranc.es
MIT License
9k stars 572 forks source link

[feature request] make dark/light mode change with website theme #427

Open orangemn6 opened 3 years ago

orangemn6 commented 3 years ago

so my website has a light and dark theme. would there be a way to do what the title says?

TysonMN commented 3 years ago

Yes. I am doing so on my blog.

orangemn6 commented 3 years ago

@TysonMN how would I go about doing this?

TysonMN commented 3 years ago

I don't know. It depends on your website.

orangemn6 commented 3 years ago

it is built with hugo

TysonMN commented 3 years ago

Never heard of it. Sorry.

brennerm commented 3 years ago

@orangemn6 Add two elements to your page, each with a unique ID, e.g. "dark-mode" and "light-mode". Both elements have the Utterances script as a child with different values for the theme attribute. On dark/light mode switch you hide one element (display: none;) and show the other (display: block;).

        <div id="light-mode">
            <script src="https://utteranc.es/client.js"
                repo="$REPO"
                issue-term="pathname"
                theme="github-light"
                crossorigin="anonymous"
                async>
            </script>
        </div>
        <div id="dark-mode">
            <script src="https://utteranc.es/client.js"
                repo="$REPO"
                issue-term="pathname"
                theme="github-dark"
                crossorigin="anonymous"
                async>
            </script>
        </div>

Here you can find the relevant code of my blog. https://github.com/brennerm/brennerm.github.io/blob/gh-pages/static/js/application.js#L64 https://github.com/brennerm/brennerm.github.io/blob/gh-pages/_layouts/post.html#L25

Be aware that this will result in the Utterances script being loaded twice, but as it happens asynchronously I don't really care too much.

orangemn6 commented 3 years ago

@brennerm I like that idea! I am trying to make an if statement now

orangemn6 commented 3 years ago

@brennerm for me the script is loaded twice on top of each other? is this supposed to happen?

TysonMN commented 3 years ago

That is the behavior on my blog. I don't see a benefit in trying to optimize this.

brennerm commented 3 years ago

@orangemn6 Do you mean on my blog?

orangemn6 commented 3 years ago

@brennerm yes

brennerm commented 3 years ago

Mmh that's strange. Had another look at it and switched to a CSS based logic. Below you can find my rules.

html[data-theme="dark"] #utterances-light {
    display: none;
}

html[data-theme="dark"] #utterances-dark {
    display: block;
}

html #utterances-light,
html[data-theme="light"] #utterances-light {
    display: block;
}

html #utterances-dark,
html[data-theme="light"] #utterances-dark {
    display: none;
}

Please let me know if that works for you.

TysonMN commented 3 years ago

@brennerm, are you saying that those CSS rules allow your website to switch between light and dark modes while only querying GitHub for data once?

brennerm commented 3 years ago

Nope, just switched to CSS rules for hiding one div and displaying the other instead of using Javascript.

tristan957 commented 3 years ago

@orangemn6 I have gone with a slightly different approach.

https://git.sr.ht/~tristan957/tristan.partin.io/tree/master/layouts/_default/single.html#L32

My logic is above. My website is https://tristan.partin.io. I think the CSS-based solution is cool, but users will very rarely switch site themes, so every time a user visits your site, you'll have an extra request going for no reason. When you can just re-request utterances with a different theme if and only if the user switches themes.

titieo commented 3 years ago

Have any one successfully done this on Gatsby (React). Would you mind sharing your code? (Currently there is only 1 mode on utterances)

header.mdx (inject to header of the page):

export const ModeToggle = ({ ...props }) => {
  const [colorMode, setColorMode] = useColorMode()
  const utterance = document.getElementById("utterance")
  return (
    <Button
      className="mode-toggle"
      aria-label="Toggle mode"
      variant="icon"
      onClick={() => setColorMode(colorMode === "default" ? "dark" : "default")}
// Set dark and light mode
      ml={[2, 4]}
      hoverColor="primary"
      focusColor="text"
      {...props}
    >
      <Icon name={colorMode === "default" ? "sun" : "moon"} size="5" />
    </Button>
  )
}

comment.js:

import React, { Component } from 'react';
// import { useColorMode } from '@reflexjs/gatsby-theme-core';

export default class Comments extends Component {
  constructor(props) {
    super(props);
    this.commentBox = React.createRef();
  }

  componentDidMount() {
    const scriptEl = document.createElement('script');
    scriptEl.async = true;
    scriptEl.src = 'https://utteranc.es/client.js';
    scriptEl.setAttribute('src', 'https://utteranc.es/client.js');
    scriptEl.setAttribute('crossorigin', 'anonymous');
    scriptEl.setAttribute('repo', 'loctran016/loctran016.github.io');
    scriptEl.setAttribute('issue-term', 'title');
    scriptEl.setAttribute('id', 'utterance');
    scriptEl.setAttribute('theme', 'github-light');
    this.commentBox.current.appendChild(scriptEl);
  }

  render() {
    return (
      <div id="comments" className="comment-box-wrapper container pt-9">
        <h1 className="my-0">
          Comments
        </h1>
        <hr className="my-0" />
        <div ref={this.commentBox} className="comment-box" />
      </div>
    );
  }
}
tristan957 commented 3 years ago

scriptEl.setAttribute('theme', 'github-light');

make that dependent on your color mode and try again. Also you are setting src twice.

titieo commented 3 years ago

scriptEl.setAttribute('theme', 'github-light');

make that dependent on your color mode and try again. Also you are setting src twice.

I've done this. My code is here: comment.js and header.mdx but my site still require a reload to change the utturances theme

tristan957 commented 3 years ago

Class-based components have a method to determine whether they should re-render. I forget what it is called. You need to implement that method and say if the color changes, re-render. I have moved on to hook-based components, but that is what I remember from React pre-hooks!

shouldComponentUpdate() is what you are looking for.

I am really not sure how using hooks in class-based components works though since your useColorMode() looks like a hook.

titieo commented 3 years ago

I am really not sure how using hooks in class-based components works though since your useColorMode() looks like a hook. Well, here is my code for dark/light mode button (if it's dark mode, the id of button will be 'dark', in light mode is 'light')

const [colorMode, setColorMode] = useColorMode()
id={colorMode === "default" ? "light" : "dark"}

and then set the theme like this:

const utturancesTheme = typeof(document.getElementById("dark")) != 'undefined' && document.getElementById("dark") != null ? 'icy-dark' : 'github-light';    
scriptEl.setAttribute('theme', utturancesTheme);

Well, the shouldComponentUpdate() doesn't work, it just add another utterances (for ex: if I click on the ToggleButton once, there will be 2 utturances comment box on the page). Anyway, thanks for your help But actually, reload will make it render and people don't usually change the light/dark mode too many times

tristan957 commented 3 years ago

You need to unmount the first utterances instance. That's what I do

titieo commented 3 years ago

You need to unmount the first utterances instance. That's what I do

I've tried. Using .remove() doesn't help.

justin-calleja commented 3 years ago

What's the suggested approach to add a loading spinner while the theme changes? After injecting the script tag, it overwrites itself and inserts the utterance iframe. I want to show a loading spinner instead of the iframe as soon as the theme is changed and inject another script tag to load the new theme.

Thanks

EDIT: doesn't address spinner but uses postMessage to change theme

I came across the following in this repo in src/theme.ts:

    addEventListener('message', event => {
      if (event.origin === origin && event.data.type === 'set-theme') {
        link.href = `/stylesheets/themes/${event.data.theme}/utterances.css`;
      }
    });

So one way to do is to avoid injecting the script again but send a postMessage instead. Note: if you have dev tools open and you are disabling cache while they're open in your settings, then there will be a delay when you switch your comments section's theme. If anyone else is interested, you can find my convoluted solution (using React) below. The TLDR is this though:

 iframe.contentWindow.postMessage({ type: "set-theme", theme: themeName },  "*")

Also, you can probably make the solution less convoluted by copying the useEffect in useScript and just not depending on attributes in order to re-run the effect. You want to change the theme via postMessage not via re-injection of script tag.

Comments.js

import React, { useRef } from "react"
import useScript from "~utils/useScript"

const attributes = {
  //   repo: "justin-calleja/choices",
  repo: "justin-calleja/justincalleja.com",
  "issue-term": "pathname",
  label: "comment",
  theme: "github-dark",
  crossorigin: "anonymous",
}

const Comments = ({ themeName }) => {
  const initThemeNameRef = useRef(themeName)
  // avoid re-rendering the script tag by using the ref value for the
  // theme name rather than the prop. Use postMessage to change the
  // theme instead of removing the iframe and injecting the utterance
  // script again.
  attributes.theme = initThemeNameRef.current
  const commentsRef = useRef(null)
  useScript("https://utteranc.es/client.js", attributes, commentsRef)

  const prevThemeNameRef = useRef(themeName)
  // if themeName prop has changed:
  if (prevThemeNameRef.current !== themeName) {
    prevThemeNameRef.current = themeName
    if (commentsRef.current) {
      const iframe = commentsRef.current.getElementsByTagName("iframe")[0]
      if (iframe) {
        console.log("about to postMessage with themeName:", themeName)
        iframe.contentWindow.postMessage(
          { type: "set-theme", theme: themeName },
          "*",
        )
      }
    }
  }

  return <div className="comments" ref={commentsRef}></div>
}

export default Comments

useScript.js

// @ts-check
import { useEffect } from "react"

/**
 *
 * @param {string} url
 * @param {{ [key: string]:  string }} attributes
 * @param {MutableRefObject<HTMLElement | null>} parentRef
 */
const useScript = (url, attributes = {}, parentRef) => {
  useEffect(() => {
    const script = document.createElement("script")

    script.src = url
    script.async = true

    if (attributes) {
      for (let [key, value] of Object.entries(attributes)) {
        script.setAttribute(key, value)
      }
    }

    const parentEl =
      parentRef && parentRef.current ? parentRef.current : document.body
    parentEl.appendChild(script)

    return () => {
      // NOTE: script might have been removed e.g. when using utteranc
      // script (for comments), once you inject the script it overwrites
      // itself to include an iframe in its place. i.e. the original script
      // tag is no longer in the html.
      try {
        parentEl.removeChild(script)
      } catch (err) {
        // The node to be removed is not a child of this node
        if (err.message.includes("is not a child of this node")) {
          parentEl.innerHTML = ""
        }
      }
    }
  }, [url, attributes, parentRef])
}

export default useScript
qianbinbin commented 3 years ago

If you are using hugo and want utterances theme consistent with your website, this may help:

        <section class="article discussion">
            <script>
                function loadComment() {
                    let theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'github-dark' : 'github-light';
                    let s = document.createElement('script');
                    s.src = 'https://utteranc.es/client.js';
                    s.setAttribute('repo', '{{- .Site.Params.comments.utterancesRepo -}}');
                    s.setAttribute('issue-term', '{{- .Site.Params.comments.utterancesIssueTerm | default "pathname" -}}');
                    s.setAttribute('theme', theme);
                    s.setAttribute('crossorigin', 'anonymous');
                    s.setAttribute('async', '');
                    document.querySelector('section.article.discussion').innerHTML = '';
                    document.querySelector('section.article.discussion').appendChild(s);
                }

                loadComment();
                if (window.matchMedia)
                    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {loadComment();});
            </script>
        </section>
Dialvive commented 3 years ago

I'm using Jekyll, in my blog, I have a method that is called each time the theme is changed. Just as @justin-calleja says, posting a message to the utterances iframe should generally work to switch themes:

const utterancesTheme = newTheme === themes.DARK ? "github-dark" : "github-light";
document.getElementsByClassName("utterances-frame")[0].contentWindow.postMessage({ type: "set-theme", theme:  utterancesTheme },  "*")

My approach to load the correct theme on the first time is to declare two Utterances with different themes, then get the current theme from a variable in localStorage, finally delete the utterances theme we don't want:

<div id = "uttComments">
  <script id="uttDark"
    src="https://utteranc.es/client.js"
    repo="Dialvive/dialvive.github.io"
    issue-term="pathname"
    label="✨💬✨"
    theme="github-dark"
    crossorigin="anonymous"
    async>
  </script>

  <script id="uttLight"
    src="https://utteranc.es/client.js"
    repo="Dialvive/dialvive.github.io"
    issue-term="pathname"
    label="✨💬✨"
    theme="github-light"
    crossorigin="anonymous"
    async>
  </script>
</div>

<script>
  const savedTheme = localStorage.getItem("theme");
  var uttLight = document.getElementById("uttLight");
  var uttDark = document.getElementById("uttDark");

  if(savedTheme === themes.DARK){
    uttLight.parentNode.removeChild(uttLight);
  } else {
    uttDark.parentNode.removeChild(uttDark);
  }
</script>

Almost always the unnecessary Utterances is deleted before it is loaded, therefore it is better than hiding it completely loaded.

mgsloan commented 2 years ago

Combining loctran016's approach with some of qianbinbin's code and adapting to dark-mode-toggle:

  function getUtterancesTheme(mode) {
    return mode === 'dark' ? 'github-dark' : 'github-light';
  }

  // Defer loading utterances until everything else is loaded.
  // This way more important page content is prioritized.
  window.addEventListener('load', () => {
    let initialTheme = 'github-light';
    const toggle = document.getElementsByTagName('dark-mode-toggle');
    if (toggle.length !== 1) {
      console.warn('Expected one dark-mode-toggle, but got: ', toggle);
    } else {
      initialTheme = getUtterancesTheme(toggle[0].mode);
    }

    // Add script that loads utterance
    const commentsContainer = document.getElementById('comments');
    if (commentsContainer) {
      const s = document.createElement('script');
      s.src = 'https://utteranc.es/client.js';
      s.setAttribute('repo', REPO);
      s.setAttribute('issue-term', 'pathname');
      s.setAttribute('theme', initialTheme);
      s.setAttribute('crossorigin', 'anonymous');
      s.setAttribute('async', '');
      commentsContainer.appendChild(s);
    }
  });

  document.addEventListener('colorschemechange', (e) => {
    const mode = e.detail.colorScheme;
    const theme = getUtterancesTheme(mode);
    for (const frame of document.getElementsByClassName('utterances-frame')) {
      frame.contentWindow.postMessage({ type: 'set-theme', theme:  theme },  '*');
    }
  });

This script expects to find a <div id="comments"></div> in the page, and will place utterances inside this div.

MolotovCherry commented 2 years ago

By far the best way to do it is to use what's already built in (although same as previous comment, but it's clearer since it's barebones)

let iframe = document.querySelector("iframe");
let msg = {
  type: "set-theme",
  theme: 'your-theme'
};
iframe.contentWindow.postMessage(msg, "https://utteranc.es");
mgsloan commented 2 years ago

Yes, sending the set-theme message is clear. However, this means that the theme will switch after load and this change will be visible to the user. To me that seems sloppy so instead my solution initializes it on the component.

Also, when do you send the message to have it switch? I imagine there is a way to know when utterances is ready to receive messages, but I'm not sure what it is. I don't think that messages sent via postMessage get queued waiting for a listener, since that would be asking for memory leaks (but it would allow for much cleaner solutions to this if it did).

MolotovCherry commented 2 years ago

Yes, sending the set-theme message is clear. However, this means that the theme will switch after load and this change will be visible to the user. To me that seems sloppy so instead my solution initializes it on the component.

Also, when do you send the message to have it switch? I imagine there is a way to know when utterances is ready to receive messages, but I'm not sure what it is. I don't think that messages sent via postMessage get queued waiting for a listener, since that would be asking for memory leaks (but it would allow for much cleaner solutions to this if it did).

What I posted wasn't meant to be sent when you load it to change it (that's sloppy as you said). For that, you can simply load the script with the correct theme already specified, as you did. (Besides, I had no need to re-post a solution for that which you already provided)

It was meant for changing the theme after it's already loaded (like the website theme switches). This is in-line with the title of the issue feature request (as people already know they can specify a different theme when it loads, that's the easy part).

So it was just a stripped down version of the answer to the issue's title, for anyone that wanted to see it easily.

Of course, it has been stated quite a few times here already. I just posted it because it's easy to see (perhaps compared to some other answers, due to formatting. Plus, I had to figure this solution out for myself, so I wanted to share it at the same time)

Your answer is plenty useful, thanks!