parsav  Check-in [b6c2a79945]

Overview
Comment:more jabbascript improvements
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: b6c2a79945ff2c35e061b4eecfd6ec79de53ba399d100e0432bc65ef6ff2b73a
User & Date: lexi on 2021-01-04 20:33:33
Other Links: manifest | tags
Context
2021-01-05
22:10
improve sql, js, docs, other tweaks check-in: 7bd78f9b1c user: lexi tags: trunk
2021-01-04
20:33
more jabbascript improvements check-in: b6c2a79945 user: lexi tags: trunk
15:29
add like + retweets buttons, keyboard nav check-in: b9cf14c14b user: lexi tags: trunk
Changes

Modified parsav.md from [bd4297f1d2] to [7a2c43b008].

     1      1   # parsav
     2      2   
     3         -**parsav** is a lightweight fediverse server
            3  +**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".
     4      4   
     5      5   ## backends
     6      6   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.
     7      7   
     8      8   * postgresql
     9      9   
    10     10   ## dependencies
................................................................................
   130    130   * sqlite3
   131    131   * generic odbc
   132    132   * lua
   133    133   * ldap for auth (and maybe actors?)
   134    134   * cdb (for static content, maybe?)
   135    135   * mariadb/mysql
   136    136   * the various nosql horrors, e.g. redis, mongo, and so on
          137  +
          138  +parsav urgently needs an internationalization framework as well. right now everything is just hardcoded in english. yuck.

Modified render/docpage.t from [97c704e199] to [8016afcf69].

    70     70   	var [pages] = array([allpages])
    71     71   	var started = false
    72     72   	for i=0,[pages.type.N] do
    73     73   		if pages[i].parent == idx+1 and (pages[i].priv:sz() == 0 or 
    74     74   				(ps and pages[i].priv):sz() ~= 0) then
    75     75   			if not started then
    76     76   				started = true
    77         -				list:lpush('<ul>')
           77  +				list:lpush('<ol>')
    78     78   			end
    79         -			list:lpush('<li><a href="/doc/'):rpush(pages[i].name):lpush('">')
           79  +			list:lpush('<li><a accesskey="'):ipush(i):lpush('" href="/doc/'):rpush(pages[i].name):lpush('">')
    80     80   				:rpush(pages[i].title):lpush('</a>')
    81     81   			pushbranches(list, i, ps)
    82     82   			list:lpush('</li>')
    83     83   		end
    84     84   	end
    85         -	if started then list:lpush('</ul>') end
           85  +	if started then list:lpush('</ol>') end
    86     86   end
    87     87   
    88     88   local terra 
    89     89   render_docpage(co: &lib.srv.convo, pg: pref)
    90     90   	var nullprivs: lib.store.powerset nullprivs:clear()
    91     91   	if not pg then -- display index
    92     92   		var list: lib.str.acc list:compose('<ul>')

Modified render/nav.t from [4a737bb7ff] to [c8c1aa5dcb].

     1      1   -- vim: ft=terra
     2      2   local terra 
     3      3   render_nav(co: &lib.srv.convo)
     4      4   	var t: lib.str.acc t:init(64)
     5      5   	if co.who ~= nil or co.srv.cfg.pol_sec == lib.srv.secmode.public then
     6         -		t:lpush(' <a href="/">timeline</a>')
            6  +		t:lpush(' <a accesskey="t" href="/">timeline</a>')
     7      7   	end
     8      8   	if co.who ~= nil then
     9         -		t:lpush(' <a href="/compose">compose</a> <a href="/'):push(co.who.xid,0)
    10         -		t:lpush('">profile</a> <a href="/conf">configure</a> <a href="/doc">docs</a> <a href="/logout">log out</a>')
            9  +		t:lpush(' <a accesskey="c" href="/compose">compose</a> <a accesskey="p" href="/'):push(co.who.xid,0)
           10  +		t:lpush('">profile</a> <a accesskey="o" href="/conf">configure</a> <a accesskey="d" href="/doc">docs</a> <a accesskey="g" href="/logout">log out</a>')
    11     11   	else
    12         -		t:lpush(' <a href="/doc">docs</a> <a href="/login">log in</a>')
           12  +		t:lpush(' <a accesskey="d" href="/doc">docs</a> <a accesskey="g" href="/login">log in</a>')
    13     13   	end
    14     14   	return t:finalize()
    15     15   end
    16     16   return render_nav

Modified render/profile.t from [08f3a58ce1] to [531b5ac1cf].

     5      5   end
     6      6   
     7      7   local terra 
     8      8   render_profile(co: &lib.srv.convo, actor: &lib.store.actor)
     9      9   	var aux: lib.str.acc
    10     10   	var followed = false -- FIXME
    11     11   	if co.aid ~= 0 and co.who.id == actor.id then
    12         -		aux:compose('<a class="button" href="/conf/profile?go=/@',actor.handle,'">alter</a>')
           12  +		aux:compose('<a accesskey="a" class="button" href="/conf/profile?go=/@',actor.handle,'">alter</a>')
    13     13   	elseif co.aid ~= 0 then
    14     14   		if not followed then
    15         -			aux:compose('<button method="post" class="pos" name="act" value="follow">follow</button>')
           15  +			aux:compose('<button accesskey="f" method="post" class="pos" name="act" value="follow">follow</button>')
    16     16   		elseif followed then
    17         -			aux:compose('<button method="post" class="neg" name="act" value="unfollow">unfollow</button>')
           17  +			aux:compose('<button accesskey="f" method="post" class="neg" name="act" value="unfollow">unfollow</button>')
    18     18   		end
    19         -		aux:lpush('<a class="button" href="/'):push(actor.xid,0):lpush('/chat">chat</a>')
           19  +		aux:lpush('<a accesskey="h" class="button" href="/'):push(actor.xid,0):lpush('/chat">chat</a>')
    20     20   		if co.who.rights.powers:affect_users() and co.who:overpowers(actor) then
    21         -			aux:lpush('<a class="button" href="/'):push(actor.xid,0):lpush('/ctl">control</a>')
           21  +			aux:lpush('<a accesskey="n" class="button" href="/'):push(actor.xid,0):lpush('/ctl">control</a>')
    22     22   		end
    23     23   	else
    24         -		aux:compose('<a class="button" href="/', actor.xid, '/follow">remote follow</a>')
           24  +		aux:compose('<a accesskey="f" class="button" href="/', actor.xid, '/follow">remote follow</a>')
    25     25   	end
    26     26   	var auxp = aux:finalize()
    27     27   	var timestr: int8[26] lib.osclock.ctime_r(&actor.knownsince, &timestr[0])
    28     28   
    29     29   	var strfbuf: int8[28*4]
    30     30   	var stats = co.srv:actor_stats(actor.id)
    31     31   		var sn_posts     = cs(lib.math.decstr_friendly(stats.posts, &strfbuf[ [strfbuf.type.N - 1] ]))
    32     32   		var sn_follows   = cs(lib.math.decstr_friendly(stats.follows, sn_posts.ptr - 1))
    33     33   		var sn_followers = cs(lib.math.decstr_friendly(stats.followers, sn_follows.ptr - 1))
    34     34   		var sn_mutuals   = cs(lib.math.decstr_friendly(stats.mutuals, sn_followers.ptr - 1))
    35         -	var bio = lib.str.plit "<em>tall, dark, and mysterious</em>"
           35  +	var bio = lib.str.plit '<em style="opacity:0.6">tall, dark, and mysterious</em>'
    36     36   	if actor.bio ~= nil then
    37     37   		bio = lib.smackdown.html(cs(actor.bio))
    38     38   	end
    39     39   	var fullname = lib.render.nym(actor,0,nil,false) defer fullname:free()
    40     40   	var comments: lib.str.acc comments:init(64)
    41         -	-- this is really more what epithets are for, i think
    42         -	--if actor.rights.rank > 0 then comments:lpush('<li>staff member</li>') end
           41  +
    43     42   	if co.srv.cfg.master == actor.id then
    44         -		comments:lpush('<li style="--co:-70">founder</li>')
           43  +		var foundertxt = lib.str.plit 'founder'
           44  +		if co.srv.cfg.ui_cue_founder:ref() then
           45  +			if co.srv.cfg.ui_cue_founder.ct == 0 -- empty string, suppress field
           46  +				then foundertxt = pstr.null()
           47  +				else foundertxt = co.srv.cfg.ui_cue_founder
           48  +			end
           49  +		end
           50  +
           51  +		if foundertxt:ref() then
           52  +			comments:lpush('<li style="--co:-70">'):ppush(foundertxt):lpush('</li>')
           53  +		end
    45     54   	end
    46     55   	if co.aid ~= 0 and actor.rights.rank ~= 0 then
           56  +		var stafftxt = lib.str.plit 'site staff'
           57  +		if co.srv.cfg.ui_cue_staff:ref() then
           58  +			if co.srv.cfg.ui_cue_staff.ct == 0 -- empty string, suppress field
           59  +				then stafftxt = pstr.null()
           60  +				else stafftxt = co.srv.cfg.ui_cue_staff
           61  +			end
           62  +		end
           63  +
           64  +		-- this is really more what epithets are for, i think
           65  +		if actor.rights.rank > 0 and stafftxt:ref() then
           66  +			comments:lpush('<li>'):ppush(stafftxt):lpush('</li>')
           67  +		end
           68  +
    47     69   		if co.who:outranks(actor) then
    48     70   			comments:lpush('<li style="--co:50">underling</li>')
    49     71   		elseif actor:outranks(co.who) then
    50     72   			comments:lpush('<li style="--co:-50">outranks you</li>')
    51     73   		end
    52     74   	end
    53     75   

Modified render/tweet-page.t from [8729ddd689] to [57c7b287c5].

    27     27   	co: &lib.srv.convo,
    28     28   	path: lib.mem.ptr(pref),
    29     29   	p: &lib.store.post
    30     30   ): {}
    31     31   	var livetime = co.srv:thread_latest_arrival_calc(p.id)
    32     32   
    33     33   	var pg: lib.str.acc pg:init(256)
           34  +	pg:lpush('<div data-live="10">') -- make the OP refresh too
    34     35   	lib.render.tweet(co, p, &pg)
           36  +	pg:lpush('</div>')
    35     37   
    36     38   	if co.aid ~= 0 then
    37     39   		pg:lpush('<form class="action-bar" method="post">')
    38     40   		if not co.srv:post_liked_uid(co.who.id, p.id)
    39         -			then pg:lpush('<button class="pos" name="act" value="like">like</button>')
    40         -			else pg:lpush('<button class="neg" name="act" value="dislike">dislike</button>')
           41  +			then pg:lpush('<button class="pos" name="act" accesskey="l" value="like">like</button>')
           42  +			else pg:lpush('<button class="neg" name="act" accesskey="l" value="dislike">dislike</button>')
    41     43   		end
    42         -		pg:lpush('<button class="pos" name="act" value="rt">retweet</button>')
           44  +		pg:lpush('<button class="pos" name="act" accesskey="r" value="rt">retweet</button>')
    43     45   		if p.author == co.who.id then
    44         -			pg:lpush('<a class="button" href="/post/'):rpush(path(1)):lpush('/edit">edit</a><a class="neg button" href="/post/'):rpush(path(1)):lpush('/del">delete</a>')
           46  +			pg:lpush('<a class="button" accesskey="e" href="/post/'):rpush(path(1)):lpush('/edit">edit</a><a class="neg button" accesskey="d" href="/post/'):rpush(path(1)):lpush('/del">delete</a>')
    45     47   		end
    46     48   		-- TODO list user's chosen reaction emoji
    47     49   		pg:lpush('</form>')
    48     50   
    49     51   	end
    50     52   	pg:lpush('<div id="convo" data-live="10">')
    51     53   	render_tweet_replies(co, &pg, p.id)

Modified render/tweet.t from [ee058ed0af] to [d7574b82d8].

    63     63   		s:lpush('<div class="stats">')
    64     64   		if p.rts   > 0 then s:lpush('<div class="rt">'  ):ipush(p.rts  ):lpush('</div>') end
    65     65   		if p.likes > 0 then s:lpush('<div class="like">'):ipush(p.likes):lpush('</div>') end
    66     66   		s:lpush('</div>')
    67     67   		tpl.stats = s:finalize()
    68     68   	end
    69     69   
    70         -	var attrbuf: int8[32]
           70  +	var attrbuf: int8[48]
           71  +	var attrcur = &attrbuf[0]
    71     72   	if p.accent ~= -1 and p.accent ~= co.ui_hue then
    72     73   		var hdecbuf: int8[21]
    73     74   		var hdec = lib.math.decstr(p.accent, &hdecbuf[20])
    74         -		lib.str.cpy(&attrbuf[0], ' style="--hue:')
    75         -		lib.str.cpy(&attrbuf[14], hdec)
    76         -		var len = &hdecbuf[20] - hdec 
    77         -		lib.str.cpy(&attrbuf[14] + len, '"')
    78         -		tpl.attr = &attrbuf[0]
           75  +		attrcur = lib.str.cpy(attrcur,' style="--hue:')
           76  +		attrcur = lib.str.cpy(attrcur, hdec)
           77  +		-- var len = &hdecbuf[20] - hdec 
           78  +		attrcur = lib.str.cpy(attrcur, '"')
    79     79   	end
           80  +	if p.author == co.who.id then attrcur = lib.str.cpy(attrcur, ' data-own') end
           81  +
           82  +	if attrcur ~= &attrbuf[0] then tpl.attr = &attrbuf[0] end
    80     83   
    81     84   	defer tpl.permalink:free()
    82     85   	if acc ~= nil then
    83     86   		if retweeter ~= nil then push_promo_header(co, acc, retweeter, p.rtact) end
    84     87   		tpl:append(acc)
    85     88   		if retweeter ~= nil then acc:lpush('</div>') end
    86     89   		if p.rts + p.likes > 0 then tpl.stats:free() end

Modified srv.t from [e0d52a1828] to [d4dcecb4e5].

     1      1   -- vim: ft=terra
     2      2   local util = lib.util
     3      3   local secmode = lib.enum { 'public', 'private', 'lockdown', 'isolate' }
     4      4   local pstring = lib.mem.ptr(int8)
     5      5   local struct srv
     6      6   local struct cfgcache {
     7         -	secret: lib.mem.ptr(int8)
            7  +	secret: pstring
     8      8   	pol_sec: secmode.t
     9      9   	pol_reg: bool
    10     10   	credmgd: bool
    11     11   	maxupsz: intptr
    12         -	instance: lib.mem.ptr(int8)
           12  +	instance: pstring
    13     13   	overlord: &srv
           14  +	ui_cue_staff: pstring
           15  +	ui_cue_founder: pstring
    14     16   	ui_hue: uint16
    15     17   	nranks: uint16
    16     18   	maxinvites: uint16
    17     19   	master: uint64
    18     20   }
    19     21   local struct srv {
    20     22   	sources: lib.mem.ptr(lib.store.source)
................................................................................
    23     25   	cfg: cfgcache
    24     26   	id: rawstring
    25     27   }
    26     28   
    27     29   terra cfgcache:free() -- :/
    28     30   	self.secret:free()
    29     31   	self.instance:free()
           32  +	self.ui_cue_staff:free()
           33  +	self.ui_cue_founder:free()
    30     34   end
    31     35   
    32     36   terra srv:post_enum_author_uid(uid: uint64, r: lib.store.range): lib.mem.vec(lib.mem.ptr(lib.store.post))
    33     37   	var all: lib.mem.vec(lib.mem.ptr(lib.store.post)) all:init(64)
    34     38   	for i=0,self.sources.ct do var src = self.sources.ptr + i
    35     39   		if src.handle ~= nil and src.backend.timeline_instance_fetch ~= nil then
    36     40   			var lst = src:post_enum_author_uid(uid,r)
................................................................................
   812    816   		if not wma then
   813    817   			lib.warn('the webmaster specified in the configuration store does not seem to exist or is not known to this instance; preceding as if no master defined. if the master is a remote user, you can rectify this with the `actor ',{webmaster.ptr,webmaster.ct},' instantiate` and `conf refresh` commands')
   814    818   		else
   815    819   			self.master = wma(0).id
   816    820   			wma:free()
   817    821   		end
   818    822   	end
          823  +
          824  +	self.ui_cue_staff = self.overlord:conf_get('ui-profile-cue-staff')
          825  +	self.ui_cue_founder = self.overlord:conf_get('ui-profile-cue-master')
   819    826   end
   820    827   
   821    828   return {
   822    829   	overlord = srv;
   823    830   	convo = convo;
   824    831   	route = route;
   825    832   	secmode = secmode;
   826    833   }

Modified static/live.js from [9100f46c08] to [f01ba9ae01].

     2      2    * if there are any UI elements unfortunate enough to need
     3      3    * interactivity beyond what native HTML+CSS can provide. if so,
     4      4    * we attach the appropriate listeners to them. */
     5      5   window.addEventListener('load', function() {
     6      6   	/* social media is less fun when you can't just click on a tweet
     7      7   	 * to insta-like or -retweet it. this is unfortunately not possible
     8      8   	 * (except in various hideously shitty ways) without javascript. */
     9         -	function mk(elt) { return document.createElement(elt); }
    10         -	function posturl(post) {
    11         -		return post.querySelector('.permalink').attributes.getNamedItem('href').value;
    12         -	}
            9  +	let mk      =  elt => document.createElement(elt);
           10  +	let posturl = post => post.querySelector('.permalink').attributes.getNamedItem('href').value;
           11  +	let focused =   () => document.querySelector('textarea:focus, input:focus, button:focus, select:focus, a[href]:focus') != null;
    13     12   	function postReq(url,act,elt) {
    14     13   		fetch(new Request(url, {
    15     14   			method: 'POST',
    16     15   			body: 'act='+act
    17     16   		})).then(function(resp) {
    18     17   			if (resp.ok && resp.status == 200) {
    19         -				var i = parseInt(elt.innerHTML)
           18  +				let i = parseInt(elt.innerHTML)
    20     19   				if (isNaN(i)) {i=0}
    21     20   				elt.innerHTML = (i+1).toString()
           21  +				elt.animate({
           22  +					transform: ['scale(1.4)', 'scale(1.0)'],
           23  +					filter: ['brightness(1)','brightness(0)']
           24  +				},200)
    22     25   			}
    23     26   		})
    24     27   	}
    25     28   
    26         -	/* div-based like and rt aren't very keyboard-friendly. add a replacement */
    27         -	if (document.querySelector('body.timeline, body.profile') != null) {
    28         -		window.addEventListener('keydown', function(event) {
           29  +	function onkey(elt, fn) {
           30  +		elt.addEventListener('keydown', function(ev) {
    29     31   			if (!window._liveTweetMap) { return; }
    30     32   			if (event.isComposing || event.keyCode === 229) { return; } // 🙄
    31         -			var cururl = window._liveTweetMap.cur;
    32         -			var nexturl = null;
           33  +			return fn(ev);
           34  +		})
           35  +	}
           36  +
           37  +	/* div-based like and rt aren't very keyboard-friendly. add a replacement */
           38  +	if (document.querySelector('body.timeline, body.profile, body.post') != null) {
           39  +		onkey(window, function(event) {
           40  +			if (focused()) {return;}
           41  +			let cururl = window._liveTweetMap.cur;
           42  +			let nexturl = null;
    33     43   			if (event.key == 'j') { // down
    34     44   				if (cururl == null) {
    35     45   					nexturl = window._liveTweetMap.first
    36     46   				} else {
    37     47   					nexturl = window._liveTweetMap.map.get(cururl).next
    38     48   				}
    39     49   			} else if (event.key == 'k') { // up
    40     50   				if (cururl == null) {
    41     51   					nexturl = window._liveTweetMap.last
    42     52   				} else {
    43     53   					nexturl = window._liveTweetMap.map.get(cururl).prev
    44     54   				}
    45     55   			} else if (cururl != null) {
    46         -				var post = window._liveTweetMap.map.get(cururl).me
           56  +				let post = window._liveTweetMap.map.get(cururl).me
    47     57   				if (event.key == 'f') { // fave
    48     58   					postReq(cururl, 'like', post.querySelector('.stats>.like'))
    49     59   				} else if (event.key == 'r') { // rt
    50     60   					postReq(cururl, 'rt', post.querySelector('.stats>.rt'))
           61  +				} else if (event.key == 'd') { // rt
           62  +					if (post.attributes.getNamedItem('data-own')) {
           63  +						window.location = cururl + '/del';
           64  +					}
    51     65   				} else if (event.key == 'Enter') { // nav
    52     66   					window.location = cururl;
    53     67   					return;
    54     68   				}
    55     69   			}
    56     70   			if (nexturl != null) {
    57     71   				if (cururl != null) {
    58         -					var cur = window._liveTweetMap.map.get(cururl);
           72  +					let cur = window._liveTweetMap.map.get(cururl);
    59     73   					cur.me.classList.remove('live-selected')
    60     74   				}
    61         -				var next = window._liveTweetMap.map.get(nexturl);
           75  +				let next = window._liveTweetMap.map.get(nexturl);
    62     76   				next.me.classList.add('live-selected')
    63     77   				window._liveTweetMap.cur = nexturl
    64     78   			}
    65     79   		});
    66     80   	}
           81  +	
           82  +	/* make ctrl-enter submit poasts. why the fuck does this require jabbascript */
           83  +	document.querySelectorAll('form').forEach(form => form.querySelectorAll('textarea').forEach(function(te) {
           84  +		let submitbtn = form.querySelector('button[name], input[type="submit"][name], input[type="image"][name]');
           85  +		onkey(te, function(e) {
           86  +			if(e.ctrlKey && e.keyCode == 13) {
           87  +				if(submitbtn == null) { form.submit(); } else { submitbtn.click(); }
           88  +				// are you kidding me with this shit
           89  +				return true;
           90  +			}
           91  +		})
           92  +	}));
           93  +
           94  +	/* allow response to queries via the keyboard */
           95  +	let queryform = document.querySelector('body.query form');
           96  +	if(queryform != null) {
           97  +		okbtn = queryform.querySelector('button[name]');
           98  +		nobtn = queryform.querySelector('.button.no, button.no');
           99  +		onkey(window, function(e) {
          100  +			if (focused()) {return;}
          101  +			if (e.keyCode == 13 || e.key == 'y') {
          102  +				if (okbtn != null) { okbtn.click() } else { queryform.submit() }
          103  +			} else if (e.key == 'Escape' || e.key == 'n') {
          104  +				if (nobtn != null) { nobtn.click() } else { window.history.back() }
          105  +			}
          106  +		});
          107  +	}
    67    108   
    68    109   	function attachButtons() {
    69         -		var last = null;
    70         -		var newmap = { cur: null, first: null, last: null, map: new Map() }
    71         -		document.querySelectorAll('body:not(.post) main article.post').forEach(function(post){
          110  +		let last = null;
          111  +		let newmap = { cur: null, first: null, last: null, map: new Map() }
          112  +		document.querySelectorAll('main article.post').forEach(function(post){
    72    113   			let url = posturl(post);
    73    114   			if (last == null) { newmap.first = url; } else {
    74    115   				newmap.map.get(last).next = url
    75    116   			}
    76    117   			newmap.map.set(url, {me: post, prev: last, next: null})
    77    118   			last = url
    78    119   			if (window._liveTweetMap && window._liveTweetMap.cur == url) {
    79    120   				post.classList.add('live-selected');
    80    121   			}
    81    122   
    82         -			var stats = post.querySelector('.stats');
          123  +			let stats = post.querySelector('.stats');
    83    124   			if (stats == null) {
    84    125   				/* no stats box; create one */
    85         -				var n = mk('div');
          126  +				let n = mk('div');
    86    127   				n.classList.add('stats');
    87    128   				post.appendChild(n);
    88    129   				stats = n
    89    130   			}
    90    131   			function ensureElt(cls, before) {
    91    132   				let s = stats.querySelector('.' + cls);
    92    133   				if (s == null) {
    93         -					var n = mk('div');
          134  +					let n = mk('div');
    94    135   					n.classList.add(cls);
    95    136   					if (before == null) { stats.appendChild(n) } else {
    96    137   						stats.insertBefore(n,stats.querySelector(before))
    97    138   					}
    98    139   					return n
    99    140   				} else { return s }
   100    141   			}
   101         -			var like = ensureElt('like', null);
   102         -			var rt   = ensureElt('rt','.like');
          142  +			let like = ensureElt('like', null);
          143  +			let rt   = ensureElt('rt','.like');
   103    144   			function activate(elt,name) {
   104    145   				elt.addEventListener('click', function(e) { postReq(url,name,elt) });
   105    146   				elt.style.setProperty('cursor','pointer');
   106    147   				elt.setAttribute('tabindex','0');
   107    148   			}
   108    149   			activate(like,'like');
   109    150   			activate(rt,'rt');
   110    151   		});
   111    152   		newmap.last = last
   112    153   		if (window._liveTweetMap) {
   113    154   			newmap.cur = window._liveTweetMap.cur // TODO handle vanishments
   114    155   		}
   115         -		window._liveTweetMap = newmap
          156  +		window._liveTweetMap = newmap;
   116    157   	}
   117    158   
   118    159   	/* update hue-picker background when slider is adjusted */
   119    160   	document.querySelectorAll('.color-picker').forEach(function(box) {
   120    161   		let slider = box.querySelector('[data-color-pick]');
   121    162   		box.style.setProperty('--hue', slider.value);
   122    163   		slider.addEventListener('input', function(e) {
................................................................................
   131    172   	 * tree from its html, find the element in question, ferret out
   132    173   	 * any deltas, and apply them. */
   133    174   	document.querySelectorAll('*[data-live]').forEach(function(container) {
   134    175   		let interv = parseFloat(container.attributes.getNamedItem('data-live').nodeValue) * 1000;
   135    176   		container._liveLastArrival = 0; /* TODO include initial value in document */
   136    177   
   137    178   		window.setInterval(function() {
   138         -			var req = new Request(window.location, {
          179  +			let req = new Request(window.location, {
   139    180   				method: 'GET',
   140         -				headers: {
   141         -					'X-Live-Last-Arrival': container._liveLastArrival
   142         -				}
          181  +				headers: { 'X-Live-Last-Arrival': container._liveLastArrival }
   143    182   			})
   144    183   			
   145    184   			fetch(req).then(function(resp) {
   146    185   				if (!resp.ok) return;
   147    186   				let newest = parseInt(resp.headers.get('X-Live-Newest-Artifact'));
   148    187   				if (newest == container._liveLastArrival) { // != also handles some deletions
   149    188   					resp.body.cancel();
   150    189   					return;
   151    190   				}
   152    191   				container._liveLastArrival = newest
   153    192   
   154    193   				resp.text().then(function(htmlbody) {
   155         -					var parser = new DOMParser();
   156         -					var newdoc = parser.parseFromString(htmlbody,'text/html')
          194  +					let parser = new DOMParser();
          195  +					let newdoc = parser.parseFromString(htmlbody,'text/html')
   157    196   					container.innerHTML = newdoc.getElementById(container.id).innerHTML;
   158    197   					attachButtons();
   159    198   				})
   160    199   			})
   161    200   		}, interv)
   162    201   	});
   163    202   
   164    203   	attachButtons();
   165    204   });

Modified static/style.scss from [ea5ab728f6] to [d34b63e467].

   500    500   article.post {
   501    501   	@extend %box;
   502    502   	display: grid;
   503    503   	margin: unset;
   504    504   	grid-template-columns: 1in 1fr max-content max-content;
   505    505   	grid-template-rows: min-content max-content;
   506    506   	margin-bottom: 0.1in;
   507         -	transition: 0.3s;
          507  +	transition: 0.2s ease-out;
   508    508   	>.avatar {
   509    509   		grid-column: 1/2; grid-row: 1/2;
   510    510   		img { display: block; width: 1in; height: 1in; margin:0; }
   511    511   		background: linear-gradient(to bottom, tone(-53%), tone(-57%));
   512    512   	}
   513    513   	>a[href].username {
   514    514   		display: block;
................................................................................
   543    543   		grid-column: 3/4; grid-row: 2/3;
   544    544   		justify-content: center;
   545    545   		> .like, > .rt {
   546    546   			margin: 0.5em 0.3em;
   547    547   			padding-left: 1.3em;
   548    548   			background-size: 1.1em;
   549    549   			background-repeat: no-repeat;
   550         -			pointer-events: all;
   551    550   			min-width: 0.3em;
   552    551   			&:empty {
   553    552   				transition: 0.3s;
   554    553   				opacity: 0.0001; // qutebrowser won't show hints if opacity=0 :(
   555    554   				&:hover, &:focus { opacity: 0.6 !important; }
   556    555   			}
   557    556   		}
   558    557   		> .like { background-image: url(/s/heart.webp); }
   559    558   		> .rt   { background-image: url(/s/retweet.webp); }
   560    559   	}
   561    560   
   562    561   	// used for keyboard navigation
   563    562   	&.live-selected {
   564         -		margin-left: 0.4in;
   565         -		margin-right: -0.4in;
          563  +		//margin-left: 0.4in; margin-right: -0.4in;
   566    564   		box-shadow: 0 0 0 1px tone(15%), 0 0 1in tone(5%, -0.5);
          565  +		transform: scale(1.05) translateX(0.1in);
   567    566   	}
   568    567   }
   569    568   
   570    569   article.post:hover div.stats { > .like, > .rt { &:empty {opacity: 0.3;} } }
   571    570   
   572    571   a[href].rawlink {
   573    572   	@extend %teletype;

Modified view/confirm.tpl from [0d2952df9c] to [8b0d6acfba].

     1      1   <form class="message" method="post">
     2      2   	<img class="icon" src="/s/query.webp">
     3      3   	<h1>@title</h1>
     4      4   	<p>@query</p>
     5      5   	<menu class="horizontal choice">
     6         -		<a class="button" href="@:cancel">cancel</a>
            6  +		<a class="no button" href="@:cancel">cancel</a>
     7      7   		<button name="act" value="confirm">confirm</button>
     8      8   	</menu>
     9      9   </form>

Modified view/docskel.tpl from [3229efd171] to [e84fb6bf18].

     5      5   		<link rel="stylesheet" type="text/css" href="/s/style.css">
     6      6   		<script type="text/javascript" src="/s/live.js" async></script>
     7      7   	</head>
     8      8   	<body class="@class"@attr>
     9      9   		<header><div>
    10     10   			<h1>@title</h1>
    11     11   			<nav>
    12         -				<a href="/instance">instance</a>
           12  +				<a accesskey="i" href="/instance">instance</a>
    13     13   				@navlinks
    14     14   			</nav>
    15     15   		</div></header>
    16     16   		<main>
    17     17   			@body
    18     18   		</main>
    19     19   	</body>
    20     20   </html>