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
2
3
4
5
6
7
8
9
10
...
130
131
132
133
134
135
136


# parsav

**parsav** is a lightweight fediverse server

## 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

## dependencies
................................................................................
* sqlite3
* generic odbc
* 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




|







 







>
>
1
2
3
4
5
6
7
8
9
10
...
130
131
132
133
134
135
136
137
138
# parsav

**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

## dependencies
................................................................................
* sqlite3
* generic odbc
* 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.

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

70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
	var [pages] = array([allpages])
	var started = false
	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('<ul>')
			end
			list:lpush('<li><a href="/doc/'):rpush(pages[i].name):lpush('">')
				:rpush(pages[i].title):lpush('</a>')
			pushbranches(list, i, ps)
			list:lpush('</li>')
		end
	end
	if started then list:lpush('</ul>') end
end

local terra 
render_docpage(co: &lib.srv.convo, pg: pref)
	var nullprivs: lib.store.powerset nullprivs:clear()
	if not pg then -- display index
		var list: lib.str.acc list:compose('<ul>')







|

|





|







70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
	var [pages] = array([allpages])
	var started = false
	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('<ol>')
			end
			list:lpush('<li><a accesskey="'):ipush(i):lpush('" href="/doc/'):rpush(pages[i].name):lpush('">')
				:rpush(pages[i].title):lpush('</a>')
			pushbranches(list, i, ps)
			list:lpush('</li>')
		end
	end
	if started then list:lpush('</ol>') end
end

local terra 
render_docpage(co: &lib.srv.convo, pg: pref)
	var nullprivs: lib.store.powerset nullprivs:clear()
	if not pg then -- display index
		var list: lib.str.acc list:compose('<ul>')

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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(' <a href="/">timeline</a>')
	end
	if co.who ~= nil then
		t:lpush(' <a href="/compose">compose</a> <a href="/'):push(co.who.xid,0)
		t:lpush('">profile</a> <a href="/conf">configure</a> <a href="/doc">docs</a> <a href="/logout">log out</a>')
	else
		t:lpush(' <a href="/doc">docs</a> <a href="/login">log in</a>')
	end
	return t:finalize()
end
return render_nav





|


|
|

|




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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(' <a accesskey="t" href="/">timeline</a>')
	end
	if co.who ~= nil then
		t:lpush(' <a accesskey="c" href="/compose">compose</a> <a accesskey="p" href="/'):push(co.who.xid,0)
		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>')
	else
		t:lpush(' <a accesskey="d" href="/doc">docs</a> <a accesskey="g" href="/login">log in</a>')
	end
	return t:finalize()
end
return render_nav

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

5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

43









44
45

46













47
48
49
50
51
52
53
end

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('<a class="button" href="/conf/profile?go=/@',actor.handle,'">alter</a>')
	elseif co.aid ~= 0 then
		if not followed then
			aux:compose('<button method="post" class="pos" name="act" value="follow">follow</button>')
		elseif followed then
			aux:compose('<button method="post" class="neg" name="act" value="unfollow">unfollow</button>')
		end
		aux:lpush('<a class="button" href="/'):push(actor.xid,0):lpush('/chat">chat</a>')
		if co.who.rights.powers:affect_users() and co.who:overpowers(actor) then
			aux:lpush('<a class="button" href="/'):push(actor.xid,0):lpush('/ctl">control</a>')
		end
	else
		aux:compose('<a class="button" href="/', actor.xid, '/follow">remote follow</a>')
	end
	var auxp = aux:finalize()
	var timestr: int8[26] lib.osclock.ctime_r(&actor.knownsince, &timestr[0])

	var strfbuf: int8[28*4]
	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 "<em>tall, dark, and mysterious</em>"
	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('<li>staff member</li>') end

	if co.srv.cfg.master == actor.id then









		comments:lpush('<li style="--co:-70">founder</li>')
	end

	if co.aid ~= 0 and actor.rights.rank ~= 0 then













		if co.who:outranks(actor) then
			comments:lpush('<li style="--co:50">underling</li>')
		elseif actor:outranks(co.who) then
			comments:lpush('<li style="--co:-50">outranks you</li>')
		end
	end








|


|

|

|

|


|










|





<
<
>

>
>
>
>
>
>
>
>
>
|
|
>

>
>
>
>
>
>
>
>
>
>
>
>
>







5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40


