Skip to content

Instantly share code, notes, and snippets.

@dallasread
Last active December 1, 2022 13:04
Show Gist options
  • Save dallasread/82e3263ab3344909b3b87ea7c1c550d8 to your computer and use it in GitHub Desktop.
Save dallasread/82e3263ab3344909b3b87ea7c1c550d8 to your computer and use it in GitHub Desktop.
github-commenter.user.js
// ==UserScript==
// @name GitHubCommenter
// @version 1
// @grant none
// @match https://dnsimple.com/admin*
// ==/UserScript==
class Commenter {
constructor($target, namespace, repo) {
this.repo = repo
this.namespace = namespace
this.accessToken = null
this.cookieName = `${this.namespace}-access-token`
this.appendElements($target)
this.init()
}
init() {
const accessToken = localStorage.getItem(this.cookieName)
if (accessToken) {
this.accessToken = accessToken
this.fetch()
}
}
appendElements($target) {
const $style = document.createElement('style')
$style.innerHTML = `
@keyframes fadeslideUp {
0% {
-webkit-transform: translate3d(0, 100%, 0);
transform: translate3d(0, 100%, 0);
visibility: visible;
}
100% {
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
}
.${this.namespace} {
position: fixed;
bottom: 0;
left: 0;
line-height: 1.6;
box-sizing: border-box;
z-index: 9999999;
}
.${this.namespace} *, .${this.namespace} *::before, .${this.namespace} *::after {
box-sizing: inherit;
}
.${this.namespace} .${this.namespace}-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 9999999;
}
.${this.namespace} .${this.namespace}-prompt {
position: fixed;
left: 0.5rem;
bottom: 0.5rem;
}
.${this.namespace} .${this.namespace}-prompt svg {
transform: scaleX(-1);
display: block;
width: 2.5rem;
height: 2.5rem;
}
.${this.namespace} .${this.namespace}-prompt .${this.namespace}-counter {
display: block;
width: 1.25rem;
height: 1.25rem;
position: absolute;
bottom: 1.25rem;
left: 1.75rem;
border-radius: 50%;
background: #1f6feb;
color: #fff;
text-align: center;
font-size: 0.6rem;
padding-top: 0.1rem;
}
.${this.namespace} .${this.namespace}-header {
background: #eee;
padding: 1rem;
border-bottom: 1px solid #ddd;
}
.${this.namespace} .${this.namespace}-header h3 {
margin: 0;
line-height: 1;
}
.${this.namespace} .${this.namespace}-header .${this.namespace}-html-url {
display: inline-block;
margin-left: 0.5rem;
}
.${this.namespace} .${this.namespace}-header .${this.namespace}-forget-link {
font-size: 0.75rem;
float: right;
}
.${this.namespace} form label {
display: block;
}
.${this.namespace} form {
margin: 0;
line-height: 1;
}
.${this.namespace} input {
width: 100%;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
}
.${this.namespace} .${this.namespace}-login button {
width: 100%;
margin-top: 1rem;
}
.${this.namespace} .${this.namespace}-page {
position: fixed;
bottom: 0;
left: 0;
width: 30rem;
background: #fff;
border-top-right-radius: 4px;
border-top: 1px solid #ddd;
border-right: 1px solid #ddd;
z-index: 10000000;
animation: fadeslideUp;
-webkit-animation-duration: 500ms;
animation-duration: 500ms;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.${this.namespace} .${this.namespace}-comments {
position: relative;
background: #fafafa;
}
.${this.namespace} .${this.namespace}-comments form textarea {
width: 100%;
border: 0;
height: 6rem;
padding: 0.5rem;
border-top: 1px solid #ddd;
outline: 0 !important;
resize: none;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);.
background: #fff;
line-height: 1.6;
}
.${this.namespace} .${this.namespace}-comments form button {
position: absolute;
bottom: 5px;
right: 5px;
}
.${this.namespace} button {
background: #1f6feb;
color: #fff;
border-radius: 4px;
padding: 0.25rem 0.75rem;
border: 0;
display: block;
line-height: 1.6;
}
.${this.namespace} .${this.namespace}-page form[disabled] button {
opacity: 0.5;
content: 'Loading...'
}
.${this.namespace} .${this.namespace}-with-padding {
padding: 1rem;
}
.${this.namespace} .${this.namespace}-comments ul {
margin: 0;
padding: 0px;
list-style: none;
max-height: 60vh;
overflow-y: auto;
margin-bottom: -1px;
}
.${this.namespace} .${this.namespace}-comments ul li {
margin: 0;
padding: 0px;
border-bottom: 1px solid #ddd;
padding: 1rem;
}
.${this.namespace} .${this.namespace}-comments ul li:last-child {
border-bottom: 0;
}
.${this.namespace} .${this.namespace}-author {
font-size: 0.8rem;
opacity: 0.8;
}
.${this.namespace} p {
margin: 0;
}
`
this.$commenter = document.createElement('div')
this.$commenter.className = this.namespace
this.$overlay = document.createElement('div')
this.$overlay.className = `${this.namespace}-overlay`
this.$overlay.style.display = 'none'
this.$overlay.onclick = () => this.close()
this.$commenter.appendChild(this.$overlay)
this.$prompt = document.createElement('a')
this.$prompt.href = 'javascript:;'
this.$prompt.className = `${this.namespace}-prompt`
this.$prompt.innerHTML = Commenter.ICON('#ccc')
this.$prompt.onclick = () => this.toggle()
this.$counter = document.createElement('span')
this.$counter.className = `${this.namespace}-counter`
this.$counter.innerText = '?'
this.$prompt.appendChild(this.$counter)
this.$commenter.appendChild(this.$prompt)
this.$login = document.createElement('form')
this.$login.className = `${this.namespace}-login ${this.namespace}-page ${this.namespace}-with-padding`
this.$login.style.display = 'none'
const $accessToken = document.createElement('input')
$accessToken.type = 'password'
const $accessTokenLabel = document.createElement('label')
$accessTokenLabel.innerHTML = 'GitHub Access Token (<a href="https://github.com/settings/tokens" target="_blank">info</a>)'
$accessTokenLabel.for = $accessToken
const $button = document.createElement('button')
$button.innerText = 'Log in'
$button.type = 'submit'
this.$login.onsubmit = (event) => {
event.preventDefault();
this.updateAccessToken($accessToken.value)
this.$login.reset()
this.go('$comments')
}
this.$login.onMounted = () => {
$accessToken.focus()
}
this.$login.appendChild($accessTokenLabel)
this.$login.appendChild($accessToken)
this.$login.appendChild($button)
this.$commenter.appendChild(this.$login)
this.$comments = document.createElement('div')
this.$comments.className = `${this.namespace}-comments ${this.namespace}-page`
this.$commentsHeader = document.createElement('div')
this.$commentsHeader.className = `${this.namespace}-header`
this.$commentsHeaderTitle = document.createElement('h3')
this.$commentsHeaderTitle.innerText = 'Comments'
this.$commentsHeaderTitleLink = document.createElement('a')
this.$commentsHeaderTitleLink.className = `${this.namespace}-html-url`
this.$commentsHeaderTitleLink.style.display = 'none'
this.$commentsHeaderTitleLink.target = '_blank'
this.$commentsHeaderTitleLink.innerHTML = '🔗'
this.$commentsHeaderTitle.appendChild(this.$commentsHeaderTitleLink)
this.$commentsHeader.appendChild(this.$commentsHeaderTitle)
this.$comments.appendChild(this.$commentsHeader)
this.$error = document.createElement('p')
this.$error.style.display = 'none'
this.$commentList = document.createElement('ul')
this.$comments.style.display = 'none'
this.$reset = document.createElement('a')
this.$reset.href = 'javascript:;'
this.$reset.className = `${this.namespace}-forget-link`
this.$reset.innerText = 'Forget me'
this.$reset.onclick = () => {
if (confirm('Are you sure you want to clear your GitHub Access Token?')) {
this.reset()
}
}
this.$commentForm = document.createElement('form')
const $commentFormTextarea = document.createElement('textarea')
const $commentFormButton = document.createElement('button')
$commentFormButton.innerText = 'Comment'
$commentFormButton.type = 'submit'
$commentFormTextarea.placeholder = 'Leave a comment'
this.$commentForm.onsubmit = async (event) => {
event.preventDefault();
const comment = $commentFormTextarea.value
this.$commentForm.reset()
this.$commentForm.setAttribute('disabled', 'disabled')
await this.addComment(comment)
this.$commentForm.removeAttribute('disabled')
}
this.$comments.onMounted = () => {
$commentFormTextarea.focus()
this.fetch();
}
this.$commentForm.appendChild($commentFormTextarea)
this.$commentForm.appendChild($commentFormButton)
this.$comments.appendChild(this.$error)
this.$commentsHeaderTitle.appendChild(this.$reset)
this.$comments.appendChild(this.$commentList)
this.$commenter.appendChild(this.$comments)
this.$comments.appendChild(this.$commentForm)
$target.appendChild(this.$commenter)
$target.appendChild($style)
}
updateAccessToken(accessToken) {
this.accessToken = accessToken
localStorage.setItem(this.cookieName, accessToken)
}
updateCounter(content) {
this.$counter.innerText = content
}
async _fetchIssue(url) {
const q = encodeURIComponent(`type:issue in:title repo:${this.repo} ${url}`)
const response = await fetch(`https://api.github.com/search/issues?q=${q}&sort=created&order=asc`, {
method: 'get',
headers: {
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
Authorization: `token ${this.accessToken}`
}
})
const data = await response.json()
if (data.message) {
this.error(data.message)
return null
}
return data.items.filter((item) => item.title === url)[0]
}
async _fetchComments(issue) {
const response = await fetch(issue.comments_url, {
method: 'get',
headers: {
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
Authorization: `token ${this.accessToken}`
}
})
const data = await response.json()
if (data.message) {
this.error(data.message)
return []
}
return data
}
async _createIssue(url) {
const response = await fetch(`https://api.github.com/repos/${this.repo}/issues`, {
method: 'post',
headers: {
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
Authorization: `token ${this.accessToken}`
},
body: JSON.stringify({
title: url
})
})
const data = await response.json()
if (data.message) {
alert(data.message)
return
}
return data
}
async _createComment(issue, comment) {
const response = await fetch(issue.comments_url, {
method: 'post',
headers: {
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
Authorization: `token ${this.accessToken}`
},
body: JSON.stringify({
body: comment
})
})
const data = await response.json()
if (data.message) {
alert(data.message)
return
}
}
async addComment(comment) {
const url = window.location.href
let issue = await this._fetchIssue(url)
if (!issue) {
issue = await this._createIssue(url)
}
if (!issue) {
alert('Could not create the issue. :(')
return
}
this.updateHeader(issue)
await this._createComment(issue, comment)
await this._updateComments(issue)
}
async fetch() {
this.$commentList.scrollTop = this.$commentList.scrollHeight;
const issue = await this._fetchIssue(window.location.href)
if (!issue) {
this.updateCounter(0)
return
}
this.updateHeader(issue)
this._updateComments(issue)
}
async _updateComments(issue) {
const comments = await this._fetchComments(issue)
this.updateCounter(comments.length)
this.$commentList.innerHTML = ''
comments.forEach((comment) => {
const $comment = document.createElement('li')
const $body = document.createElement('div')
$body.innerHTML = this.linkify(comment.body)
$comment.appendChild($body)
const $author = document.createElement('p')
$author.className = `${this.namespace}-author`
$author.innerText = comment.user.login
$comment.appendChild($author)
this.$commentList.appendChild($comment)
})
this.$commentList.scrollTop = this.$commentList.scrollHeight;
}
updateHeader(issue) {
this.$commentsHeaderTitleLink.style.display = 'inline-block'
this.$commentsHeaderTitleLink.href = issue.html_url
}
toggle() {
if (this.isOpen()) {
this.close()
} else if (this.accessToken) {
this.go('$comments')
} else {
this.go('$login')
}
}
error(msg) {
this.updateCounter('?')
this.$error.innerText = msg
this.$error.style.display = 'block'
}
isOpen() {
return this.$login.style.display !== 'none' || this.$comments.style.display !== 'none'
}
close() {
this.$overlay.style.display = 'none'
this.$login.style.display = 'none'
this.$comments.style.display = 'none'
}
linkify(str) {
if (!str) {
return '';
}
return str.replace(Commenter.LINK_DETECTOR, (url) => {
let hyperlink = url
if (!hyperlink.match('^https?:\/\/')) {
hyperlink = `http://${hyperlink}`
}
return `<a href="${hyperlink}" target="_blank">${url}</a>`
})
}
go(path) {
this.close()
this[path].style.display = 'block'
this.$overlay.style.display = 'block'
if (typeof this[path].onMounted === 'function') {
this[path].onMounted()
}
}
reset() {
this.$error.style.display = 'none'
this.$error.innerHTML = ''
this.$commentList.innerHTML = ''
this.updateCounter('?')
this.accessToken = null
localStorage.removeItem(this.cookieName)
this.close()
}
}
Commenter.ICON = (color) => {
return `<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path fill="${color}" d="M 2 5 L 2 21 L 6 21 L 6 26.09375 L 7.625 24.78125 L 12.34375 21 L 22 21 L 22 5 Z M 4 7 L 20 7 L 20 19 L 11.65625 19 L 11.375 19.21875 L 8 21.90625 L 8 19 L 4 19 Z M 24 9 L 24 11 L 28 11 L 28 23 L 24 23 L 24 25.90625 L 20.34375 23 L 12.84375 23 L 10.34375 25 L 19.65625 25 L 26 30.09375 L 26 25 L 30 25 L 30 9 Z"/></svg>`
}
Commenter.LINK_DETECTOR = /(((https?:\/\/)|(www\.))[^\s]+)/g
;(() => {
new Commenter(document.body, 'commenter', 'dnsimple/dnsimple-admin-comments')
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment