Initial version

master
Clement Bois 2019-06-07 15:05:12 +02:00
commit b38b400e04
8 changed files with 465 additions and 0 deletions

99
README.md Normal file
View File

@ -0,0 +1,99 @@
# Comtodon
A minimal commenting system for static blogs using external [Mastodon](https://joinmastodon.org) or API [compatible](https://pleroma.social) server.
- **painless:** use already existing fediverse accounts
- **serverless:** hosted on static server or cdn
- **lightweight:** any useless features *(IMO)*
- **no dependencies:** pure native js
- **personal:** no style, no tracking
```html
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751"></div>
<script src="comtodon.min.js" defer></script>
```
See [index.html](index.html)
## Deep
Create a tree by adding `data-deep=N`
- 0: Full tree
- 1: Only "direct" replies
- 2: With replies to replies
- And so on
## Moderation ?
Add `data-moderator="{moderator_id}"` to display only approved *(replied)* comments
Note: Can't use fav, it requires authentication
## Hugo
Put `comtodon.min.js` in `static` folder
In your site config
```yaml
params:
comtodon:
domain: mastodon.social
# moderator: 358957
```
In your single page layout
```html
{{ if .Params.comtodon }}
<div class="comtodon" data-domain="{{ .Site.Params.comtodon.domain }}" data-status="{{ .Params.comtodon }}" {{ with .Site.Params.comtodon.moderator }}data-moderator="{{ . }}"{{ end }}></div>
<script src="//comtodon.min.js" defer></script>
{{- end }}
```
In your content header
```yaml
comtodon: 100745593232538751
```
**Style it**
## Mastodon
Only use `/api/v1/statuses/:id/context`. See [doc](https://docs.joinmastodon.org/api/rest/statuses/#get-api-v1-statuses-id-context)
> No authentication required
```
{
descendants: [{
account: {
acct,
avatar_static,
display_name,
id,
emojis: [{shortcode, static_url}],
url
},
created_at,
content,
emojis: [{shortcode, static_url}],
id,
in_reply_to_id,
sensitive,
spoiler_text
}]
}
```
## No js ?
Simple placeholder
```html
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751">
<p class="no-js sad">Enable JavaScript to see comments</p>
</div>
```
## TODO
- Use personal status as sample
- Create proxy backend for cache and moderating with fav
- Auto-magically create statuses from hugo ???

108
comtodon.js Normal file
View File

@ -0,0 +1,108 @@
(function () {
function fail(el, text) {
el.innerHTML = 'Loading fail =('
console.error(text, el)
}
function required(el, field) {
const val = el.dataset[field]
if (val) {
return val
}
fail(el, `Missing data-${field} attribut`)
}
function h(classes, content, tag = 'div') {
return `<${tag} class="${classes}">${content}</${tag}>`
}
const TIMES = new Map([
['second', 1000],
['minute', 60],
['hour', 60],
['day', 24],
['month', 30.5],
['year', 12],
['century', 100]
])
function ago(date) {
const now = Date.now()
const target = Number(new Date(date))
const prefix = target > now ? 'in ' : ''
const milliseconds = Math.floor(Math.abs(target - now))
let cur = 0
let divider = 1
let name = 'millisecond'
for (const time of TIMES) {
divider *= time[1]
const next = Math.floor(milliseconds / divider)
if (next <= 0) {
return `${prefix}${cur} ${name}${cur > 1 ? 's' : ''} ago`
}
name = time[0]
cur = next
}
return `${prefix}a long time ago`
}
function moji(text, emojis) {
for (const emoji of emojis) {
text = text.split(`:${emoji.shortcode}:`).join(
`<img class="emoji" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.static_url}">`
)
}
return text
}
function tree(statuses, parent, limit) {
const [replies, others] = statuses.reduce(([c, o], s) => (s.in_reply_to_id == parent.id ? [[...c, s], o] : [c, [...o, s]]), [[], []])
parent.replies = limit ? replies.map(r => tree(others, r, limit - 1)) : []
return parent
}
function html(statuses, domain) {
return statuses.map(({ account, created_at, content, id, emojis, sensitive, spoiler_text, replies }) =>
h('status', h('date', ago(created_at), 'p') +
h('author" href="' + account.url, `<img class="avatar" src="${account.avatar_static}" />` +
h('name', moji(account.display_name, account.emojis), 'span') +
h('acct', account.acct, 'span'), 'a') +
(spoiler_text || sensitive ? h('spoiler', spoiler_text || h('spoiler-empty', 'Sensitive', 'span')) : '') +
h('content' + (spoiler_text || sensitive ? ' sensitive' : ''), moji(content, emojis)) +
(replies ? h('replies', html(replies, domain)) : '') +
h(`reply" href="https://${domain}/interact/${id}?type=reply`, 'Reply', 'a'))).join('')
}
function moderate(statuses, id) {
if (!id) {
return statuses
}
const valids = statuses.filter(s => s.account.id == id).map(s => s.in_reply_to_id)
return statuses.filter(s => valids.includes(s.id))
}
for (const el of document.getElementsByClassName('comtodon')) {
el.innerHTML = '<div class="loading">Loading...</div>'
const domain = required(el, 'domain')
const status = required(el, 'status')
if (!domain || !status) {
return
}
fetch(`https://${domain}/api/v1/statuses/${status}/context`)
.then(res => res.json())
.then(res => {
el.innerHTML = h(`reply-main" href="https://${domain}/interact/${status}?type=reply`, 'Comment', 'a')
const statuses = moderate(res.descendants, el.dataset.moderator)
if (statuses) {
el.innerHTML += html('deep' in el.dataset ? tree(statuses, { id: status }, el.dataset.deep || -1).replies : statuses, domain)
} else {
el.innerHTML += h('empty', 'Any comment')
}
})
.catch(() => fail(el, 'Request fail'))
}
})()

1
comtodon.min.js vendored Normal file
View File

@ -0,0 +1 @@
(function(){function a(a,b){a.innerHTML="Loading fail =(",console.error(b,a)}function b(b,c){const d=b.dataset[c];return d?d:void a(b,`Missing data-${c} attribut`)}function c(a,b,c="div"){return`<${c} class="${a}">${b}</${c}>`}function d(a){const b=Date.now(),c=+new Date(a),d=c>b?"in ":"",e=Math.floor(Math.abs(c-b));let f=0,g=1,h="millisecond";for(const b of i){g*=b[1];const a=Math.floor(e/g);if(0>=a)return`${d}${f} ${h}${1<f?"s":""} ago`;h=b[0],f=a}return`${d}a long time ago`}function e(a,b){for(const c of b)a=a.split(`:${c.shortcode}:`).join(`<img class="emoji" alt="${c.shortcode}" title="${c.shortcode}" src="${c.static_url}">`);return a}function f(a,b,c){const[d,e]=a.reduce(([a,c],d)=>d.in_reply_to_id==b.id?[[...a,d],c]:[a,[...c,d]],[[],[]]);return b.replies=c?d.map(a=>f(e,a,c-1)):[],b}function g(a,b){return a.map(({account:a,created_at:f,content:h,id:i,emojis:j,sensitive:k,spoiler_text:l,replies:m})=>c("status",c("date",d(f),"p")+c("author\" href=\""+a.url,`<img class="avatar" src="${a.avatar_static}" />`+c("name",e(a.display_name,a.emojis),"span")+c("acct",a.acct,"span"),"a")+(l||k?c("spoiler",l||c("spoiler-empty","Sensitive","span")):"")+c("content"+(l||k?" sensitive":""),e(h,j))+(m?c("replies",g(m,b)):"")+c(`reply" href="https://${b}/interact/${i}?type=reply`,"Reply","a"))).join("")}function h(a,b){if(!b)return a;const c=a.filter(a=>a.account.id==b).map(a=>a.in_reply_to_id);return a.filter(a=>c.includes(a.id))}const i=new Map([["second",1e3],["minute",60],["hour",60],["day",24],["month",30.5],["year",12],["century",100]]);for(const d of document.getElementsByClassName("comtodon")){d.innerHTML="<div class=\"loading\">Loading...</div>";const e=b(d,"domain"),i=b(d,"status");if(!e||!i)return;fetch(`https://${e}/api/v1/statuses/${i}/context`).then(a=>a.json()).then(a=>{d.innerHTML=c(`reply-main" href="https://${e}/interact/${i}?type=reply`,"Comment","a");const b=h(a.descendants,d.dataset.moderator);d.innerHTML+=b?g("deep"in d.dataset?f(b,{id:i},d.dataset.deep||-1).replies:b,e):c("empty","Any comment")}).catch(()=>a(d,"Request fail"))}})();

33
index.html Normal file
View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Comtodon samples</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Comtodon samples</h1>
<main>
<section>
<h2>Simple</h2>
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751"></div>
</section>
<section>
<h2>Tree</h2>
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751" data-deep></div>
</section>
<section>
<h2>Direct</h2>
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751" data-deep="1"></div>
</section>
<section>
<h2>Moderation</h2>
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751" data-deep="1" data-moderator="358957"></div>
</section>
</main>
<script src="comtodon.js" defer></script>
</body>
</html>

26
index.pug Normal file
View File

@ -0,0 +1,26 @@
doctype html
html(lang="en")
head
meta(charset="UTF-8")
title Comtodon samples
link(rel="stylesheet" href="style.css")
body
h1 Comtodon samples
main
section
h2 Simple
.comtodon(data-domain="mastodon.social" data-status="100745593232538751")
section
h2 Tree
.comtodon(data-domain="mastodon.social" data-status="100745593232538751" data-deep)
section
h2 Direct
.comtodon(data-domain="mastodon.social" data-status="100745593232538751" data-deep="1")
section
h2 Moderation
.comtodon(data-domain="mastodon.social" data-status="100745593232538751" data-deep="1" data-moderator="358957")
script(src="comtodon.js" defer)

117
style.css Normal file
View File

@ -0,0 +1,117 @@
@charset "UTF-8";
* {
font-family: sans-serif;
}
h1, h2 {
text-align: center;
}
main {
display: -ms-grid;
display: grid;
-ms-grid-columns: (1fr)[2];
grid-template-columns: repeat(2, 1fr);
grid-gap: 2em;
padding: 0 2em;
}
.comtodon .status {
position: relative;
}
.comtodon .content, .comtodon .reply-main {
margin-bottom: .5em;
}
.comtodon .content {
border-left: 2px solid #777;
}
.comtodon .content.sensitive {
content: 'Sensitive';
}
.comtodon .replies {
margin-left: 1em;
}
.comtodon p {
margin: .1em;
}
.comtodon a {
text-decoration: none;
color: #777;
}
.comtodon .emoji {
height: 1em;
}
.comtodon .author {
display: -ms-grid;
display: grid;
-ms-grid-columns: 3.3em auto;
grid-template-columns: 3.3em auto;
-ms-grid-rows: (1em)[3];
grid-template-rows: repeat(3, 1em);
grid-gap: .1em;
}
.comtodon .author .avatar {
border-radius: 20%;
-ms-grid-row: 1;
-ms-grid-row-span: 3;
-ms-grid-column: 1;
grid-area: 1 / 1 / 4 / 1;
height: 100%;
}
.comtodon .author .name {
color: black;
font-weight: bold;
}
.comtodon .author .acct:before {
content: '@';
}
.comtodon .date {
float: right;
}
.comtodon .reply-main {
display: inline-block;
color: white;
background-color: #777;
border-radius: .5em;
padding: .1em .3em;
}
.comtodon .reply-main:after {
content: '✉';
margin-left: .5em;
}
.comtodon .reply {
visibility: hidden;
position: absolute;
right: .1em;
top: 2em;
}
.comtodon .reply:after {
content: '✉';
visibility: visible;
display: block;
position: absolute;
right: 0;
bottom: 0;
color: white;
background-color: #777;
border-radius: .5em;
font-size: .8em;
padding: .1em .3em;
}
/*# sourceMappingURL=style.css.map */

9
style.css.map Normal file
View File

@ -0,0 +1,9 @@
{
"version": 3,
"mappings": ";AAAA,AAAA,CAAC,CAAC;EACA,WAAW,EAAE,UAAU;CAAG;;AAE5B,AAAA,EAAE,EAAE,EAAE,CAAC;EACL,UAAU,EAAE,MAAM;CAAG;;AAEvB,AAAA,IAAI,CAAC;EACH,OAAO,EAAE,IAAI;EACb,qBAAqB,EAAE,cAAc;EACrC,QAAQ,EAAE,GAAG;EACb,OAAO,EAAE,KAAK;CAAG;;AAEnB,AACE,SADO,CACP,OAAO,CAAC;EACN,QAAQ,EAAE,QAAQ;CAAG;;AAFzB,AAGE,SAHO,CAGP,QAAQ,EAHV,SAAS,CAGG,WAAW,CAAC;EACpB,aAAa,EAAE,IAAI;CAAG;;AAJ1B,AAKE,SALO,CAKP,QAAQ,CAAC;EACP,WAAW,EAAE,cAAc;CAEA;;AAR/B,AAOI,SAPK,CAKP,QAAQ,AAEL,UAAU,CAAC;EACV,OAAO,EAAE,WAAW;CAAG;;AAR7B,AASE,SATO,CASP,QAAQ,CAAC;EACP,WAAW,EAAE,GAAG;CAAG;;AAVvB,AAWE,SAXO,CAWP,CAAC,CAAC;EACA,MAAM,EAAE,IAAI;CAAG;;AAZnB,AAaE,SAbO,CAaP,CAAC,CAAC;EACA,eAAe,EAAE,IAAI;EACrB,KAAK,EAAE,IAAI;CAAG;;AAflB,AAgBE,SAhBO,CAgBP,MAAM,CAAC;EACL,MAAM,EAAE,GAAG;CAAG;;AAjBlB,AAkBE,SAlBO,CAkBP,OAAO,CAAC;EACN,OAAO,EAAE,IAAI;EACb,qBAAqB,EAAE,UAAU;EACjC,kBAAkB,EAAE,cAAc;EAClC,QAAQ,EAAE,IAAI;CASK;;AA/BvB,AAuBI,SAvBK,CAkBP,OAAO,CAKL,OAAO,CAAC;EACN,aAAa,EAAE,GAAG;EAClB,SAAS,EAAE,aAAa;EACxB,MAAM,EAAE,IAAI;CAAG;;AA1BrB,AA2BI,SA3BK,CAkBP,OAAO,CASL,KAAK,CAAC;EACJ,KAAK,EAAE,KAAK;EACZ,WAAW,EAAE,IAAI;CAAG;;AA7B1B,AA8BI,SA9BK,CAkBP,OAAO,CAYL,KAAK,AAAA,OAAO,CAAC;EACX,OAAO,EAAE,GAAG;CAAG;;AA/BrB,AAgCE,SAhCO,CAgCP,KAAK,CAAC;EACJ,KAAK,EAAE,KAAK;CAAG;;AAjCnB,AAkCE,SAlCO,CAkCP,WAAW,CAAC;EACV,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,KAAK;EACZ,gBAAgB,EAAE,IAAI;EACtB,aAAa,EAAE,IAAI;EACnB,OAAO,EAAE,SAAS;CAGM;;AA1C5B,AAwCI,SAxCK,CAkCP,WAAW,AAMR,MAAM,CAAC;EACN,OAAO,EAAE,IAAI;EACb,WAAW,EAAE,IAAI;CAAG;;AA1C1B,AA2CE,SA3CO,CA2CP,MAAM,CAAC;EACL,UAAU,EAAE,MAAM;EAClB,QAAQ,EAAE,QAAQ;EAClB,KAAK,EAAE,IAAI;EACX,GAAG,EAAE,GAAG;CAYiB;;AA3D7B,AAgDI,SAhDK,CA2CP,MAAM,AAKH,MAAM,CAAC;EACN,OAAO,EAAE,IAAI;EACb,UAAU,EAAE,OAAO;EACnB,OAAO,EAAE,KAAK;EACd,QAAQ,EAAE,QAAQ;EAClB,KAAK,EAAE,CAAC;EACR,MAAM,EAAE,CAAC;EACT,KAAK,EAAE,KAAK;EACZ,gBAAgB,EAAE,IAAI;EACtB,aAAa,EAAE,IAAI;EACnB,SAAS,EAAE,IAAI;EACf,OAAO,EAAE,SAAS;CAAG",
"sources": [
"style.sass"
],
"names": [],
"file": "style.css"
}

72
style.sass Normal file
View File

@ -0,0 +1,72 @@
*
font-family: sans-serif
h1, h2
text-align: center
main
display: grid
grid-template-columns: repeat(2, 1fr)
grid-gap: 2em
padding: 0 2em
.comtodon
.status
position: relative
.content, .reply-main
margin-bottom: .5em
.content
border-left: 2px solid #777
&.sensitive
content: 'Sensitive'
.replies
margin-left: 1em
p
margin: .1em
a
text-decoration: none
color: #777
.emoji
height: 1em
.author
display: grid
grid-template-columns: 3.3em auto
grid-template-rows: repeat(3, 1em)
grid-gap: .1em
.avatar
border-radius: 20%
grid-area: 1 / 1 / 4 / 1
height: 100%
.name
color: black
font-weight: bold
.acct:before
content: '@'
.date
float: right
.reply-main
display: inline-block
color: white
background-color: #777
border-radius: .5em
padding: .1em .3em
&:after
content: ''
margin-left: .5em
.reply
visibility: hidden
position: absolute
right: .1em
top: 2em
&:after
content: ''
visibility: visible
display: block
position: absolute
right: 0
bottom: 0
color: white
background-color: #777
border-radius: .5em
font-size: .8em
padding: .1em .3em