41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
end

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('<a accesskey="a" class="button" href="/conf/profile?go=/@',actor.handle,'">alter</a>')
	elseif co.aid ~= 0 then
		if not followed then
			aux:compose('<button accesskey="f" method="post" class="pos" name="act" value="follow">follow</button>')
		elseif followed then
			aux:compose('<button accesskey="f" method="post" class="neg" name="act" value="unfollow">unfollow</button>')
		end
		aux:lpush('<a accesskey="h" class="button" href="/'):push(actor.xid,0):lpush('/chat">chat</a>')
		if co.who.rights.powers:affect_users() and co.who:overpowers(actor) then
			aux:lpush('<a accesskey="n" class="button" href="/'):push(actor.xid,0):lpush('/ctl">control</a>')
		end
	else
		aux:compose('<a accesskey="f" class="button" href="/', actor.xid, '/follow">remote follow</a>')
	end
	var auxp = aux:finalize()
	var timestr: int8[26] lib.osclock.ctime_r(&actor.knownsince, &timestr[0])

	var strfbuf: int8[28*4]
	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 '<em style="opacity:0.6">tall, dark, and mysterious</em>'
	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)



	if co.srv.cfg.master == actor.id then
		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('<li style="--co:-70">'):ppush(foundertxt):lpush('</li>')
		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('<li>'):ppush(stafftxt):lpush('</li>')
		end

		if co.who:outranks(actor) then
			comments:lpush('<li style="--co:50">underling</li>')
		elseif actor:outranks(co.who) then
			comments:lpush('<li style="--co:-50">outranks you</li>')
		end
	end

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

27
28
29
30
31
32
33

34

35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
	co: &lib.srv.convo,
	path: lib.mem.ptr(pref),
	p: &lib.store.post
): {}
	var livetime = co.srv:thread_latest_arrival_calc(p.id)

	var pg: lib.str.acc pg:init(256)

	lib.render.tweet(co, p, &pg)


	if co.aid ~= 0 then
		pg:lpush('<form class="action-bar" method="post">')
		if not co.srv:post_liked_uid(co.who.id, p.id)
			then pg:lpush('<button class="pos" name="act" value="like">like</button>')
			else pg:lpush('<button class="neg" name="act" value="dislike">dislike</button>')
		end
		pg:lpush('<button class="pos" name="act" value="rt">retweet</button>')
		if p.author == co.who.id then
			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>')
		end
		-- TODO list user's chosen reaction emoji
		pg:lpush('</form>')

	end
	pg:lpush('<div id="convo" data-live="10">')
	render_tweet_replies(co, &pg, p.id)







>

>




|
|

|

|







27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
	co: &lib.srv.convo,
	path: lib.mem.ptr(pref),
	p: &lib.store.post
): {}
	var livetime = co.srv:thread_latest_arrival_calc(p.id)

	var pg: lib.str.acc pg:init(256)
	pg:lpush('<div data-live="10">') -- make the OP refresh too
	lib.render.tweet(co, p, &pg)
	pg:lpush('</div>')

	if co.aid ~= 0 then
		pg:lpush('<form class="action-bar" method="post">')
		if not co.srv:post_liked_uid(co.who.id, p.id)
			then pg:lpush('<button class="pos" name="act" accesskey="l" value="like">like</button>')
			else pg:lpush('<button class="neg" name="act" accesskey="l" value="dislike">dislike</button>')
		end
		pg:lpush('<button class="pos" name="act" accesskey="r" value="rt">retweet</button>')
		if p.author == co.who.id then
			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>')
		end
		-- TODO list user's chosen reaction emoji
		pg:lpush('</form>')

	end
	pg:lpush('<div id="convo" data-live="10">')
	render_tweet_replies(co, &pg, p.id)

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

63
64
65
66
67
68
69
70

71
72
73
74
75
76
77
78
79



80
81
82
83
84
85
86
		s:lpush('<div class="stats">')
		if p.rts   > 0 then s:lpush('<div class="rt">'  ):ipush(p.rts  ):lpush('</div>') end
		if p.likes > 0 then s:lpush('<div class="like">'):ipush(p.likes):lpush('</div>') end
		s:lpush('</div>')
		tpl.stats = s:finalize()
	end

	var attrbuf: int8[32]

	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]
	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)
		if retweeter ~= nil then acc:lpush('</div>') end
		if p.rts + p.likes > 0 then tpl.stats:free() end







|
>



|
|
|
|
<

>
>
>







63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

