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(' composeprofileconfiguredocslog out')
+ t:lpush(' composeprofileconfiguredocslog out')
else
- t:lpush(' docslog in')
+ t:lpush(' docslog 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('')
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 @@
Index: view/docskel.tpl
==================================================================
--- view/docskel.tpl
+++ view/docskel.tpl
@@ -7,14 +7,14 @@