Index: parsav.md ================================================================== --- parsav.md +++ parsav.md @@ -1,8 +1,8 @@ # parsav -**parsav** is a lightweight fediverse server +**parsav** is a lightweight social media server written in [terra](https://terralang.org), intended to integrate to some degree with the fediverse. it is named for the [Ranuir](http://ʞ.cc/fic/spirals/ranuir) words *par* "speech, communication" and *sav* "unity, togetherness, solidarity". ## backends parsav is designed to be storage-agnostic, and can draw data from multiple backends at a time. backends can be enabled or disabled at compile time to avoid unnecessary dependencies. * postgresql @@ -132,5 +132,7 @@ * lua * ldap for auth (and maybe actors?) * cdb (for static content, maybe?) * mariadb/mysql * the various nosql horrors, e.g. redis, mongo, and so on + +parsav urgently needs an internationalization framework as well. right now everything is just hardcoded in english. yuck. Index: render/docpage.t ================================================================== --- render/docpage.t +++ render/docpage.t @@ -72,19 +72,19 @@ for i=0,[pages.type.N] do if pages[i].parent == idx+1 and (pages[i].priv:sz() == 0 or (ps and pages[i].priv):sz() ~= 0) then if not started then started = true - list:lpush('') end + if started then list:lpush('') end end local terra render_docpage(co: &lib.srv.convo, pg: pref) var nullprivs: lib.store.powerset nullprivs:clear() Index: render/nav.t ================================================================== --- render/nav.t +++ render/nav.t @@ -1,16 +1,16 @@ -- vim: ft=terra local terra render_nav(co: &lib.srv.convo) var t: lib.str.acc t:init(64) if co.who ~= nil or co.srv.cfg.pol_sec == lib.srv.secmode.public then - t:lpush(' timeline') + t:lpush(' timeline') end if co.who ~= nil then - t:lpush(' compose profile configure docs log out') + t:lpush(' compose profile configure docs log out') else - t:lpush(' docs log in') + t:lpush(' docs log in') end return t:finalize() end return render_nav Index: render/profile.t ================================================================== --- render/profile.t +++ render/profile.t @@ -7,23 +7,23 @@ local terra render_profile(co: &lib.srv.convo, actor: &lib.store.actor) var aux: lib.str.acc var followed = false -- FIXME if co.aid ~= 0 and co.who.id == actor.id then - aux:compose('alter') + aux:compose('alter') elseif co.aid ~= 0 then if not followed then - aux:compose('') + aux:compose('') elseif followed then - aux:compose('') + aux:compose('') end - aux:lpush('chat') + aux:lpush('chat') if co.who.rights.powers:affect_users() and co.who:overpowers(actor) then - aux:lpush('control') + aux:lpush('control') end else - aux:compose('remote follow') + aux:compose('remote follow') end var auxp = aux:finalize() var timestr: int8[26] lib.osclock.ctime_r(&actor.knownsince, ×tr[0]) var strfbuf: int8[28*4] @@ -30,22 +30,44 @@ var stats = co.srv:actor_stats(actor.id) var sn_posts = cs(lib.math.decstr_friendly(stats.posts, &strfbuf[ [strfbuf.type.N - 1] ])) var sn_follows = cs(lib.math.decstr_friendly(stats.follows, sn_posts.ptr - 1)) var sn_followers = cs(lib.math.decstr_friendly(stats.followers, sn_follows.ptr - 1)) var sn_mutuals = cs(lib.math.decstr_friendly(stats.mutuals, sn_followers.ptr - 1)) - var bio = lib.str.plit "tall, dark, and mysterious" + var bio = lib.str.plit 'tall, dark, and mysterious' if actor.bio ~= nil then bio = lib.smackdown.html(cs(actor.bio)) end var fullname = lib.render.nym(actor,0,nil,false) defer fullname:free() var comments: lib.str.acc comments:init(64) - -- this is really more what epithets are for, i think - --if actor.rights.rank > 0 then comments:lpush('
  • staff member
  • ') end + if co.srv.cfg.master == actor.id then - comments:lpush('
  • founder
  • ') + var foundertxt = lib.str.plit 'founder' + if co.srv.cfg.ui_cue_founder:ref() then + if co.srv.cfg.ui_cue_founder.ct == 0 -- empty string, suppress field + then foundertxt = pstr.null() + else foundertxt = co.srv.cfg.ui_cue_founder + end + end + + if foundertxt:ref() then + comments:lpush('
  • '):ppush(foundertxt):lpush('
  • ') + end end if co.aid ~= 0 and actor.rights.rank ~= 0 then + var stafftxt = lib.str.plit 'site staff' + if co.srv.cfg.ui_cue_staff:ref() then + if co.srv.cfg.ui_cue_staff.ct == 0 -- empty string, suppress field + then stafftxt = pstr.null() + else stafftxt = co.srv.cfg.ui_cue_staff + end + end + + -- this is really more what epithets are for, i think + if actor.rights.rank > 0 and stafftxt:ref() then + comments:lpush('
  • '):ppush(stafftxt):lpush('
  • ') + end + if co.who:outranks(actor) then comments:lpush('
  • underling
  • ') elseif actor:outranks(co.who) then comments:lpush('
  • outranks you
  • ') end Index: render/tweet-page.t ================================================================== --- render/tweet-page.t +++ render/tweet-page.t @@ -29,21 +29,23 @@ p: &lib.store.post ): {} var livetime = co.srv:thread_latest_arrival_calc(p.id) var pg: lib.str.acc pg:init(256) + pg:lpush('
    ') -- make the OP refresh too lib.render.tweet(co, p, &pg) + pg:lpush('
    ') if co.aid ~= 0 then pg:lpush('
    ') if not co.srv:post_liked_uid(co.who.id, p.id) - then pg:lpush('') - else pg:lpush('') + then pg:lpush('') + else pg:lpush('') end - pg:lpush('') + pg:lpush('') if p.author == co.who.id then - pg:lpush('editdelete') + pg:lpush('editdelete') end -- TODO list user's chosen reaction emoji pg:lpush('
    ') end Index: render/tweet.t ================================================================== --- render/tweet.t +++ render/tweet.t @@ -65,20 +65,23 @@ if p.likes > 0 then s:lpush('
    '):ipush(p.likes):lpush('
    ') end s:lpush('') tpl.stats = s:finalize() end - var attrbuf: int8[32] + var attrbuf: int8[48] + var attrcur = &attrbuf[0] if p.accent ~= -1 and p.accent ~= co.ui_hue then var hdecbuf: int8[21] var hdec = lib.math.decstr(p.accent, &hdecbuf[20]) - lib.str.cpy(&attrbuf[0], ' style="--hue:') - lib.str.cpy(&attrbuf[14], hdec) - var len = &hdecbuf[20] - hdec - lib.str.cpy(&attrbuf[14] + len, '"') - tpl.attr = &attrbuf[0] + attrcur = lib.str.cpy(attrcur,' style="--hue:') + attrcur = lib.str.cpy(attrcur, hdec) + -- var len = &hdecbuf[20] - hdec + attrcur = lib.str.cpy(attrcur, '"') end + if p.author == co.who.id then attrcur = lib.str.cpy(attrcur, ' data-own') end + + if attrcur ~= &attrbuf[0] then tpl.attr = &attrbuf[0] end defer tpl.permalink:free() if acc ~= nil then if retweeter ~= nil then push_promo_header(co, acc, retweeter, p.rtact) end tpl:append(acc) Index: srv.t ================================================================== --- srv.t +++ srv.t @@ -2,17 +2,19 @@ local util = lib.util local secmode = lib.enum { 'public', 'private', 'lockdown', 'isolate' } local pstring = lib.mem.ptr(int8) local struct srv local struct cfgcache { - secret: lib.mem.ptr(int8) + secret: pstring pol_sec: secmode.t pol_reg: bool credmgd: bool maxupsz: intptr - instance: lib.mem.ptr(int8) + instance: pstring overlord: &srv + ui_cue_staff: pstring + ui_cue_founder: pstring ui_hue: uint16 nranks: uint16 maxinvites: uint16 master: uint64 } @@ -25,10 +27,12 @@ } terra cfgcache:free() -- :/ self.secret:free() self.instance:free() + self.ui_cue_staff:free() + self.ui_cue_founder:free() end terra srv:post_enum_author_uid(uid: uint64, r: lib.store.range): lib.mem.vec(lib.mem.ptr(lib.store.post)) var all: lib.mem.vec(lib.mem.ptr(lib.store.post)) all:init(64) for i=0,self.sources.ct do var src = self.sources.ptr + i @@ -814,13 +818,16 @@ else self.master = wma(0).id wma:free() end end + + self.ui_cue_staff = self.overlord:conf_get('ui-profile-cue-staff') + self.ui_cue_founder = self.overlord:conf_get('ui-profile-cue-master') end return { overlord = srv; convo = convo; route = route; secmode = secmode; } Index: static/live.js ================================================================== --- static/live.js +++ static/live.js @@ -4,34 +4,44 @@ * we attach the appropriate listeners to them. */ window.addEventListener('load', function() { /* social media is less fun when you can't just click on a tweet * to insta-like or -retweet it. this is unfortunately not possible * (except in various hideously shitty ways) without javascript. */ - function mk(elt) { return document.createElement(elt); } - function posturl(post) { - return post.querySelector('.permalink').attributes.getNamedItem('href').value; - } + let mk = elt => document.createElement(elt); + let posturl = post => post.querySelector('.permalink').attributes.getNamedItem('href').value; + let focused = () => document.querySelector('textarea:focus, input:focus, button:focus, select:focus, a[href]:focus') != null; function postReq(url,act,elt) { fetch(new Request(url, { method: 'POST', body: 'act='+act })).then(function(resp) { if (resp.ok && resp.status == 200) { - var i = parseInt(elt.innerHTML) + let i = parseInt(elt.innerHTML) if (isNaN(i)) {i=0} elt.innerHTML = (i+1).toString() + elt.animate({ + transform: ['scale(1.4)', 'scale(1.0)'], + filter: ['brightness(1)','brightness(0)'] + },200) } }) } - /* div-based like and rt aren't very keyboard-friendly. add a replacement */ - if (document.querySelector('body.timeline, body.profile') != null) { - window.addEventListener('keydown', function(event) { + function onkey(elt, fn) { + elt.addEventListener('keydown', function(ev) { if (!window._liveTweetMap) { return; } if (event.isComposing || event.keyCode === 229) { return; } // 🙄 - var cururl = window._liveTweetMap.cur; - var nexturl = null; + return fn(ev); + }) + } + + /* div-based like and rt aren't very keyboard-friendly. add a replacement */ + if (document.querySelector('body.timeline, body.profile, body.post') != null) { + onkey(window, function(event) { + if (focused()) {return;} + let cururl = window._liveTweetMap.cur; + let nexturl = null; if (event.key == 'j') { // down if (cururl == null) { nexturl = window._liveTweetMap.first } else { nexturl = window._liveTweetMap.map.get(cururl).next @@ -41,36 +51,67 @@ nexturl = window._liveTweetMap.last } else { nexturl = window._liveTweetMap.map.get(cururl).prev } } else if (cururl != null) { - var post = window._liveTweetMap.map.get(cururl).me + let post = window._liveTweetMap.map.get(cururl).me if (event.key == 'f') { // fave postReq(cururl, 'like', post.querySelector('.stats>.like')) } else if (event.key == 'r') { // rt postReq(cururl, 'rt', post.querySelector('.stats>.rt')) + } else if (event.key == 'd') { // rt + if (post.attributes.getNamedItem('data-own')) { + window.location = cururl + '/del'; + } } else if (event.key == 'Enter') { // nav window.location = cururl; return; } } if (nexturl != null) { if (cururl != null) { - var cur = window._liveTweetMap.map.get(cururl); + let cur = window._liveTweetMap.map.get(cururl); cur.me.classList.remove('live-selected') } - var next = window._liveTweetMap.map.get(nexturl); + let next = window._liveTweetMap.map.get(nexturl); next.me.classList.add('live-selected') window._liveTweetMap.cur = nexturl } }); } + + /* make ctrl-enter submit poasts. why the fuck does this require jabbascript */ + document.querySelectorAll('form').forEach(form => form.querySelectorAll('textarea').forEach(function(te) { + let submitbtn = form.querySelector('button[name], input[type="submit"][name], input[type="image"][name]'); + onkey(te, function(e) { + if(e.ctrlKey && e.keyCode == 13) { + if(submitbtn == null) { form.submit(); } else { submitbtn.click(); } + // are you kidding me with this shit + return true; + } + }) + })); + + /* allow response to queries via the keyboard */ + let queryform = document.querySelector('body.query form'); + if(queryform != null) { + okbtn = queryform.querySelector('button[name]'); + nobtn = queryform.querySelector('.button.no, button.no'); + onkey(window, function(e) { + if (focused()) {return;} + if (e.keyCode == 13 || e.key == 'y') { + if (okbtn != null) { okbtn.click() } else { queryform.submit() } + } else if (e.key == 'Escape' || e.key == 'n') { + if (nobtn != null) { nobtn.click() } else { window.history.back() } + } + }); + } function attachButtons() { - var last = null; - var newmap = { cur: null, first: null, last: null, map: new Map() } - document.querySelectorAll('body:not(.post) main article.post').forEach(function(post){ + let last = null; + let newmap = { cur: null, first: null, last: null, map: new Map() } + document.querySelectorAll('main article.post').forEach(function(post){ let url = posturl(post); if (last == null) { newmap.first = url; } else { newmap.map.get(last).next = url } newmap.map.set(url, {me: post, prev: last, next: null}) @@ -77,31 +118,31 @@ last = url if (window._liveTweetMap && window._liveTweetMap.cur == url) { post.classList.add('live-selected'); } - var stats = post.querySelector('.stats'); + let stats = post.querySelector('.stats'); if (stats == null) { /* no stats box; create one */ - var n = mk('div'); + let n = mk('div'); n.classList.add('stats'); post.appendChild(n); stats = n } function ensureElt(cls, before) { let s = stats.querySelector('.' + cls); if (s == null) { - var n = mk('div'); + let n = mk('div'); n.classList.add(cls); if (before == null) { stats.appendChild(n) } else { stats.insertBefore(n,stats.querySelector(before)) } return n } else { return s } } - var like = ensureElt('like', null); - var rt = ensureElt('rt','.like'); + let like = ensureElt('like', null); + let rt = ensureElt('rt','.like'); function activate(elt,name) { elt.addEventListener('click', function(e) { postReq(url,name,elt) }); elt.style.setProperty('cursor','pointer'); elt.setAttribute('tabindex','0'); } @@ -110,11 +151,11 @@ }); newmap.last = last if (window._liveTweetMap) { newmap.cur = window._liveTweetMap.cur // TODO handle vanishments } - window._liveTweetMap = newmap + window._liveTweetMap = newmap; } /* update hue-picker background when slider is adjusted */ document.querySelectorAll('.color-picker').forEach(function(box) { let slider = box.querySelector('[data-color-pick]'); @@ -133,15 +174,13 @@ document.querySelectorAll('*[data-live]').forEach(function(container) { let interv = parseFloat(container.attributes.getNamedItem('data-live').nodeValue) * 1000; container._liveLastArrival = 0; /* TODO include initial value in document */ window.setInterval(function() { - var req = new Request(window.location, { + let req = new Request(window.location, { method: 'GET', - headers: { - 'X-Live-Last-Arrival': container._liveLastArrival - } + headers: { 'X-Live-Last-Arrival': container._liveLastArrival } }) fetch(req).then(function(resp) { if (!resp.ok) return; let newest = parseInt(resp.headers.get('X-Live-Newest-Artifact')); @@ -150,16 +189,16 @@ return; } container._liveLastArrival = newest resp.text().then(function(htmlbody) { - var parser = new DOMParser(); - var newdoc = parser.parseFromString(htmlbody,'text/html') + let parser = new DOMParser(); + let newdoc = parser.parseFromString(htmlbody,'text/html') container.innerHTML = newdoc.getElementById(container.id).innerHTML; attachButtons(); }) }) }, interv) }); attachButtons(); }); Index: static/style.scss ================================================================== --- static/style.scss +++ static/style.scss @@ -502,11 +502,11 @@ display: grid; margin: unset; grid-template-columns: 1in 1fr max-content max-content; grid-template-rows: min-content max-content; margin-bottom: 0.1in; - transition: 0.3s; + transition: 0.2s ease-out; >.avatar { grid-column: 1/2; grid-row: 1/2; img { display: block; width: 1in; height: 1in; margin:0; } background: linear-gradient(to bottom, tone(-53%), tone(-57%)); } @@ -545,11 +545,10 @@ > .like, > .rt { margin: 0.5em 0.3em; padding-left: 1.3em; background-size: 1.1em; background-repeat: no-repeat; - pointer-events: all; min-width: 0.3em; &:empty { transition: 0.3s; opacity: 0.0001; // qutebrowser won't show hints if opacity=0 :( &:hover, &:focus { opacity: 0.6 !important; } @@ -559,13 +558,13 @@ > .rt { background-image: url(/s/retweet.webp); } } // used for keyboard navigation &.live-selected { - margin-left: 0.4in; - margin-right: -0.4in; + //margin-left: 0.4in; margin-right: -0.4in; box-shadow: 0 0 0 1px tone(15%), 0 0 1in tone(5%, -0.5); + transform: scale(1.05) translateX(0.1in); } } article.post:hover div.stats { > .like, > .rt { &:empty {opacity: 0.3;} } } Index: view/confirm.tpl ================================================================== --- view/confirm.tpl +++ view/confirm.tpl @@ -1,9 +1,9 @@

    @title

    @query

    - cancel + cancel
    Index: view/docskel.tpl ================================================================== --- view/docskel.tpl +++ view/docskel.tpl @@ -7,14 +7,14 @@

    @title

    @body