v2ex-reaciton

给v2ex增加emoji reaction功能

// ==UserScript==
// @name         v2ex-reaciton
// @namespace    npm/vite-plugin-monkey
// @version      0.1.1
// @author       yuyinws
// @description  给v2ex增加emoji reaction功能
// @license      MIT
// @icon         https://vitejs.dev/logo.svg
// @iconURL      https://www.v2ex.com/static/favicon.ico
// @match        *://*.v2ex.com/t/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.prod.js
// ==/UserScript==

(o=>{const e=document.createElement("style");e.dataset.source="vite-plugin-monkey",e.textContent=o,document.head.append(e)})(' :root{--emojir-text-primary: #24292f}@media (prefers-color-scheme: dark){:root{--emojir-text-primary: #f5f5f5}}.emoji-reaction{display:flex;flex-direction:column;align-items:center;gap:1rem;margin:1rem 0;flex-wrap:wrap}.emoji-title{font-size:14px;font-weight:600;cursor:pointer;color:var(--emojir-text-primary)}.emoji-face-icon:before,.emoji-face-icon::-webkit-details-marker{display:none}.emoji-face-icon::marker{content:""}.emoji-face-icon{height:100%;width:100%;display:flex;justify-content:center;align-items:center;cursor:pointer}.emoji-list{display:flex;gap:5px}.emoji-menu{position:relative;background:#f6f8fa;border:1px solid #d0d7de;border-radius:50%;width:24px;height:24px}.emoji-menu:hover{background:#eaeef2}.emoji-panel-list{display:flex;flex-wrap:wrap;gap:.3rem}.emoji-panel{padding:.5rem;position:absolute;z-index:10;box-shadow:0 0 10px #0000001a;border-radius:.5rem;background-color:#fff;display:flex;flex-wrap:wrap;width:9.5rem}.emoji-panel-login{font-size:12px;color:#2563eb!important;font-style:italic}.emoji-item{padding:.5rem;width:1rem;height:1rem;cursor:pointer;border-radius:3px;display:flex;justify-content:center;align-items:center}.emoji-item:hover{background:#f3f4f6;font-size:20px;transition:font-size .2s ease-in-out}.emoji-item-reacted{background:#ddf4ff}.emoji-counter{padding:0 4px;font-size:12px;border-radius:100px;background:red;height:24px;width:34px;line-height:24px;background:#fff;border:1px solid #d1d5db;cursor:pointer;color:#222}.emoji-counter:hover{background:#eaeef2}.emoji-counter-reacted{background:#ddf4ff;border:1px solid #0969da}.emoji-counter-reacted:hover{background:#b6e3ff}.emoji-item-disabled{cursor:not-allowed;opacity:.5} ');