79
80
81
82
83
84
85
86
87
88
89
		s:lpush('<div class="stats">')
		if p.rts   > 0 then s:lpush('<div class="rt">'  ):ipush(p.rts  ):lpush('</div>') end
		if p.likes > 0 then s:lpush('<div class="like">'):ipush(p.likes):lpush('</div>') end
		s:lpush('</div>')
		tpl.stats = s:finalize()
	end

	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])
		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)
		if retweeter ~= nil then acc:lpush('</div>') end
		if p.rts + p.likes > 0 then tpl.stats:free() end

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

1
2
3
4
5
6
7
8
9
10
11
12
13


14
15
16
17
18
19
20
..
23
24
25
26
27
28
29


30
31
32
33
34
35
36
...
812
813
814
815
816
817
818



819
820
821
822
823
824
825
826
-- vim: ft=terra
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)
	pol_sec: secmode.t
	pol_reg: bool
	credmgd: bool
	maxupsz: intptr
	instance: lib.mem.ptr(int8)
	overlord: &srv


	ui_hue: uint16
	nranks: uint16
	maxinvites: uint16
	master: uint64
}
local struct srv {
	sources: lib.mem.ptr(lib.store.source)
................................................................................
	cfg: cfgcache
	id: rawstring
}

terra cfgcache:free() -- :/
	self.secret:free()
	self.instance: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
		if src.handle ~= nil and src.backend.timeline_instance_fetch ~= nil then
			var lst = src:post_enum_author_uid(uid,r)
................................................................................
		if not wma then
			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')
		else
			self.master = wma(0).id
			wma:free()
		end
	end



end

return {
	overlord = srv;
	convo = convo;
	route = route;
	secmode = secmode;
}






|




|

>
>







 







>
>







 







>
>
>








1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
..
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
...
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
-- vim: ft=terra
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: pstring
	pol_sec: secmode.t
	pol_reg: bool
	credmgd: bool
	maxupsz: intptr
	instance: pstring
	overlord: &srv
	ui_cue_staff: pstring
	ui_cue_founder: pstring
	ui_hue: uint16
	nranks: uint16
	maxinvites: uint16
	master: uint64
}
local struct srv {
	sources: lib.mem.ptr(lib.store.source)
................................................................................
	cfg: cfgcache
	id: rawstring
}

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
		if src.handle ~= nil and src.backend.timeline_instance_fetch ~= nil then
			var lst = src:post_enum_author_uid(uid,r)
................................................................................
		if not wma then
			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')
		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;
}

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

2
3
4
5
6
7
8
9
10
11
12

13
14
15
16
17
18
19
20
21




22
23
24
25
26
27
28
29
30








31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50




51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67



























68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
...
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
 * if there are any UI elements unfortunate enough to need
 * interactivity beyond what native HTML+CSS can provide. if so,
 * 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;
	}

	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)
				if (isNaN(i)) {i=0}
				elt.innerHTML = (i+1).toString()




			}
		})
	}

	/* 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) {
			if (!window._liveTweetMap) { return; }
			if (event.isComposing || event.keyCode === 229) { return; } // 🙄








			var cururl = window._liveTweetMap.cur;
			var nexturl = null;
			if (event.key == 'j') { // down
				if (cururl == null) {
					nexturl = window._liveTweetMap.first
				} else {
					nexturl = window._liveTweetMap.map.get(cururl).next
				}
			} else if (event.key == 'k') { // up
				if (cururl == null) {
					nexturl = window._liveTweetMap.last
				} else {
					nexturl = window._liveTweetMap.map.get(cururl).prev
				}
			} else if (cururl != null) {
				var 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 == 'Enter') { // nav
					window.location = cururl;
					return;
				}
			}
			if (nexturl != null) {
				if (cururl != null) {
					var cur = window._liveTweetMap.map.get(cururl);
					cur.me.classList.remove('live-selected')
				}
				var next = window._liveTweetMap.map.get(nexturl);
				next.me.classList.add('live-selected')
				window._liveTweetMap.cur = nexturl
			}
		});
	}




























	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 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})
			last = url
			if (window._liveTweetMap && window._liveTweetMap.cur == url) {
				post.classList.add('live-selected');
			}

			var stats = post.querySelector('.stats');
			if (stats == null) {
				/* no stats box; create one */
				var 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');
					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');
			function activate(elt,name) {
				elt.addEventListener('click', function(e) { postReq(url,name,elt) });
				elt.style.setProperty('cursor','pointer');
				elt.setAttribute('tabindex','0');
			}
			activate(like,'like');
			activate(rt,'rt');
		});
		newmap.last = last
		if (window._liveTweetMap) {
			newmap.cur = window._liveTweetMap.cur // TODO handle vanishments
		}
		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]');
		box.style.setProperty('--hue', slider.value);
		slider.addEventListener('input', function(e) {
................................................................................
	 * tree from its html, find the element in question, ferret out
	 * any deltas, and apply them. */
	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, {
				method: 'GET',
				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'));
				if (newest == container._liveLastArrival) { // != also handles some deletions
					resp.body.cancel();
					return;
				}
				container._liveLastArrival = newest

				resp.text().then(function(htmlbody) {
					var parser = new DOMParser();
					var newdoc = parser.parseFromString(htmlbody,'text/html')
					container.innerHTML = newdoc.getElementById(container.id).innerHTML;
					attachButtons();
				})
			})
		}, interv)
	});

	attachButtons();
});







|
<
|
<
>






|


>
>
>
>




|
<
|


>
>
>
>
>
>
>
>
|
|













|




>
>
>
>







|


|





|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

|
|
|










|


|







|







|
|












|







 







|

<
|
<












|
|









2
3
4
5
6
7
8
9

10

11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
...
172
173
174
175
176
177
178
179
180

181

182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
 * if there are any UI elements unfortunate enough to need
 * interactivity beyond what native HTML+CSS can provide. if so,
 * 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. */
	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) {
				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)
			}
		})
	}

	function onkey(elt, fn) {

		elt.addEventListener('keydown', function(ev) {
			if (!window._liveTweetMap) { return; }
			if (event.isComposing || event.keyCode === 229) { return; } // 🙄
			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
				}
			} else if (event.key == 'k') { // up
				if (cururl == null) {
					nexturl = window._liveTweetMap.last
				} else {
					nexturl = window._liveTweetMap.map.get(cururl).prev
				}
			} else if (cururl != null) {
				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) {
					let cur = window._liveTweetMap.map.get(cururl);
					cur.me.classList.remove('live-selected')
				}
				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() {
		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})
			last = url
			if (window._liveTweetMap && window._liveTweetMap.cur == url) {
				post.classList.add('live-selected');
			}

			let stats = post.querySelector('.stats');
			if (stats == null) {
				/* no stats box; create one */
				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) {
					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 }
			}
			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');
			}
			activate(like,'like');
			activate(rt,'rt');
		});
		newmap.last = last
		if (window._liveTweetMap) {
			newmap.cur = window._liveTweetMap.cur // TODO handle vanishments
		}
		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]');
		box.style.setProperty('--hue', slider.value);
		slider.addEventListener('input', function(e) {
................................................................................
	 * tree from its html, find the element in question, ferret out
	 * any deltas, and apply them. */
	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() {
			let req = new Request(window.location, {
				method: 'GET',

				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'));
				if (newest == container._liveLastArrival) { // != also handles some deletions
					resp.body.cancel();
					return;
				}
				container._liveLastArrival = newest

				resp.text().then(function(htmlbody) {
					let parser = new DOMParser();
					let newdoc = parser.parseFromString(htmlbody,'text/html')
					container.innerHTML = newdoc.getElementById(container.id).innerHTML;
					attachButtons();
				})
			})
		}, interv)
	});

	attachButtons();
});

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

500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
...
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
article.post {
	@extend %box;
	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;
	>.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%));
	}
	>a[href].username {
		display: block;
................................................................................
		grid-column: 3/4; grid-row: 2/3;
		justify-content: center;
		> .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; }
			}
		}
		> .like { background-image: url(/s/heart.webp); }
		> .rt   { background-image: url(/s/retweet.webp); }
	}

	// used for keyboard navigation
	&.live-selected {
		margin-left: 0.4in;
		margin-right: -0.4in;
		box-shadow: 0 0 0 1px tone(15%), 0 0 1in tone(5%, -0.5);
	}
}

article.post:hover div.stats { > .like, > .rt { &:empty {opacity: 0.3;} } }

a[href].rawlink {
	@extend %teletype;







|







 







<













|
|
|







500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
...
543
544
545
546
547
548
549

550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
article.post {
	@extend %box;
	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.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%));
	}
	>a[href].username {
		display: block;
................................................................................
		grid-column: 3/4; grid-row: 2/3;
		justify-content: center;
		> .like, > .rt {
			margin: 0.5em 0.3em;
			padding-left: 1.3em;
			background-size: 1.1em;
			background-repeat: no-repeat;

			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; }
			}
		}
		> .like { background-image: url(/s/heart.webp); }
		> .rt   { background-image: url(/s/retweet.webp); }
	}

	// used for keyboard navigation
	&.live-selected {
		//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;} } }

a[href].rawlink {
	@extend %teletype;

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

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





|



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

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

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







|








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