(function (vue) {
  'use strict';

  function getSearchParam(key) {
    const params = new URLSearchParams(window.location.search);
    return params.get(key);
  }
  const emojiMap = {
    THUMBS_UP: "👍",
    THUMBS_DOWN: "👎",
    LAUGH: "😄",
    HOORAY: "🎉",
    CONFUSED: "😕",
    HEART: "❤️",
    ROCKET: "🚀",
    EYES: "👀"
  };
  const serverDomin = "https://v2ex-reaction.vercel.app";
  const token = vue.ref("");
  const authURL = vue.ref("");
  const isAuth = vue.ref(false);
  function useAuth() {
    async function genAuthURL() {
      const href = window.location.href;
      const response = await fetch(`${serverDomin}/authorize?app_return_url=${href}`);
      const data = await response.text();
      authURL.value = data;
    }
    function setToken() {
      const emoji_token = getSearchParam("emoji-reaction-token") || localStorage.getItem("emoji-reaction-token");
      if (emoji_token) {
        localStorage.setItem("emoji-reaction-token", emoji_token);
        token.value = emoji_token;
        isAuth.value = true;
      }
    }
    setToken();
    return {
      genAuthURL,
      authURL,
      token,
      isAuth
    };
  }
  const reactions = vue.ref([]);
  const subjectId = vue.ref("");
  const filteredReactions = vue.computed(() => {
    return reactions.value.filter((reaction) => reaction.totalCount > 0);
  });
  const totalCount = vue.computed(() => {
    return filteredReactions.value.reduce((total, reaction) => {
      return total + reaction.totalCount;
    }, 0);
  });
  function useReaction() {
    const discussionUrl = vue.ref("");
    const loading = vue.ref(false);
    async function getReaction() {
      try {
        loading.value = true;
        const pathname = window.location.pathname;
        if (pathname.includes("review"))
          return;
        const token2 = localStorage.getItem("emoji-reaction-token");
        const url = new URL(`${serverDomin}/getDiscussion`);
        if (token2)
          url.searchParams.append("token", token2);
        if (pathname)
          url.searchParams.append("pathname", pathname);
        const response = await fetch(url.toString());
        const { data, state } = await response.json();
        if (state === "fail")
          throw new Error(data);
        const reactionNodes = data.search.nodes;
        if (!reactionNodes.length) {
          const createUrl = new URL(`${serverDomin}/createDiscussion`);
          if (pathname)
            createUrl.searchParams.append("pathname", pathname);
          const res = await fetch(createUrl);
          const createData = await res.json();
          if (createData.state === "ok") {
            setTimeout(() => {
              getReaction();
            }, 2e3);
          }
        } else {
          const reactionGroups = reactionNodes[0].reactionGroups;
          const discussionId = reactionNodes[0].id;
          const _discussionUrl = reactionNodes[0].url;
          subjectId.value = discussionId;
          discussionUrl.value = _discussionUrl;
          reactions.value = reactionGroups.map((reaction) => {
            return {
              content: reaction.content,
              totalCount: reaction.users.totalCount,
              viewerHasReacted: reaction.viewerHasReacted,
              emoji: emojiMap[reaction.content]
            };
          });
        }
      } catch (error) {
        console.log(error);
      } finally {
        loading.value = false;
      }
    }
    const TOGGLE_REACTION_QUERY = (mode) => `
  mutation($content: ReactionContent!, $subjectId: ID!) {
    toggleReaction: ${mode}Reaction(input: {content: $content, subjectId: $subjectId}) {
      reaction {
        content
        id
      }
    }
  }`;
    async function clickReaction(isAuth2, content, token2, viewerHasReacted, cb) {
      try {
        if (!isAuth2)
          return;
        await fetch("https://api.github.com/graphql", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Authorization": `Bearer ${token2}`
          },
          body: JSON.stringify({
            query: TOGGLE_REACTION_QUERY(viewerHasReacted ? "remove" : "add"),
            variables: {
              subjectId: subjectId.value,
              content
            }
          })
        });
        await getReaction();
      } catch (error) {
        console.log(error);
      } finally {
        cb();
      }
    }
    return {
      reactions,
      getReaction,
      filteredReactions,
      totalCount,
      clickReaction,
      discussionUrl,
      loading
    };
  }
  const _hoisted_1$1 = { class: "emoji-list" };
  const _hoisted_2$1 = { class: "emoji-face-icon" };
  const _hoisted_3$1 = ["fill"];
  const _hoisted_4$1 = /* @__PURE__ */ vue.createElementVNode("path", { d: "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm3.82 1.636a.75.75 0 0 1 1.038.175l.007.009c.103.118.22.222.35.31.264.178.683.37 1.285.37.602 0 1.02-.192 1.285-.371.13-.088.247-.192.35-.31l.007-.008a.75.75 0 0 1 1.222.87l-.022-.015c.02.013.021.015.021.015v.001l-.001.002-.002.003-.005.007-.014.019a2.066 2.066 0 0 1-.184.213c-.16.166-.338.316-.53.445-.63.418-1.37.638-2.127.629-.946 0-1.652-.308-2.126-.63a3.331 3.331 0 0 1-.715-.657l-.014-.02-.005-.006-.002-.003v-.002h-.001l.613-.432-.614.43a.75.75 0 0 1 .183-1.044ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM5 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm5.25 2.25.592.416a97.71 97.71 0 0 0-.592-.416Z" }, null, -1);
  const _hoisted_5$1 = [
    _hoisted_4$1
  ];
  const _hoisted_6$1 = { class: "emoji-panel" };
  const _hoisted_7$1 = ["href"];
  const _hoisted_8$1 = /* @__PURE__ */ vue.createElementVNode("span", { style: { "font-size": "12px", "font-style": "italic", "color": "#94a3b8" } }, "以添加反应", -1);
  const _hoisted_9 = { class: "emoji-panel-list" };
  const _hoisted_10 = ["onClick"];
  const _sfc_main$1 = /* @__PURE__ */ vue.defineComponent({
    __name: "Menu",
    props: {
      reactions: {
        type: Array,
        required: true
      },
      color: {
        type: String,
        default: "#000"
      }
    },
    setup(__props) {
      const { clickReaction } = useReaction();
      const { token: token2, isAuth: isAuth2, authURL: authURL2 } = useAuth();
      const emojiPanelRef = vue.ref(null);
      const vClickOutside = {
        beforeMount(el, binding) {
          el.clickOutsideEvent = function(event) {
            if (!(el === event.target || el.contains(event.target)))
              binding.value(event);
          };
          document.addEventListener("mousedown", el.clickOutsideEvent);
        },
        beforeUnmount(el) {
          document.removeEventListener("mousedown", el.clickOutsideEvent);
        }
      };
      function handleClickOutside() {
        emojiPanelRef.value.open = false;
      }
      return (_ctx, _cache) => {
        return vue.openBlock(), vue.createElementBlock("div", _hoisted_1$1, [
          vue.withDirectives((vue.openBlock(), vue.createElementBlock("details", {
            ref_key: "emojiPanelRef",
            ref: emojiPanelRef,
            class: "emoji-menu"
          }, [
            vue.createElementVNode("summary", _hoisted_2$1, [
              (vue.openBlock(), vue.createElementBlock("svg", {
                "aria-hidden": "true",
                focusable: "false",
                role: "img",
                viewBox: "0 0 16 16",
                width: "16",
                height: "16",
                fill: __props.color,
                style: { "display": "inline-block", "user-select": "none", "vertical-align": "text-bottom", "overflow": "visible" }
              }, _hoisted_5$1, 8, _hoisted_3$1))
            ]),
            vue.createElementVNode("div", _hoisted_6$1, [
              !vue.unref(isAuth2) ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [
                vue.createElementVNode("a", {
                  href: vue.unref(authURL2),
                  class: "emoji-panel-login"
                }, "登录", 8, _hoisted_7$1),
                _hoisted_8$1
              ], 64)) : vue.createCommentVNode("", true),
              vue.createElementVNode("div", _hoisted_9, [
                (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(__props.reactions, (item, index) => {
                  return vue.openBlock(), vue.createElementBlock("div", {
                    key: index,
                    class: vue.normalizeClass([[
                      item.viewerHasReacted ? "emoji-item-reacted" : "",
                      vue.unref(isAuth2) ? "" : "emoji-item-disabled"
                    ], "emoji-item"]),
                    onClick: ($event) => vue.unref(clickReaction)(vue.unref(isAuth2), item.content, vue.unref(token2), item.viewerHasReacted, handleClickOutside)
                  }, vue.toDisplayString(item.emoji), 11, _hoisted_10);
                }), 128))
              ])
            ])
          ])), [
            [vClickOutside, handleClickOutside]
          ])
        ]);
      };
    }
  });
  const _hoisted_1 = { key: 0 };
  const _hoisted_2 = /* @__PURE__ */ vue.createElementVNode("img", {
    width: "50",
    style: { "margin-top": "1rem" },
    height: "50",
    src: "https://raw.githubusercontent.com/yuyinws/v2ex-reaction/main/source/loading.gif",
    alt: "loading",
    srcset: ""
  }, null, -1);
  const _hoisted_3 = [
    _hoisted_2
  ];
  const _hoisted_4 = { key: 1 };
  const _hoisted_5 = { class: "emoji-reaction" };
  const _hoisted_6 = ["href"];
  const _hoisted_7 = { class: "emoji-list" };
  const _hoisted_8 = ["onClick"];
  const _sfc_main = /* @__PURE__ */ vue.defineComponent({
    __name: "App",
    setup(__props) {
      const {
        reactions: reactions2,
        getReaction,
        filteredReactions: filteredReactions2,
        totalCount: totalCount2,
        clickReaction,
        discussionUrl,
        loading
      } = useReaction();
      const { genAuthURL, isAuth: isAuth2, token: token2 } = useAuth();
      function init() {
        if (!isAuth2.value)
          genAuthURL();
        getReaction();
      }
      vue.onMounted(() => {
        init();
      });
      return (_ctx, _cache) => {
        return vue.unref(loading) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_1, _hoisted_3)) : (vue.openBlock(), vue.createElementBlock("div", _hoisted_4, [
          vue.createElementVNode("div", _hoisted_5, [
            vue.createElementVNode("a", {
              class: "emoji-title",
              href: vue.unref(discussionUrl),
              target: "_blank"
            }, vue.toDisplayString(vue.unref(totalCount2)) + "个反应 ", 9, _hoisted_6),
            vue.createElementVNode("div", _hoisted_7, [
              vue.createVNode(_sfc_main$1, {
                reactions: vue.unref(reactions2),
                color: "#444"
              }, null, 8, ["reactions"]),
              (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(filteredReactions2), (item, index) => {
                return vue.openBlock(), vue.createElementBlock("div", {
                  key: index,
                  class: vue.normalizeClass([[
                    item.viewerHasReacted ? "emoji-counter-reacted" : "",
                    vue.unref(isAuth2) ? "" : "emoji-item-disabled"
                  ], "emoji-counter"]),
                  onClick: ($event) => vue.unref(clickReaction)(vue.unref(isAuth2), item.content, vue.unref(token2), item.viewerHasReacted)
                }, vue.toDisplayString(item.emoji) + " " + vue.toDisplayString(item.totalCount), 11, _hoisted_8);
              }), 128))
            ])
          ])
        ]));
      };
    }
  });
  vue.createApp(_sfc_main).mount(
    (() => {
      const emojiApp = document.createElement("div");
      emojiApp.id = "emoji-reaction";
      const parentEL = document.querySelector("#Main > .box");
      const topicBtnEl = document.querySelector(".topic_buttons");
      parentEL.insertBefore(emojiApp, topicBtnEl);
      return emojiApp;
    })()
  );

})(Vue);