parsav  Check-in [78b0198f09]

Overview
Comment:add likes, retweets, and iterate on a whole bunch of other shit
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 78b0198f099355ea30a888c8100a75cec3ed910e0e6dbc50f5b711fc065a574d
User & Date: lexi on 2021-01-04 06:44:13
Other Links: manifest | tags
Context
2021-01-04
15:29
add like + retweets buttons, keyboard nav check-in: b9cf14c14b user: lexi tags: trunk
06:44
add likes, retweets, and iterate on a whole bunch of other shit check-in: 78b0198f09 user: lexi tags: trunk
2021-01-02
18:32
iterate on user mgmt UI check-in: f09cd18161 user: lexi tags: trunk
Changes

Modified backend/pgsql.t from [962a3e64e0] to [2e62d4947d].

   213    213   			select a.id, a.nym, a.handle, a.origin, a.bio,
   214    214   			       a.avataruri, a.rank, a.quota, a.key, a.epithet,
   215    215   			       extract(epoch from a.knownsince)::bigint,
   216    216   				   coalesce(a.handle || '@' || s.domain,
   217    217   				            '@' || a.handle) as xid,
   218    218   
   219    219   			       au.restrict,
   220         -						array['post'  ] <@ au.restrict as can_post,
   221         -						array['edit'  ] <@ au.restrict as can_edit,
   222         -						array['acct'  ] <@ au.restrict as can_acct,
   223         -						array['upload'] <@ au.restrict as can_upload,
   224         -						array['censor'] <@ au.restrict as can_censor,
   225         -						array['admin' ] <@ au.restrict as can_admin
          220  +						array['post'    ] <@ au.restrict,
          221  +						array['edit'    ] <@ au.restrict,
          222  +						array['account' ] <@ au.restrict,
          223  +						array['upload'  ] <@ au.restrict,
          224  +						array['moderate'] <@ au.restrict,
          225  +						array['admin'   ] <@ au.restrict
   226    226   
   227    227   			from      parsav_auth au
   228    228   			left join parsav_actors a     on au.uid = a.id
   229    229   			left join parsav_servers s    on a.origin = s.id
   230    230   
   231    231   			where au.aid = $1::bigint and au.blacklist = false and
   232    232   				(au.netmask is null or au.netmask >> $2::inet) and
................................................................................
   349    349   		params = {uint64}, cmd = true, sql = [[
   350    350   			delete from parsav_posts where id = $1::bigint
   351    351   		]]
   352    352   	};
   353    353   	
   354    354   	post_fetch = {
   355    355   		params = {uint64}, sql = [[
          356  +			with counts as (
          357  +				select a.kind, p.id as subject, count(*) as ct from parsav_acts as a
          358  +					inner join parsav_posts as p on p.id = a.subject
          359  +				group by a.kind, p.id
          360  +			)
          361  +
   356    362   			select a.origin is null,
   357    363   				p.id, p.author, p.subject, p.acl, p.body,
   358    364   				extract(epoch from p.posted    )::bigint,
   359    365   				extract(epoch from p.discovered)::bigint,
   360    366   				extract(epoch from p.edited    )::bigint,
   361    367   				p.parent, p.convoheaduri, p.chgcount,
   362         -				coalesce(c.value, -1)::smallint
          368  +				coalesce(c.value, -1)::smallint, 0::bigint, 0::bigint,
          369  +				coalesce((select ct from counts where kind = 'like' and counts.subject = p.id),0)::integer,
          370  +				coalesce((select ct from counts where kind = 'rt' and counts.subject = p.id),0)::integer
   363    371   
   364    372   			from parsav_posts as p
   365    373   				inner join parsav_actors          as a on p.author = a.id
   366    374   				left join  parsav_actor_conf_ints as c on c.uid    = a.id and c.key = 'ui-accent'
   367    375   			where p.id = $1::bigint
   368    376   		]];
   369    377   	};
   370    378   
   371    379   	post_enum_parent = {
   372    380   		params = {uint64}, sql = [[
          381  +			with counts as (
          382  +				select a.kind, p.id as subject, count(*) as ct from parsav_acts as a
          383  +					inner join parsav_posts as p on p.id = a.subject
          384  +				group by a.kind, p.id
          385  +			)
          386  +
   373    387   			select a.origin is null,
   374    388   				p.id, p.author, p.subject, p.acl, p.body,
   375    389   				extract(epoch from p.posted    )::bigint,
   376    390   				extract(epoch from p.discovered)::bigint,
   377    391   				extract(epoch from p.edited    )::bigint,
   378    392   				p.parent, p.convoheaduri, p.chgcount,
   379         -				coalesce(c.value, -1)::smallint
          393  +				coalesce(c.value, -1)::smallint, 0::bigint, 0::bigint,
          394  +				coalesce((select ct from counts where kind = 'like' and counts.subject = p.id),0)::integer,
          395  +				coalesce((select ct from counts where kind = 'rt' and counts.subject = p.id),0)::integer
   380    396   
   381    397   			from parsav_posts as p
   382    398   				inner join parsav_actors as a on a.id = p.author
   383    399   				left join  parsav_actor_conf_ints as c on c.uid = a.id and c.key = 'ui-accent'
   384    400   			where p.parent = $1::bigint
   385    401   			order by p.posted, p.discovered asc
   386    402   		]]
................................................................................
   401    417   					inner join parsav_posts as p
   402    418   						on p.id = posts.id
   403    419   			)
   404    420   
   405    421   			select extract(epoch from max(m))::bigint from maxes
   406    422   		]];
   407    423   	};
          424  +
          425  +	post_react_simple = {
          426  +		params = {uint64, uint64, pstring}, sql = [[
          427  +			insert into parsav_acts (kind,actor,subject) values (
          428  +				$3::text, $1::bigint, $2::bigint
          429  +			) returning id
          430  +		]];
          431  +	};
          432  +
          433  +	post_react_cancel = {
          434  +		params = {uint64, uint64, pstring}, cmd = true, sql = [[
          435  +			delete from parsav_acts where
          436  +				actor = $1::bigint and
          437  +				subject = $2::bigint and
          438  +				kind = $3::text
          439  +		]];
          440  +	};
          441  +
          442  +	post_reacts_fetch_uid = {
          443  +		params = {uint64, uint64, pstring}, sql = [[
          444  +			select id, actor, subject, kind, body, time from parsav_acts where
          445  +				($1::bigint = 0 or actor   = $1::bigint) and
          446  +				($2::bigint = 0 or subject = $2::bigint) and
          447  +				($3::text is null or kind  = $3::text  )
          448  +		]]
          449  +	};
   408    450   
   409    451   	post_enum_author_uid = {
   410    452   		params = {uint64,uint64,uint64,uint64, uint64}, sql = [[
          453  +			with ownposts as (
          454  +				select *, 0::bigint as rtid from parsav_posts as p
          455  +				where p.author = $5::bigint and
          456  +					($1::bigint = 0 or p.posted <= to_timestamp($1::bigint)) and
          457  +					($2::bigint = 0 or to_timestamp($2::bigint) < p.posted)
          458  +			),
          459  +
          460  +			retweets as (
          461  +				select p.*, a.id as rtid from parsav_acts as a
          462  +					inner join parsav_posts as p on a.subject = p.id
          463  +				where a.actor = $5::bigint and
          464  +					  a.kind = 'rt' and
          465  +					  ($1::bigint = 0 or a.time <= to_timestamp($1::bigint)) and
          466  +					  ($2::bigint = 0 or to_timestamp($2::bigint) < a.time)
          467  +			),
          468  +
          469  +			allposts as (select *, 0::bigint  as retweeter from ownposts
          470  +			      union  select *, $5::bigint as retweeter from retweets),
          471  +
          472  +			counts as (
          473  +				select a.kind, p.id as subject, count(*) as ct from parsav_acts as a
          474  +					inner join parsav_posts as p on p.id = a.subject
          475  +				group by a.kind, p.id
          476  +			)
          477  +
   411    478   			select a.origin is null,
   412    479   				p.id, p.author, p.subject, p.acl, p.body,
   413    480   				extract(epoch from p.posted    )::bigint,
   414    481   				extract(epoch from p.discovered)::bigint,
   415    482   				extract(epoch from p.edited    )::bigint,
   416    483   				p.parent, p.convoheaduri, p.chgcount,
   417         -				coalesce((select value from parsav_actor_conf_ints as c where
   418         -					c.uid = $1::bigint and c.key = 'ui-accent'),-1)::smallint
   419         -
   420         -			from parsav_posts as p
          484  +				coalesce(c.value,-1)::smallint,
          485  +				p.retweeter, p.rtid,
          486  +				coalesce((select ct from counts where kind = 'like' and counts.subject = p.id),0)::integer,
          487  +				coalesce((select ct from counts where kind = 'rt' and counts.subject = p.id),0)::integer
          488  +			from allposts as p
   421    489   				inner join parsav_actors as a on p.author = a.id
   422         -			where p.author = $5::bigint and
   423         -				($1::bigint = 0 or p.posted <= to_timestamp($1::bigint)) and
   424         -				($2::bigint = 0 or to_timestamp($2::bigint) < p.posted)
          490  +				left  join parsav_actor_conf_ints as c
          491  +					on c.key = 'ui-accent' and
          492  +					   c.uid = a.id
   425    493   			order by (p.posted, p.discovered) desc
   426    494   			limit case when $3::bigint = 0 then null
   427         -			           else $3::bigint end
          495  +					   else $3::bigint end
   428    496   			offset $4::bigint
   429    497   		]]
   430    498   	};
   431    499   
   432    500   	-- maybe there's some way to unify these two, idk, im tired
   433    501   
   434    502   	timeline_instance_fetch = {
   435    503   		params = {uint64, uint64, uint64, uint64}, sql = [[
   436         -			select true,
   437         -				p.id, p.author, p.subject, p.acl, p.body,
   438         -				extract(epoch from p.posted    )::bigint,
   439         -				extract(epoch from p.discovered)::bigint,
   440         -				extract(epoch from p.edited    )::bigint,
   441         -				p.parent, null::text, p.chgcount,
   442         -				coalesce(c.value, -1)::smallint
   443         -
   444         -			from parsav_posts as p
   445         -				inner join parsav_actors          as a on p.author = a.id
   446         -				left join  parsav_actor_conf_ints as c on c.uid    = a.id and c.key = 'ui-accent'
   447         -			where
   448         -				($1::bigint = 0 or p.posted <= to_timestamp($1::bigint)) and
   449         -				($2::bigint = 0 or to_timestamp($2::bigint) < p.posted) and
   450         -				(a.origin is null)
   451         -			order by (p.posted, p.discovered) desc
   452         -			limit case when $3::bigint = 0 then null
   453         -			           else $3::bigint end
   454         -			offset $4::bigint
          504  +			with posts as (
          505  +				select true,
          506  +					p.id, p.author, p.subject, p.acl, p.body,
          507  +					extract(epoch from p.posted    )::bigint,
          508  +					extract(epoch from p.discovered)::bigint,
          509  +					extract(epoch from p.edited    )::bigint,
          510  +					p.parent, null::text, p.chgcount,
          511  +					coalesce(c.value, -1)::smallint, 0::bigint, 0::bigint
          512  +
          513  +				from parsav_posts as p
          514  +					inner join parsav_actors          as a on p.author = a.id
          515  +					left join  parsav_actor_conf_ints as c on c.uid    = a.id and c.key = 'ui-accent'
          516  +				where
          517  +					($1::bigint = 0 or p.posted <= to_timestamp($1::bigint)) and
          518  +					($2::bigint = 0 or to_timestamp($2::bigint) < p.posted) and
          519  +					(a.origin is null)
          520  +				order by (p.posted, p.discovered) desc
          521  +				limit case when $3::bigint = 0 then null
          522  +						   else $3::bigint end
          523  +				offset $4::bigint
          524  +			), counts as (
          525  +				select a.kind, p.id as subject, count(*) as ct from parsav_acts as a
          526  +					inner join parsav_posts as p on p.id = a.subject
          527  +				group by a.kind, p.id
          528  +			)
          529  +
          530  +			select *,
          531  +				coalesce((select ct from counts as c where kind = 'like' and c.subject = posts.id),0)::integer,
          532  +				coalesce((select ct from counts as c where kind = 'rt' and c.subject = posts.id),0)::integer
          533  +			from posts
   455    534   		]]
   456    535   	};
   457    536   
   458    537   	artifact_instantiate = {
   459    538   		params = {binblob, binblob, pstring}, sql = [[
   460    539   			insert into parsav_artifacts (content,hash,mime) values (
   461    540   				$1::bytea, $2::bytea, $3::text
................................................................................
   792    871   	end
   793    872   	p.ptr.parent = r:int(uint64,row,9)
   794    873   	if r:null(row,11)
   795    874   		then p.ptr.chgcount = 0
   796    875   		else p.ptr.chgcount = r:int(uint32,row,11)
   797    876   	end 
   798    877   	p.ptr.accent = r:int(int16,row,12)
          878  +	p.ptr.rtdby = r:int(uint64,row,13)
          879  +	p.ptr.rtact = r:int(uint64,row,14)
          880  +	p.ptr.likes = r:int(uint32,row,15)
          881  +	p.ptr.rts = r:int(uint32,row,16)
   799    882   	p.ptr.localpost = r:bool(row,0)
   800    883   
   801    884   	return p
   802    885   end
   803    886   local terra row_to_actor(r: &pqr, row: intptr): lib.mem.ptr(lib.store.actor)
   804    887   	var a: lib.mem.ptr(lib.store.actor)
   805    888   	var av: rawstring, avlen: intptr
................................................................................
  1161   1244   			a.ptr.source = src
  1162   1245   
  1163   1246   			var au = [lib.stat(lib.store.auth)] { ok = true }
  1164   1247   			au.val.aid = aid
  1165   1248   			au.val.uid = a.ptr.id
  1166   1249   			if not r:null(0,14) then -- restricted?
  1167   1250   				au.val.privs:clear()
  1168         -				(au.val.privs.post   << r:bool(0,15)) 
  1169         -				(au.val.privs.edit   << r:bool(0,16))
  1170         -				(au.val.privs.acct   << r:bool(0,17))
  1171         -				(au.val.privs.upload << r:bool(0,18))
  1172         -				(au.val.privs.censor << r:bool(0,19))
  1173         -				(au.val.privs.admin  << r:bool(0,20))
         1251  +				(au.val.privs.post    << r:bool(0,15)) 
         1252  +				(au.val.privs.edit    << r:bool(0,16))
         1253  +				(au.val.privs.account << r:bool(0,17))
         1254  +				(au.val.privs.upload  << r:bool(0,18))
         1255  +				(au.val.privs.moderate<< r:bool(0,19))
         1256  +				(au.val.privs.admin   << r:bool(0,20))
  1174   1257   			else au.val.privs:fill() end
  1175   1258   
  1176   1259   			return au, a
  1177   1260   		end
  1178   1261   
  1179   1262   		::fail:: return [lib.stat   (lib.store.auth) ] { ok = false        },
  1180   1263   			            [lib.mem.ptr(lib.store.actor)] { ptr = nil, ct = 0 }
................................................................................
  1211   1294   	): lib.mem.ptr(lib.store.post)
  1212   1295   		var r = queries.post_fetch.exec(src, post)
  1213   1296   		if r.sz == 0 then return [lib.mem.ptr(lib.store.post)].null() end
  1214   1297   		var p = row_to_post(&r, 0)
  1215   1298   		p.ptr.source = src
  1216   1299   		return p
  1217   1300   	end];
         1301  +
         1302  +	post_retweet = [terra(
         1303  +		src: &lib.store.source,
         1304  +		uid: uint64,
         1305  +		post: uint64,
         1306  +		undo: bool
         1307  +	): {}
         1308  +		if not undo then
         1309  +			queries.post_react_simple.exec(src,uid,post,"rt")
         1310  +		else
         1311  +			queries.post_react_cancel.exec(src,uid,post,"rt")
         1312  +		end
         1313  +	end];
         1314  +	post_like = [terra(
         1315  +		src: &lib.store.source,
         1316  +		uid: uint64,
         1317  +		post: uint64,
         1318  +		undo: bool
         1319  +	): {}
         1320  +		if not undo then
         1321  +			queries.post_react_simple.exec(src,uid,post,"like")
         1322  +		else
         1323  +			queries.post_react_cancel.exec(src,uid,post,"like")
         1324  +		end
         1325  +	end];
         1326  +	post_liked_uid = [terra(
         1327  +		src: &lib.store.source,
         1328  +		uid: uint64,
         1329  +		post: uint64
         1330  +	): bool
         1331  +		var q = queries.post_reacts_fetch_uid.exec(src,uid,post,'like')
         1332  +		if q.sz > 0 then q:free() return true end
         1333  +		return false
         1334  +	end];
  1218   1335   
  1219   1336   	timeline_instance_fetch = [terra(src: &lib.store.source, rg: lib.store.range)
  1220   1337   		var r = pqr { sz = 0 }
  1221   1338   		var A,B,C,D = rg:matrix() -- :/
  1222   1339   		r = queries.timeline_instance_fetch.exec(src,A,B,C,D)
  1223   1340   		
  1224   1341   		var ret: lib.mem.ptr(lib.mem.ptr(lib.store.post)) ret:init(r.sz)

Modified backend/schema/pgsql.sql from [72a3e65e6e] to [347a4ab533].

    82     82   	kind    smallint, -- e.g. follow, block, mute
    83     83   
    84     84   	primary key (relator, relatee, kind)
    85     85   );
    86     86   
    87     87   create table parsav_acts (
    88     88   	id      bigint primary key default (1+random()*(2^63-1))::bigint,
    89         -	kind    text not null, -- like, react, so on
           89  +	kind    text not null, -- like, rt, react, so on
    90     90   	time    timestamp not null default now(),
    91     91   	actor   bigint references parsav_actors(id)
    92     92   		on delete cascade,
    93         -	subject bigint -- may be post or act, depending on kind
           93  +	subject bigint, -- may be post or act, depending on kind
           94  +	body	text -- emoji, if react
    94     95   );
    95     96   
    96     97   create table parsav_log (
    97     98   	-- accesses are tracked for security & sending delete acts
    98     99   	id    bigint primary key default (1+random()*(2^63-1))::bigint,
    99    100   	time  timestamp not null default now(),
   100    101   	actor bigint references parsav_actors(id)

Modified config.lua from [5a4f5a8d5b] to [8efc0b984f].

    53     53   		-- the damn things before compiling (also making the binary smaller)
    54     54   		{'style.css', 'text/css'};
    55     55   		{'live.js', 'text/javascript'}; -- rrrrrrrr
    56     56   		{'default-avatar.webp', 'image/webp'}; -- needs inkscape-exclusive svg features
    57     57   		{'padlock.svg', 'image/svg+xml'};
    58     58   		{'warn.svg', 'image/svg+xml'};
    59     59   		{'query.webp', 'image/webp'};
           60  +		{'heart.webp', 'image/webp'};
           61  +		{'retweet.webp', 'image/webp'};
    60     62   	};
    61     63   	default_ui_accent = tonumber(default('parsav_ui_default_accent',323));
    62     64   }
    63     65   if os.getenv('parsav_let_me_be_an_idiot') == "i know what i'm doing" then
    64     66   	conf.braingeniousmode = true -- SOUND GENERAL QUARTERS
    65     67   end
    66     68   if u.ping '.fslckout' or u.ping '_FOSSIL_' then

Modified doc/load.lua from [68f6a9a498] to [783a358256].

     1      1   local path = ...
     2      2   local sources = {
     3      3   -- user section
     4      4   	acl = {title = 'access control lists'};
     5      5   -- admin section
     6      6   	--overview = {title = 'server overview', priv = 'config'};
     7      7   	invocation = {title = 'daemon invocation', priv = 'config'};
            8  +	usr = {title = 'user accounting', priv = {'elevate','demote','purge','herald'}};
            9  +	--srvcfg = {title = 'server configuration policies', priv = 'config'};
           10  +	--discipline = {title = 'disciplinary measures', priv = 'discipline'};
     8     11   	--backends = {title = 'storage backends', priv = 'config'};
     9     12   		--pgsql = {title = 'pgsql', priv = 'config', parent = 'backends'};
    10     13   }
    11     14   
    12     15   local util = dofile 'common.lua'
    13     16   local ingest = function(filename)
    14     17   	return (util.exec { 'cmark', '--smart', '--unsafe', (path..'/'..filename) }):gsub('\n','')

Added doc/usr.md version [61c1a08ae0].

            1  +# user accounting
            2  +parsav comes with sophisticated user management tools. you can manage users either from the command line (if you have shell and database access on the host), or using the web UI. both methods will be described in depth here.
            3  +
            4  +## core concepts
            5  +in parsav, users are a subset of *actors,* entities that post content on the fediverse. users are the actors that a given parsav instance publishes. some aspects of parsav administration apply to all actors, meaning that remote actors as well as local users are subject to them; others apply only only to users. in the database, users are distinguished only from other actors in that they are marked as belonging to the local instance.
            6  +
            7  +every actor has several properties, most of which are fairly standard social media concepts. the first of these is the handle, the username that uniquely identifies a user on an instance and on the fediverse. due to technical limitations in the design of activitypub, handles cannot be changed, and are indelibly associated with a specific account. (as parsav is also intended to have a non-federating mode, and perhaps a mode to federate only with other parsav instances, it should be possible for non-federating instances to allow handle changes at some point.) the second is the nym, or "display name" in twitter parlance, a string that the user can set and change at will to identify himself, and which is displayed before the handle on posts. and of course, a user can give herself a *bio,* a block of markdown-formatted text that is displayed on her profile. neither the display name nor the bio can be changed by administrators.
            8  +
            9  +actors can also have an *epithet,* a secondary title which is displayed in emphasized fashion after the handle and nym. how you use epithets is entirely up to you; you might use them to indicate specific staff roles ("founder", "admin", "moderator" or so on), or to attach humorous labels to well-known users as a mark of respect (or disrespect). the key thing about the epithet is that it can be set only by administrators (specifically, users with the `herald` power), so bearing an epithet indicates some kind of status recognized by the operator of the instance. (at some point it may also be made possible to change the color of a user's epithet as well, but for now they all display alike.) epithets can also be assigned to members of a chatroom by chatroom staff, but these are scoped to the chatroom and do not display outside of it (nor can they be modified by instance administrators). epithets can be set from the command line with the `parsav actor <xid> bestow <epithet>` command.
           10  +
           11  +finally, every actor can be assigned a *rank.* this determines their level of authority on the server. when users are given powers, they can only exercise these powers over actors without rank or with lower ranks than their own; for instance, users of rank 2 can affect actors of rank 3 and 4, but not rank 1. rank 1 is special, being the highest possible rank, in that rank 1 users can affect other rank 1 users — this exception is to prevent the situation where a root user forgets their password and nobody else can reset it. however, the best practice is still to reserve rank 1 for the server owner and use lower ranks for all other users. it is important to note that all actors, not just local users, can be given ranks; while remote users cannot exercise power locally, they can be exempted from the power of lower-ranking administrators. rank can be set with the `parsav actor <xid> rank <number>` command; `degrade` will remove an actor's rank
           12  +
           13  +local users have additional properties, a set of *powers* that governs their ability to use the instance. some of these powers relate only to normal use (logging in, posting, editing one's posts, and so on), and are given to all users by default. others grant power over other users, such as `elevate`, `demote`, and `discipline` (see "powers" for a list). administrative powers can be exercised only over users of lower rank; users without rank cannot make any use of most administrative powers (though `herald` can be granted to allow a user to change his own epithet). local users also have a "quota" (defaulting to 1000) that governs how many posts they can publish each day. if you run a restricted instance that requires invitations to join, users are also assigned a certain number of those invitations, and once they run out this count must be increased by an administrator before they can invite more users.
           14  +
           15  +## creating administrative accounts
           16  +when you first install parsav and initialize its database, there will be no user accounts (unless you're using postgresql and unmanaged authentication) and user self-registration will not be allowed. in order to begin using your new instance, you will need to create yourself a user with which to administrate it. in order to do this, you will need to use the `parsav` utility you used to initialize the database in the first place. (note that depending on your configuration, you may need to run `parsav` as the same user the `parsavd` process runs as for it to be able to connect to the database.)
           17  +
           18  +initial account creation is handled with the `parsav mkroot <handle>` command, where `<handle>` is the handle of your new user. issuing this command will create a new account with the given name, grant all powers to that account, assign it to rank 1, and generate a password you can use to log in. you can run this command multiple times and create multiple root users if you want, but note that these users have absolute power over the instance, including other root users! in most cases, there should be a single root account beloning to the instance owner, with lower-ranking accounts given out to moderators and other staff. (see "core concepts")
           19  +
           20  +`mkroot` is purely a convenience function, and is almost identical to the effect of the commands `user <handle> create`, `actor <xid> rank 1`, `user <handle> grant all`, `user <handle> auth pw new`, and `conf set master <handle>` issued in sequence. the only difference is that `mkroot` also `bestow`s a silly title on the new account.
           21  +
           22  +## creating user accounts
           23  +the command `parsav user` is used to manage local user accounts, and you can create a new standard user with the command `parsav user <handle> create`. for example, a user named `@eve` could be created with `parsav user eve create`.  this user will have no rank, default rights, default settings, and will not be able to log in.
           24  +
           25  +you can enable the user to log in by creating a credential for them. for instance, to issue `@eve` a password, you could use the command `parsav user eve auth pw new` or `pw reset`. (the difference between the two is that `reset` deletes existing credentials of that type, whereas `new` creates a new credential without disabling any others). this will generate a temporary password that `@eve` can use to log in.
           26  +
           27  +you can also create new users through the HTTP interface. log in to your administrative account, navigate to the configuration screen, and click "users" in the menu. (see "emergency recovery" if you don't have this option in your menu.) unfold the "new user" interface, and enter a handle for the new account; it will be created and you will be taken to a page where you can set its properties and create authentication tokens. note that administrators cannot edit other users' display names or bios; these are exclusively the prerogative of the user herself.
           28  +
           29  +nota bene: if you want to give an account to another user, creating an invitation link is generally the best way of doing it, rather than manually adding a new account.
           30  +
           31  +## powers
           32  +the abilities a user can exercise on a server are governed by their *powers,* a set of flags administrators can set on their accounts.
           33  +
           34  +these powers are intended for ordinary users, and default to on:
           35  +
           36  + * **login:** the user can log in to the instance. revoking this power is equivalent to banning the user.
           37  + * **visible:** the user and his posts can be seen by other users without navigating directly to his profile page
           38  + * **post:** the user can publish new posts
           39  + * **shout:** the user is visible on the local timeline
           40  + * **propagate:** the user's posts federate
           41  + * **artifact:** the user can upload artifacts and attach them to posts. users without this power can still add artifacts uploaded by others to their account, but cannot upload their own.
           42  + * **account:** the user can configure their own account and profile
           43  + * **edit:** the user can edit her own posts
           44  + * **snitch:** the user can submit reports asking for moderator action
           45  +
           46  +these powers are intended for staff, and default to off:
           47  +
           48  + * **herald:** the user can change the epithets of lower-ranking actors, grant them badges, or revoke their badges. note that badges can also be restricted such that only heralds of a certain rank can grant or revoke them.
           49  + * **crier:** the user can promote content to the instance page (and thus the archives). note that by default only one post can be promoted per crier per day, though this can be changed (see [server configuration](srvcfg)).
           50  + * **elevate:** the user can increase the rank of lower-ranking actors up to one rank below his own, and can grant powers that he already possesses.
           51  + * **demote:** the user can decrease the rank of lower-ranking actors or strip them of rank entirely, and can revoke powers that she too possesses.
           52  + * **censor:** the user can eliminate undesirable content, remove posts from the instance page, and respond to badthink reports, whether by dismissing the report, by suppressing (but not deleting) the post in question, or by referring the matter upwards to someone with the discipline power. on smaller instances, moderators should probably hold this power and the discipline power simultaneously; on larger ones, it may be best to separate the two.
           53  + * **discipline:** the user can place *sanctions* on lower-ranking actors and cancel pending invites. sanctions are (usually temporary) [punishments](discipline) that strip certain abilities (or suspend certain conversations), and are intended as a less extreme, more flexible means of dealing with toxic behavior. most moderators should possess this power rather than `elevate` or `demote`, as sanctions leave a paper trail and can be summarily vacated by users of equal or higher rank with the `vacate` power. `discipline` also grants various other disciplinary abilities, such as issuing *demerits,* which can result in various penalties
           54  + * **vacate:** the user can rehabilitate disciplined actors, vacating sanctions, voiding demerits, and issuing temporary reprieves from restrictions.
           55  + * **purge:** the user can completely destroy lower-ranking accounts and all associated content, removing them entirely from the instance. best to keep this one for yourself.
           56  + * **invite:** the user can issue invites without depleting their invite supply, even if they have none at all. users with both the `invite` and `elevate` powers can grant invites to others.
           57  + * **cred:** the user can add, change, and remove the credentials of lower-ranking users (think password resets).
           58  + * **config:** grants access to technical and security-related server settings, like `bind` or `domain`. be aware that changes made by users with this power affect *all* users, regardless of rank, and may affect how certain other powers function.
           59  + * **rebrand:** grants access to server settings that affect the appearance and livery of the site, like the `ui-accent` setting, the instance name, or the content of the instance page.
           60  +
           61  +powers can be granted and revoked through the online interface, in the `users` section. they can also be controlled using the command line tool, with the commands `parsav user <handle> grant <power>…` and `revoke <power>…` (`all` can be used instead of a list of powers to grant or strip all powers simultaneously)
           62  +
           63  +### recommendations
           64  +on smaller servers, it is highly recommended that the `config`, `rebrand`, `purge`, `elevate`, and `demote` powers all rest with a single user. other administrators and moderators should be given `censor`, `discipline`, `vacate`, and possibly `invite` and `herald` depending on your intentions for the site. you should be the only rank-1 user, and other staff should be given rank 2. rank 3 might be useful to limit the damage new staff can do during a "probation period." `herald` and `crier` are useful powers to combine, as they create a "moderator" with powers related mostly to promotion of users and their work.
           65  +
           66  +on larger servers, it may be necessary to have more levels of administrative abstraction, or even to increase the maximum number of ranks from its default of 10. in this case, certain exceptional powers such as `rebrand` and `purge` should still remain exclusively with the founder, but it may be necessary to (carefully!) apportion out access to powers like `elevate` and `demote`. it may also be desirable to have a broader class of less-trusted moderators who can take minimally destructive measures on their own (say, `censor` and `herald`) to filter through the bulk of reports, with a smaller corps of highly trusted commissars who have powers like `discipline` and `vacate` to handle the small number of reports that censors believe deserve their attention.
           67  +
           68  +in both cases, it's very, very important to keep in mind that 99% of community management is social. parsav tries to provide you with effective tools for when use of force becomes unavoidable, but most of the time a good community leader can accomplish his goals with words alone (remember, IRC has none of this fancy shit, and they manage just fine most of the time!). apart from those relatively rare cases where you are faced with true bad-faith actors (in which cases immediate brutality is the only solution), a community can be handled effectively with just with judicious use of symbolic measures like rank, badges, and epithets. a gentle indication that a high-status user disapproves of her conduct is often all it takes to convince a lower-status user who truly cares about her community to shape up. all the power in the world won't give you a drop of authority, and if you're new to running communities, you may be surprised how much authority you can endow other members with without giving them anything besides maybe a fancy title (though even that is just a convenience) so long as the people in your community like, trust, and respect you.
           69  +
           70  +and if your users don't respect you, you might as well pack up right now.
           71  +
           72  +## emergencies
           73  +shit happens. sometimes this shit results in getting locked out of your own instance. if so, don't panic quite yet. as long as you can get shell access to the host to run the `parsav` utility, you can resolve the situation. (note that `parsavd` does not need to be running to use commands that control the database, and for some backends such as sqlite `parsavd` may need to be shut down first.)
           74  +
           75  +### locked out
           76  +if you are locked out of your administrator account, the fix is simple, as long as you can modify the underlying database: the `parsav` utility does not use instance credentials, but rather directly modifies the DB and sends IPC signals through the kernel. if you're locked out because you've forgotten your password or all your credentials have been deleted somehow, just issue yourself a new temporary password like you would for any other user, with the `parsav user <handle> auth pw reset` command. 
           77  +
           78  +### missing privileges
           79  +if you've been stripped of the `login` privilege by a bug or a rogue admin, you can restore it with `parsav user <handle> grant login`, and it may be worthwhile to issue a `revoke demote` to keep that rogue admin from immediately locking you out again. keep in mind that this won't affect sanctions that have been issued against your account; see below for these.
           80  +
           81  +### sanctions
           82  +users with the `discipline` privilege cannot change user powers outright, but can issue sanctions that temporarily limit these powers in various ways, for instance preventing a user from posting for a few hours until they've cooled down. users with `discipline` can only affect users of lower rank unless they're rank 1, in which case they can affect all users. if you've fallen afoul of one of these users and need to get your instance back, you'll need to vacate all the sanctions against your account. this can be done with the `parsav actor <xid> sanction all vacate` command. alternately, you can list individual sanctions with `sanction`, and then delete them individually with `sanction <sid> vacate`.
           83  +
           84  +### lost account
           85  +if your account has been completely deleted, rather than just suspended, things are decidedly more serious. everything associated with your account — posts, media, circles, relationships, all of it — is gone, irreversibly, unless you have a database backup around somewhere. (the `purge` power is so named because it is *serious business,* to be treated as the equivalent of a concealed carry permit — you should give it out to other users only out of specific justified need in exceptional circumstances, and revoke it proactively when it is no longer absolutely necessary, rather than as punishment for misuse. hopefully you have now learned this lesson.)

Modified makefile from [eedbd28993] to [5c5158676d].

     1      1   dl = git
     2      2   dbg-flags = $(if $(dbg),-g)
     3      3   
     4         -images = static/default-avatar.webp static/query.webp
            4  +images = static/default-avatar.webp static/query.webp static/heart.webp static/retweet.webp
     5      5   #$(addsuffix .webp, $(basename $(wildcard static/*.svg)))
     6      6   styles = $(addsuffix .css, $(basename $(wildcard static/*.scss)))
     7      7   
     8      8   parsav parsavd: parsav.t config.lua pkgdata.lua $(images) $(styles)
     9      9   	terra $(dbg-flags) $<
    10     10   parsav.o parsavd.o: parsav.t config.lua pkgdata.lua $(images) $(styles)
    11     11   	env parsav_link=no terra $(dbg-flags) $<

Modified mgtool.t from [ee3dfa15a8] to [4f69a41277].

   385    385   					root.handle = cfmode.arglist(0)
   386    386   					var epithets = array(
   387    387   						'root', 'god', 'regional jehovah', 'titan king',
   388    388   						'king of olympus', 'cyberpharaoh', 'electric ellimist',
   389    389   						"rampaging c'tan", 'deathless tweetlord', 'postmaster',
   390    390   						'faerie queene', 'lord of the posts', 'ruthless cybercrat',
   391    391   						'general secretary', 'commissar', 'kwisatz haderach',
   392         -						'dedicated hyperturing'
          392  +						'dedicated hyperturing', 'grand inquisitor', 'reverend mother',
          393  +						'cyberpope', 'verified®', 'patent pending'
   393    394   						-- feel free to add more
   394    395   					)
   395    396   					root.epithet = epithets[lib.crypt.random(intptr,0,[epithets.type.N])]
   396    397   					root.rights.powers:fill() -- grant omnipotence
   397    398   					root.rights.rank = 1
   398    399   					var ruid = dlg:actor_create(&root)
   399    400   					dlg:conf_set('master',root.handle)

Modified render/conf/users.t from [09fa445b20] to [4bed391611].

    95     95   				then sanitized = pstr {ptr='', ct=0}
    96     96   				else sanitized = lib.html.sanitize(cs(user.ptr.epithet),true)
    97     97   			end
    98     98   			cinp:lpush('<div class="elem"><label for="epithet">epithet</label><input type="text" id="epithet" name="epithet" value="'):ppush(sanitized):lpush('"></div>')
    99     99   			if user.ptr.epithet ~= nil then sanitized:free() end
   100    100   		end
   101    101   		if co.who.rights.powers.invite() or co.who.rights.powers.discipline() then
   102         -			var min = 0
          102  +			var min: uint32 = 0
   103    103   			if not (co.who.rights.powers.discipline() or
   104    104   				co.who.rights.powers.demote() and co.who.rights.powers.invite())
   105    105   					then min = user.ptr.rights.invites end
   106         -			var max = co.srv.cfg.maxinvites
          106  +			var max: uint32 = co.srv.cfg.maxinvites
   107    107   			if not co.who.rights.powers.invite() then max = user.ptr.rights.invites end
   108    108   
   109    109   			push_num_field(cinp, 'invites', 'invites', min, max, user.ptr.rights.invites, false)
   110    110   		end
          111  +		if co.who.rights.powers.elevate() or co.who.rights.powers.demote() then
          112  +			var max: uint32 = 5000
          113  +			if not co.who.rights.powers.elevate() then max = user.ptr.rights.quota end
          114  +			var min: uint32 = 0
          115  +			if not co.who.rights.powers.demote() then min = user.ptr.rights.quota end
          116  +
          117  +			push_num_field(cinp, 'quota', 'quota', min, max, user.ptr.rights.quota, user.ptr.id == co.who.id and co.who.rights.rank ~= 1)
          118  +		end
   111    119   		cinp:lpush('</div><div class="elem"><div class="check-panel">')
   112    120   
   113    121   		if user.ptr.id ~= co.who.id and
   114    122   		   ((user.ptr.rights.rank == 0 and co.who.rights.powers.elevate()) or
   115    123   		    (user.ptr.rights.rank >  0 and co.who.rights.powers.demote())) then
   116    124   			push_checkbox(&cinp, 'staff', pstr.null(), 'site staff member', user.ptr.rights.rank > 0, true, pstr.null())
   117    125   		end
................................................................................
   150    158   		var unym: lib.str.acc unym:init(64)
   151    159   		unym:lpush('<a href="/')
   152    160   		if user(0).origin ~= 0 then unym:lpush('@') end
   153    161   		do var sanxid = lib.html.sanitize(user(0).xid, true)
   154    162   			unym:ppush(sanxid)
   155    163   			sanxid:free() end
   156    164   		unym:lpush('" class="id">')
   157         -		lib.render.nym(user.ptr,0,&unym)
          165  +		lib.render.nym(user.ptr,0,&unym,false)
   158    166   		unym:lpush('</a>')
   159    167   		var pg = data.view.conf_user_ctl {
   160    168   			name = unym:finalize();
   161    169   			inputcontent = cinpp;
   162    170   			linkcontent = clnkp;
   163    171   		}
   164    172   		var ret = pg:tostr()
................................................................................
   202    210   			if usr.rights.rank ~= 0 then
   203    211   				ulst:lpush('<span class="regalia">')
   204    212   				regalia(&ulst, usr.rights.rank)
   205    213   				ulst:lpush('</span>')
   206    214   			end
   207    215   			if co.who:overpowers(usr) then
   208    216   				ulst:lpush('<a class="id" href="users/'):push(&idbuf[0],idlen):lpush('">')
   209         -				lib.render.nym(usr, 0, &ulst)
          217  +				lib.render.nym(usr, 0, &ulst, false)
   210    218   				ulst:lpush('</a></li>')
   211    219   			else
   212    220   				ulst:lpush('<span class="id">')
   213         -				lib.render.nym(usr, 0, &ulst)
          221  +				lib.render.nym(usr, 0, &ulst, false)
   214    222   				ulst:lpush('</span></li>')
   215    223   			end
   216    224   		::skip::end
   217    225   		ulst:lpush('</ul>')
   218    226   		return ulst:finalize()
   219    227   	end
   220    228   	do return pstr.null() end

Modified render/docpage.t from [148acf7303] to [97c704e199].

    67     67   
    68     68   local terra 
    69     69   pushbranches(list: &lib.str.acc, idx: intptr, ps: lib.store.powerset): {}
    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         -				(ps and pages[i].priv) == pages[i].priv) then
           74  +				(ps and pages[i].priv):sz() ~= 0) then
    75     75   			if not started then
    76     76   				started = true
    77     77   				list:lpush('<ul>')
    78     78   			end
    79     79   			list:lpush('<li><a href="/doc/'):rpush(pages[i].name):lpush('">')
    80     80   				:rpush(pages[i].title):lpush('</a>')
    81     81   			pushbranches(list, i, ps)

Modified render/nym.t from [74775ce158] to [ea921b8ffe].

     1      1   -- vim: ft=terra
     2      2   local pstr = lib.str.t
     3      3   local terra cs(s: rawstring)
     4      4   	return pstr { ptr = s, ct = lib.str.sz(s) }
     5      5   end
     6      6   
     7      7   local terra 
     8         -render_nym(who: &lib.store.actor, scope: uint64, tgt: &lib.str.acc)
            8  +render_nym(who: &lib.store.actor, scope: uint64, tgt: &lib.str.acc, minimal: bool)
     9      9   	var acc: lib.str.acc
    10     10   	var n: &lib.str.acc
    11     11   	if tgt ~= nil then n = tgt else
    12     12   		n = &acc
    13     13   		n:init(128)
    14     14   	end
    15     15   	var xidsan = lib.html.sanitize(cs(who.xid),false)
................................................................................
    18     18   		n:lpush('<span class="nym">'):ppush(nymsan)
    19     19   			:lpush('</span> [<span class="handle">'):ppush(xidsan)
    20     20   			:lpush('</span>]')
    21     21   		nymsan:free()
    22     22   	else n:lpush('<span class="handle">'):ppush(xidsan):lpush('</span>') end
    23     23   	xidsan:free()
    24     24   
    25         -	if who.epithet ~= nil then
    26         -		var episan = lib.html.sanitize(cs(who.epithet),false)
    27         -		n:lpush('<span class="epithet">'):ppush(episan):lpush('</span>')
    28         -		episan:free()
           25  +	if not minimal then
           26  +		if who.epithet ~= nil then
           27  +			var episan = lib.html.sanitize(cs(who.epithet),false)
           28  +			n:lpush('<span class="epithet">'):ppush(episan):lpush('</span>')
           29  +			episan:free()
           30  +		end
    29     31   	end
    30     32   	
    31     33   	-- TODO: if scope == chat room then lookup titles in room member db
    32     34   	if tgt == nil then
    33     35   		return n:finalize()
    34     36   	else return pstr.null() end
    35     37   end
    36     38   
    37     39   return render_nym

Modified render/profile.t from [5d5ed1c86e] to [08f3a58ce1].

    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     35   	var bio = lib.str.plit "<em>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         -	var fullname = lib.render.nym(actor,0,nil) defer fullname:free()
           39  +	var fullname = lib.render.nym(actor,0,nil,false) defer fullname:free()
    40     40   	var comments: lib.str.acc comments:init(64)
    41     41   	-- this is really more what epithets are for, i think
    42     42   	--if actor.rights.rank > 0 then comments:lpush('<li>staff member</li>') end
           43  +	if co.srv.cfg.master == actor.id then
           44  +		comments:lpush('<li style="--co:-70">founder</li>')
           45  +	end
    43     46   	if co.aid ~= 0 and actor.rights.rank ~= 0 then
    44     47   		if co.who:outranks(actor) then
    45     48   			comments:lpush('<li style="--co:50">underling</li>')
    46     49   		elseif actor:outranks(co.who) then
    47     50   			comments:lpush('<li style="--co:-50">outranks you</li>')
    48     51   		end
    49     52   	end

Modified render/tweet-page.t from [005ba03599] to [8729ddd689].

    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     34   	lib.render.tweet(co, p, &pg)
    35     35   
    36     36   	if co.aid ~= 0 then
    37     37   		pg:lpush('<form class="action-bar" method="post">')
    38         -		var liked = false -- FIXME
    39         -		var rtd = false
    40         -		if not liked
           38  +		if not co.srv:post_liked_uid(co.who.id, p.id)
    41     39   			then pg:lpush('<button class="pos" name="act" value="like">like</button>')
    42     40   			else pg:lpush('<button class="neg" name="act" value="dislike">dislike</button>')
    43     41   		end
    44         -		if not rtd
    45         -			then pg:lpush('<button class="pos" name="act" value="rt">retweet</button>')
    46         -			else pg:lpush('<button class="neg" name="act" value="unrt">detweet</button>')
    47         -		end
           42  +		pg:lpush('<button class="pos" name="act" value="rt">retweet</button>')
    48     43   		if p.author == co.who.id then
    49     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>')
    50     45   		end
    51     46   		-- TODO list user's chosen reaction emoji
    52     47   		pg:lpush('</form>')
    53     48   
    54     49   	end

Modified render/tweet.t from [43aca48007] to [ee058ed0af].

     1      1   -- vim: ft=terra
     2      2   local pstr = lib.mem.ptr(int8)
     3      3   local terra cs(s: rawstring)
     4      4   	return pstr { ptr = s, ct = lib.str.sz(s) }
     5      5   end
     6      6   
            7  +local terra 
            8  +push_promo_header(co: &lib.srv.convo, acc: &lib.str.acc, rter: &lib.store.actor, rid: uint64)
            9  +	acc:lpush('<div class="lede"><div class="promo"><img src="'):push(rter.avatar,0):lpush('"><a href="/')
           10  +	if rter.origin ~= 0 then acc:lpush('@') end
           11  +	acc:push(rter.xid,0):lpush('" class="username">')
           12  +	lib.render.nym(rter, 0, acc, true)
           13  +	acc:lpush('</a> retweeted</div>')
           14  +	if co.who.id == rter.id then
           15  +		acc:lpush('<a href="/post/'):shpush(rid):lpush('/del" class="del">✖</a>')
           16  +	end
           17  +end
           18  +			
     7     19   local terra 
     8     20   render_tweet(co: &lib.srv.convo, p: &lib.store.post, acc: &lib.str.acc)
     9         -	var author: &lib.store.actor
           21  +	var author: &lib.store.actor = nil
           22  +	var retweeter: &lib.store.actor = nil
    10     23   	for j = 0, co.actorcache.top do
    11         -		if p.author == co.actorcache(j).ptr.id then
    12         -			author = co.actorcache(j).ptr
           24  +		if p.author == co.actorcache(j).ptr.id then author    = co.actorcache(j).ptr end
           25  +		if p.rtdby  == co.actorcache(j).ptr.id then retweeter = co.actorcache(j).ptr end
           26  +		if author ~= nil and (p.rtdby == 0 or retweeter ~= nil) then
    13     27   			goto foundauth
    14     28   		end
    15     29   	end
    16         -	author = co.actorcache:insert(co.srv:actor_fetch_uid(p.author)).ptr
           30  +	if author == nil then
           31  +		author = co.actorcache:insert(co.srv:actor_fetch_uid(p.author)).ptr
           32  +	end
           33  +	if p.rtdby ~= 0 and retweeter == nil then
           34  +		retweeter = co.actorcache:insert(co.srv:actor_fetch_uid(p.rtdby)).ptr
           35  +	end
    17     36   
    18     37   	::foundauth::
    19     38   	var avistr: lib.str.acc if author.origin == 0 then
    20     39   		avistr:compose('/avi/',author.handle)
    21     40   	end
    22     41   	var timestr: int8[26] lib.osclock.ctime_r(&p.posted, &timestr[0])
    23     42   
    24         -	var bhtml = lib.smackdown.html([lib.mem.ptr(int8)] {ptr=p.body,ct=0}) defer bhtml:free()
           43  +	var bhtml = lib.smackdown.html([lib.mem.ptr(int8)] {ptr=p.body,ct=0})
           44  +	defer bhtml:free()
    25     45   
    26     46   	var idbuf: int8[lib.math.shorthand.maxlen]
    27     47   	var idlen = lib.math.shorthand.gen(p.id, idbuf)
    28     48   	var permalink: lib.str.acc permalink:compose('/post/',{idbuf,idlen})
    29         -	var fullname = lib.render.nym(author,0,nil) defer fullname:free()
           49  +	var fullname = lib.render.nym(author,0,nil, false) defer fullname:free()
    30     50   	var tpl = data.view.tweet {
    31     51   		text = bhtml;
    32     52   		subject = cs(lib.coalesce(p.subject,''));
    33     53   		nym = fullname;
    34     54   		when = cs(&timestr[0]);
    35     55   		avatar = cs(author.avatar);
    36     56   		acctlink = cs(author.xid);
    37     57   		permalink = permalink:finalize();
    38         -		attr = ''
           58  +		attr = pstr{'',0};
           59  +		stats = pstr{'',0};
    39     60   	}
           61  +	if p.rts + p.likes > 0 then
           62  +		var s: lib.str.acc s:init(128)
           63  +		s:lpush('<div class="stats">')
           64  +		if p.rts   > 0 then s:lpush('<div class="rt">'  ):ipush(p.rts  ):lpush('</div>') end
           65  +		if p.likes > 0 then s:lpush('<div class="like">'):ipush(p.likes):lpush('</div>') end
           66  +		s:lpush('</div>')
           67  +		tpl.stats = s:finalize()
           68  +	end
    40     69   
    41     70   	var attrbuf: int8[32]
    42     71   	if p.accent ~= -1 and p.accent ~= co.ui_hue then
    43     72   		var hdecbuf: int8[21]
    44     73   		var hdec = lib.math.decstr(p.accent, &hdecbuf[20])
    45     74   		lib.str.cpy(&attrbuf[0], ' style="--hue:')
    46     75   		lib.str.cpy(&attrbuf[14], hdec)
    47     76   		var len = &hdecbuf[20] - hdec 
    48     77   		lib.str.cpy(&attrbuf[14] + len, '"')
    49     78   		tpl.attr = &attrbuf[0]
    50     79   	end
    51     80   
    52     81   	defer tpl.permalink:free()
    53         -	if acc ~= nil then tpl:append(acc) return [lib.mem.ptr(int8)]{ptr=nil,ct=0} end
    54         -	var txt = tpl:tostr()
    55         -	return txt
           82  +	if acc ~= nil then
           83  +		if retweeter ~= nil then push_promo_header(co, acc, retweeter, p.rtact) end
           84  +		tpl:append(acc)
           85  +		if retweeter ~= nil then acc:lpush('</div>') end
           86  +		if p.rts + p.likes > 0 then tpl.stats:free() end
           87  +		return [lib.mem.ptr(int8)]{ptr=nil,ct=0}
           88  +	end
           89  +
           90  +	if retweeter ~= nil then
           91  +		var rta: lib.str.acc rta:init(512)
           92  +		push_promo_header(co, &rta, retweeter, p.rtact)
           93  +		tpl:append(&rta) rta:lpush('</div>')
           94  +		return rta:finalize()
           95  +	else
           96  +		var txt = tpl:tostr()
           97  +		if p.rts + p.likes > 0 then tpl.stats:free() end
           98  +		return txt
           99  +	end
    56    100   end
    57    101   return render_tweet

Modified route.t from [2f7668c3df] to [666eb021ed].

   222    222   					return
   223    223   				else goto badop end
   224    224   			end
   225    225   		else goto badurl end
   226    226   	end
   227    227   
   228    228   	if meth == method.post then
   229         -		var replytext = co:ppostv('post')
   230         -		var acl = co:ppostv('acl')
   231         -		var subj = co:ppostv('subject')
   232         -		if not acl then acl = lib.str.plit 'all' end
   233         -		if not replytext then goto badop end
   234         -		
   235         -		var reply = lib.store.post {
   236         -			author = co.who.id, parent = pid;
   237         -			subject = subj.ptr, acl = acl.ptr, body = replytext.ptr;
   238         -		}
          229  +		var act = co:ppostv('act')
          230  +		if act:cmp(lib.str.plit 'like') and not co.srv:post_liked_uid(co.who.id,pid) then
          231  +			co.srv:post_like(co.who.id, pid, false)
          232  +			post.ptr.likes = post.ptr.likes + 1
          233  +		elseif act:cmp(lib.str.plit 'dislike') and co.srv:post_liked_uid(co.who.id,pid) then
          234  +			co.srv:post_like(co.who.id, pid, true)
          235  +			post.ptr.likes = post.ptr.likes - 1
          236  +		elseif act:cmp(lib.str.plit 'rt') then
          237  +			co.srv:post_retweet(co.who.id, pid, false)
          238  +			post.ptr.rts = post.ptr.rts + 1
          239  +		elseif act:cmp(lib.str.plit 'post') then
          240  +			var replytext = co:ppostv('post')
          241  +			var acl = co:ppostv('acl')
          242  +			var subj = co:ppostv('subject')
          243  +			if not acl then acl = lib.str.plit 'all' end
          244  +			if not replytext then goto badop end
          245  +			
          246  +			var reply = lib.store.post {
          247  +				author = co.who.id, parent = pid;
          248  +				subject = subj.ptr, acl = acl.ptr, body = replytext.ptr;
          249  +			}
   239    250   
   240         -		reply:publish(co.srv)
          251  +			reply:publish(co.srv)
          252  +		else goto badop end
   241    253   	end
   242    254   
   243    255   	lib.render.tweet_page(co, path, post.ptr)
   244    256   	do return end
   245    257   
   246    258   	::badurl:: do co:complain(404, 'invalid URL', 'this URL does not reference extant content or functionality') return end
   247    259   	::badop :: do co:complain(405, 'invalid operation', 'the operation you have attempted on this post is not meaningful') return end
................................................................................
   257    269   			path(1):cmp(lib.str.lit 'emoji')
   258    270   		) then goto nopriv
   259    271   
   260    272   		elseif not co.who.rights.powers.rebrand() and (
   261    273   			path(1):cmp(lib.str.lit 'brand')
   262    274   		) then goto nopriv
   263    275   
   264         -		elseif not co.who.rights.powers.acct() and (
          276  +		elseif not co.who.rights.powers.account() and (
   265    277   			path(1):cmp(lib.str.lit 'profile') or
   266    278   			path(1):cmp(lib.str.lit 'acct')
   267    279   		) then goto nopriv
   268    280   
   269    281   		elseif not co.who.rights.powers:affect_users() and (
   270    282   			path(1):cmp(lib.str.lit 'users')
   271    283   		) then goto nopriv end

Modified session.t from [78b2aad470] to [c79e9ffb10].

     2      2   -- sessions are implemented so as to avoid any local data storage. they
     3      3   -- are tracked by storing an encrypted cookie which contains an authid,
     4      4   -- a login epoch time, and a truncated hmac code authenticating both, all
     5      5   -- encoded using Shorthand. we need functions to generate and parse these
     6      6   
     7      7   local m = {
     8      8   	maxlen = lib.math.shorthand.maxlen*3 + 2;
     9         -	maxage = 2 * 60 * 60; -- 2 hours
            9  +	maxage = 16 * 60 * 60; -- 16 hours
    10     10   	cookiename = 'auth';
    11     11   }
    12     12   
    13     13   terra m.cookie_gen(secret: lib.mem.ptr(int8), authid: uint64, time: uint64, out: &int8): intptr
    14     14   	var ptr = out
    15     15   	ptr = ptr + lib.math.shorthand.gen(authid, ptr)
    16     16   	@ptr = @'.' ptr = ptr + 1

Modified srv.t from [34fad9fa1a] to [e0d52a1828].

    10     10   	credmgd: bool
    11     11   	maxupsz: intptr
    12     12   	instance: lib.mem.ptr(int8)
    13     13   	overlord: &srv
    14     14   	ui_hue: uint16
    15     15   	nranks: uint16
    16     16   	maxinvites: uint16
           17  +	master: uint64
    17     18   }
    18     19   local struct srv {
    19     20   	sources: lib.mem.ptr(lib.store.source)
    20     21   	webmgr: lib.net.mg_mgr
    21     22   	webcon: &lib.net.mg_connection
    22     23   	cfg: cfgcache
    23     24   	id: rawstring
................................................................................
   800    801   		end
   801    802   		smode:free()
   802    803   	end
   803    804   
   804    805   	self.ui_hue = self:cfint('ui-accent',config.default_ui_accent)
   805    806   	self.nranks = self:cfint('user-ranks',10)
   806    807   	self.maxinvites = self:cfint('max-invites',64)
          808  +	
          809  +	var webmaster = self.overlord:conf_get('master')
          810  +	if webmaster:ref() then defer webmaster:free()
          811  +		var wma = self.overlord:actor_fetch_xid(webmaster)
          812  +		if not wma then
          813  +			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  +		else
          815  +			self.master = wma(0).id
          816  +			wma:free()
          817  +		end
          818  +	end
   807    819   end
   808    820   
   809    821   return {
   810    822   	overlord = srv;
   811    823   	convo = convo;
   812    824   	route = route;
   813    825   	secmode = secmode;
   814    826   }

Added static/heart.svg version [c2edd21438].

            1  +<?xml version="1.0" encoding="UTF-8" standalone="no"?>
            2  +<!-- Created with Inkscape (http://www.inkscape.org/) -->
            3  +
            4  +<svg
            5  +   xmlns:dc="http://purl.org/dc/elements/1.1/"
            6  +   xmlns:cc="http://creativecommons.org/ns#"
            7  +   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
            8  +   xmlns:svg="http://www.w3.org/2000/svg"
            9  +   xmlns="http://www.w3.org/2000/svg"
           10  +   xmlns:xlink="http://www.w3.org/1999/xlink"
           11  +   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
           12  +   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
           13  +   width="20"
           14  +   height="20"
           15  +   viewBox="0 0 5.2916668 5.2916668"
           16  +   version="1.1"
           17  +   id="svg8"
           18  +   inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
           19  +   sodipodi:docname="heart.svg"
           20  +   inkscape:export-filename="/home/lexi/dev/parsav/static/heart.png"
           21  +   inkscape:export-xdpi="406.39999"
           22  +   inkscape:export-ydpi="406.39999">
           23  +  <defs
           24  +     id="defs2">
           25  +    <linearGradient
           26  +       id="linearGradient1395"
           27  +       inkscape:collect="always">
           28  +      <stop
           29  +         id="stop1391"
           30  +         offset="0"
           31  +         style="stop-color:#ff1616;stop-opacity:1" />
           32  +      <stop
           33  +         id="stop1393"
           34  +         offset="1"
           35  +         style="stop-color:#ff1d1d;stop-opacity:0" />
           36  +    </linearGradient>
           37  +    <linearGradient
           38  +       inkscape:collect="always"
           39  +       id="linearGradient1383">
           40  +      <stop
           41  +         style="stop-color:#980000;stop-opacity:1;"
           42  +         offset="0"
           43  +         id="stop1379" />
           44  +      <stop
           45  +         style="stop-color:#980000;stop-opacity:0;"
           46  +         offset="1"
           47  +         id="stop1381" />
           48  +    </linearGradient>
           49  +    <linearGradient
           50  +       inkscape:collect="always"
           51  +       id="linearGradient832">
           52  +      <stop
           53  +         style="stop-color:#ffcfcf;stop-opacity:1;"
           54  +         offset="0"
           55  +         id="stop828" />
           56  +      <stop
           57  +         style="stop-color:#ffcfcf;stop-opacity:0;"
           58  +         offset="1"
           59  +         id="stop830" />
           60  +    </linearGradient>
           61  +    <radialGradient
           62  +       inkscape:collect="always"
           63  +       xlink:href="#linearGradient832"
           64  +       id="radialGradient834"
           65  +       cx="3.2286437"
           66  +       cy="286.62921"
           67  +       fx="3.2286437"
           68  +       fy="286.62921"
           69  +       r="1.0866126"
           70  +       gradientTransform="matrix(1.8608797,0.8147617,-0.38242057,0.87343168,106.71446,33.692223)"
           71  +       gradientUnits="userSpaceOnUse" />
           72  +    <filter
           73  +       inkscape:collect="always"
           74  +       style="color-interpolation-filters:sRGB"
           75  +       id="filter1356"
           76  +       x="-0.044539396"
           77  +       width="1.0890788"
           78  +       y="-0.04671235"
           79  +       height="1.0934247">
           80  +      <feGaussianBlur
           81  +         inkscape:collect="always"
           82  +         stdDeviation="0.040330888"
           83  +         id="feGaussianBlur1358" />
           84  +    </filter>
           85  +    <radialGradient
           86  +       inkscape:collect="always"
           87  +       xlink:href="#linearGradient1383"
           88  +       id="radialGradient1385"
           89  +       cx="4.1787109"
           90  +       cy="286.89261"
           91  +       fx="4.1787109"
           92  +       fy="286.89261"
           93  +       r="1.2260786"
           94  +       gradientTransform="matrix(1.7016464,0,0,1.6348586,-2.9319775,-182.10895)"
           95  +       gradientUnits="userSpaceOnUse" />
           96  +    <radialGradient
           97  +       inkscape:collect="always"
           98  +       xlink:href="#linearGradient1395"
           99  +       id="radialGradient1389"
          100  +       gradientUnits="userSpaceOnUse"
          101  +       gradientTransform="matrix(0.66230313,-1.6430738,1.0154487,0.40931507,-290.06307,177.39489)"
          102  +       cx="4.02069"
          103  +       cy="287.79269"
          104  +       fx="4.02069"
          105  +       fy="287.79269"
          106  +       r="1.0866126" />
          107  +  </defs>
          108  +  <sodipodi:namedview
          109  +     id="base"
          110  +     pagecolor="#181818"
          111  +     bordercolor="#666666"
          112  +     borderopacity="1.0"
          113  +     inkscape:pageopacity="0"
          114  +     inkscape:pageshadow="2"
          115  +     inkscape:zoom="11.2"
          116  +     inkscape:cx="13.645085"
          117  +     inkscape:cy="22.307499"
          118  +     inkscape:document-units="mm"
          119  +     inkscape:current-layer="layer1"
          120  +     showgrid="false"
          121  +     units="px"
          122  +     inkscape:window-width="949"
          123  +     inkscape:window-height="1028"
          124  +     inkscape:window-x="963"
          125  +     inkscape:window-y="44"
          126  +     inkscape:window-maximized="0"
          127  +     showguides="false"
          128  +     fit-margin-top="0"
          129  +     fit-margin-left="0"
          130  +     fit-margin-right="0"
          131  +     fit-margin-bottom="0" />
          132  +  <metadata
          133  +     id="metadata5">
          134  +    <rdf:RDF>
          135  +      <cc:Work
          136  +         rdf:about="">
          137  +        <dc:format>image/svg+xml</dc:format>
          138  +        <dc:type
          139  +           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
          140  +        <dc:title></dc:title>
          141  +      </cc:Work>
          142  +    </rdf:RDF>
          143  +  </metadata>
          144  +  <g
          145  +     inkscape:label="Layer 1"
          146  +     inkscape:groupmode="layer"
          147  +     id="layer1"
          148  +     transform="translate(-2.9526324,-283.47435)">
          149  +    <path
          150  +       sodipodi:type="inkscape:offset"
          151  +       inkscape:radius="0.14186843"
          152  +       inkscape:original="M 3.625 286.55273 C 3.0632316 286.5586 3.0996094 286.98633 3.0996094 286.98633 C 3.0996094 286.98633 3.0113255 287.32746 3.4589844 287.69727 C 3.9066436 288.06708 4.1796875 288.625 4.1796875 288.625 C 4.1796875 288.625 4.4507783 288.06708 4.8984375 287.69727 C 5.3460971 287.32746 5.2578125 286.98633 5.2578125 286.98633 C 5.2578125 286.98633 5.2941901 286.55873 4.7324219 286.55273 C 4.1706518 286.54711 4.1796875 287.19141 4.1796875 287.19141 C 4.1796875 287.19141 4.1867679 286.54673 3.625 286.55273 z "
          153  +       style="fill:url(#radialGradient1385);fill-opacity:1;stroke:none;stroke-width:0.12017766px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
          154  +       id="path822"
          155  +       d="m 3.6230469,286.41016 c -0.3169165,0.003 -0.5116816,0.14302 -0.5957031,0.29101 -0.084022,0.14799 -0.068359,0.29688 -0.068359,0.29688 l 0.00391,-0.0469 c 0,0 -0.02958,0.12684 0.011719,0.28711 0.041298,0.16028 0.1506583,0.3669 0.3945312,0.56836 0.4154821,0.34323 0.6835938,0.88086 0.6835938,0.88086 a 0.14188261,0.14188261 0 0 0 0.2539062,0 c 0,0 0.2663147,-0.53776 0.6816406,-0.88086 0.2438734,-0.20146 0.3532329,-0.40808 0.3945313,-0.56836 0.041298,-0.16027 0.011719,-0.28711 0.011719,-0.28711 l 0.00391,0.0469 c 0,0 0.015663,-0.14891 -0.068359,-0.29688 -0.084023,-0.14797 -0.2787972,-0.28763 -0.5957031,-0.29101 -0.2850618,-0.003 -0.4543151,0.15732 -0.5546875,0.32226 -0.100738,-0.16498 -0.2713805,-0.32531 -0.5566406,-0.32226 z"
          156  +       transform="matrix(2.157964,0,0,2.2461218,-3.4190419,-359.83766)" />
          157  +    <path
          158  +       id="path819"
          159  +       style="fill:#ff8080;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
          160  +       d="m 4.0453212,286.36379 c -0.9660318,-0.83062 -0.7766137,-1.59419 -0.7766137,-1.59419 0,0 -0.075768,-0.96249 1.1365076,-0.97567 1.2122749,-0.0127 1.1933329,1.43711 1.1933329,1.43711 0,0 -0.018944,-1.44975 1.1933326,-1.43711 1.2122754,0.0127 1.1365076,0.97567 1.1365076,0.97567 0,0 0.1894205,0.76357 -0.7766137,1.59419 -0.9660323,0.83065 -1.5532265,2.08431 -1.5532265,2.08431 0,0 -0.5871945,-1.25366 -1.5532268,-2.08431 z"
          161  +       inkscape:connector-curvature="0"
          162  +       sodipodi:nodetypes="scccccscs" />
          163  +    <path
          164  +       sodipodi:nodetypes="scccccscs"
          165  +       inkscape:connector-curvature="0"
          166  +       d="m 3.4589842,287.69653 c -0.4476589,-0.36981 -0.3598826,-0.70976 -0.3598826,-0.70976 0,0 -0.035111,-0.42851 0.5266574,-0.43438 0.5617679,-0.006 0.5529901,0.63982 0.5529901,0.63982 0,0 -0.00878,-0.64544 0.5529901,-0.63982 0.5617682,0.006 0.5266574,0.43438 0.5266574,0.43438 0,0 0.087777,0.33995 -0.3598826,0.70976 -0.4476592,0.36981 -0.7197649,0.92795 -0.7197649,0.92795 0,0 -0.2721057,-0.55814 -0.7197649,-0.92795 z"
          167  +       style="fill:url(#radialGradient834);fill-opacity:1;stroke:none;stroke-width:0.12017766px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1356)"
          168  +       id="path826"
          169  +       transform="matrix(2.157964,0,0,2.2461218,-3.4190419,-359.83766)" />
          170  +    <path
          171  +       id="path1387"
          172  +       style="fill:url(#radialGradient1389);fill-opacity:1;stroke:none;stroke-width:0.12017766px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1356)"
          173  +       d="m 3.4589842,287.69653 c -0.4476589,-0.36981 -0.3598826,-0.70976 -0.3598826,-0.70976 0,0 -0.035111,-0.42851 0.5266574,-0.43438 0.5617679,-0.006 0.5529901,0.63982 0.5529901,0.63982 0,0 -0.00878,-0.64544 0.5529901,-0.63982 0.5617682,0.006 0.5266574,0.43438 0.5266574,0.43438 0,0 0.087777,0.33995 -0.3598826,0.70976 -0.4476592,0.36981 -0.7197649,0.92795 -0.7197649,0.92795 0,0 -0.2721057,-0.55814 -0.7197649,-0.92795 z"
          174  +       inkscape:connector-curvature="0"
          175  +       sodipodi:nodetypes="scccccscs"
          176  +       transform="matrix(2.157964,0,0,2.2461218,-3.4190419,-359.83766)" />
          177  +  </g>
          178  +</svg>

Modified static/live.js from [86bdd64b84] to [682908b4c8].

     1      1   /* first things first, we need to scan over the document and see
     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  +	/* social media is less fun when you can't just click on a tweet
            7  +	 * to insta-like or -retweet it. this is unfortunately not possible
            8  +	 * (except in various hideously shitty ways) without javascript. */
            9  +	function mk(elt) { return document.createElement(elt); }
           10  +	function attachButtons() {
           11  +		document.querySelectorAll('body:not(.post) main div.post').forEach(function(post){
           12  +			let url = post.querySelector('.permalink').attributes.getNamedItem('href').value;
           13  +			function postReq(act,elt) {
           14  +				fetch(new Request(url, {
           15  +					method: 'POST',
           16  +					body: 'act='+act
           17  +				})).then(function(resp) {
           18  +					if (resp.ok && resp.status == 200) {
           19  +						var i = parseInt(elt.innerHTML)
           20  +						if (isNaN(i)) {i=0}
           21  +						elt.innerHTML = (i+1).toString()
           22  +					}
           23  +				})
           24  +			}
           25  +
           26  +			var stats = post.querySelector('.stats');
           27  +			if (stats == null) {
           28  +				/* no stats box; create one */
           29  +				var n = mk('div');
           30  +				n.classList.add('stats');
           31  +				post.appendChild(n);
           32  +				stats = n
           33  +			}
           34  +			function ensureElt(cls, before) {
           35  +				let s = stats.querySelector('.' + cls);
           36  +				if (s == null) {
           37  +					var n = mk('div');
           38  +					n.classList.add(cls);
           39  +					if (before == null) { stats.appendChild(n) } else {
           40  +						stats.insertBefore(n,stats.querySelector(before))
           41  +					}
           42  +					return n
           43  +				} else { return s }
           44  +			}
           45  +			var like = ensureElt('like', null);
           46  +			var rt   = ensureElt('rt','.like');
           47  +			function activate(elt,name) {
           48  +				elt.addEventListener('click', function(e) { postReq(name,elt) });
           49  +				elt.style.setProperty('cursor','pointer');
           50  +			}
           51  +			activate(like,'like');
           52  +			activate(rt,'rt');
           53  +		});
           54  +	}
           55  +
     6     56   	/* update hue-picker background when slider is adjusted */
     7     57   	document.querySelectorAll('.color-picker').forEach(function(box) {
     8     58   		let slider = box.querySelector('[data-color-pick]');
     9     59   		box.style.setProperty('--hue', slider.value);
    10     60   		slider.addEventListener('input', function(e) {
    11     61   			box.style.setProperty('--hue', e.target.value);
    12     62   		});
................................................................................
    38     88   					return;
    39     89   				}
    40     90   				container._liveLastArrival = newest
    41     91   
    42     92   				resp.text().then(function(htmlbody) {
    43     93   					var parser = new DOMParser();
    44     94   					var newdoc = parser.parseFromString(htmlbody,'text/html')
    45         -					container.innerHTML = newdoc.getElementById(container.id).innerHTML
           95  +					container.innerHTML = newdoc.getElementById(container.id).innerHTML;
           96  +					attachButtons();
    46     97   				})
    47     98   			})
    48     99   		}, interv)
    49    100   	});
          101  +
          102  +	attachButtons();
    50    103   });

Added static/retweet.svg version [c3fa459a24].

            1  +<?xml version="1.0" encoding="UTF-8" standalone="no"?>
            2  +<!-- Created with Inkscape (http://www.inkscape.org/) -->
            3  +
            4  +<svg
            5  +   xmlns:dc="http://purl.org/dc/elements/1.1/"
            6  +   xmlns:cc="http://creativecommons.org/ns#"
            7  +   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
            8  +   xmlns:svg="http://www.w3.org/2000/svg"
            9  +   xmlns="http://www.w3.org/2000/svg"
           10  +   xmlns:xlink="http://www.w3.org/1999/xlink"
           11  +   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
           12  +   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
           13  +   width="20"
           14  +   height="20"
           15  +   viewBox="0 0 5.2916664 5.2916665"
           16  +   version="1.1"
           17  +   id="svg8"
           18  +   inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
           19  +   sodipodi:docname="retweet.svg">
           20  +  <defs
           21  +     id="defs2">
           22  +    <linearGradient
           23  +       id="linearGradient2866"
           24  +       inkscape:collect="always">
           25  +      <stop
           26  +         id="stop2862"
           27  +         offset="0"
           28  +         style="stop-color:#9a57ff;stop-opacity:1" />
           29  +      <stop
           30  +         id="stop2864"
           31  +         offset="1"
           32  +         style="stop-color:#ab73ff;stop-opacity:0" />
           33  +    </linearGradient>
           34  +    <linearGradient
           35  +       inkscape:collect="always"
           36  +       id="linearGradient1966">
           37  +      <stop
           38  +         style="stop-color:#f0e7ff;stop-opacity:1;"
           39  +         offset="0"
           40  +         id="stop1962" />
           41  +      <stop
           42  +         style="stop-color:#f0e7ff;stop-opacity:0;"
           43  +         offset="1"
           44  +         id="stop1964" />
           45  +    </linearGradient>
           46  +    <linearGradient
           47  +       inkscape:collect="always"
           48  +       id="linearGradient1468">
           49  +      <stop
           50  +         style="stop-color:#9955ff;stop-opacity:1;"
           51  +         offset="0"
           52  +         id="stop1464" />
           53  +      <stop
           54  +         style="stop-color:#9955ff;stop-opacity:0;"
           55  +         offset="1"
           56  +         id="stop1466" />
           57  +    </linearGradient>
           58  +    <linearGradient
           59  +       inkscape:collect="always"
           60  +       id="linearGradient1403">
           61  +      <stop
           62  +         style="stop-color:#ccaaff;stop-opacity:1;"
           63  +         offset="0"
           64  +         id="stop1399" />
           65  +      <stop
           66  +         style="stop-color:#ccaaff;stop-opacity:0;"
           67  +         offset="1"
           68  +         id="stop1401" />
           69  +    </linearGradient>
           70  +    <linearGradient
           71  +       id="linearGradient1395"
           72  +       inkscape:collect="always">
           73  +      <stop
           74  +         id="stop1391"
           75  +         offset="0"
           76  +         style="stop-color:#ff1616;stop-opacity:1" />
           77  +      <stop
           78  +         id="stop1393"
           79  +         offset="1"
           80  +         style="stop-color:#ff1d1d;stop-opacity:0" />
           81  +    </linearGradient>
           82  +    <linearGradient
           83  +       inkscape:collect="always"
           84  +       id="linearGradient1383">
           85  +      <stop
           86  +         style="stop-color:#980000;stop-opacity:1;"
           87  +         offset="0"
           88  +         id="stop1379" />
           89  +      <stop
           90  +         style="stop-color:#980000;stop-opacity:0;"
           91  +         offset="1"
           92  +         id="stop1381" />
           93  +    </linearGradient>
           94  +    <linearGradient
           95  +       inkscape:collect="always"
           96  +       id="linearGradient832">
           97  +      <stop
           98  +         style="stop-color:#ffcfcf;stop-opacity:1;"
           99  +         offset="0"
          100  +         id="stop828" />
          101  +      <stop
          102  +         style="stop-color:#ffcfcf;stop-opacity:0;"
          103  +         offset="1"
          104  +         id="stop830" />
          105  +    </linearGradient>
          106  +    <radialGradient
          107  +       inkscape:collect="always"
          108  +       xlink:href="#linearGradient832"
          109  +       id="radialGradient834"
          110  +       cx="3.2286437"
          111  +       cy="286.62921"
          112  +       fx="3.2286437"
          113  +       fy="286.62921"
          114  +       r="1.0866126"
          115  +       gradientTransform="matrix(1.8608797,0.8147617,-0.38242057,0.87343168,106.71446,33.692223)"
          116  +       gradientUnits="userSpaceOnUse" />
          117  +    <radialGradient
          118  +       inkscape:collect="always"
          119  +       xlink:href="#linearGradient1383"
          120  +       id="radialGradient1385"
          121  +       cx="4.1787109"
          122  +       cy="286.89261"
          123  +       fx="4.1787109"
          124  +       fy="286.89261"
          125  +       r="1.2260786"
          126  +       gradientTransform="matrix(1.7016464,0,0,1.6348586,-2.9319775,-182.10895)"
          127  +       gradientUnits="userSpaceOnUse" />
          128  +    <radialGradient
          129  +       inkscape:collect="always"
          130  +       xlink:href="#linearGradient1395"
          131  +       id="radialGradient1389"
          132  +       gradientUnits="userSpaceOnUse"
          133  +       gradientTransform="matrix(0.66230313,-1.6430738,1.0154487,0.40931507,-290.06307,177.39489)"
          134  +       cx="4.02069"
          135  +       cy="287.79269"
          136  +       fx="4.02069"
          137  +       fy="287.79269"
          138  +       r="1.0866126" />
          139  +    <linearGradient
          140  +       inkscape:collect="always"
          141  +       xlink:href="#linearGradient1403"
          142  +       id="linearGradient1405"
          143  +       x1="8.3939333"
          144  +       y1="288.1091"
          145  +       x2="7.0158253"
          146  +       y2="287.32819"
          147  +       gradientUnits="userSpaceOnUse" />
          148  +    <filter
          149  +       inkscape:collect="always"
          150  +       style="color-interpolation-filters:sRGB"
          151  +       id="filter2508"
          152  +       x="-0.24674278"
          153  +       width="1.4934856"
          154  +       y="-0.13581935"
          155  +       height="1.2716388">
          156  +      <feGaussianBlur
          157  +         inkscape:collect="always"
          158  +         stdDeviation="0.056246184"
          159  +         id="feGaussianBlur2510" />
          160  +    </filter>
          161  +    <filter
          162  +       inkscape:collect="always"
          163  +       style="color-interpolation-filters:sRGB"
          164  +       id="filter3064"
          165  +       x="-0.07694713"
          166  +       width="1.1538943"
          167  +       y="-0.14115551"
          168  +       height="1.282311">
          169  +      <feGaussianBlur
          170  +         inkscape:collect="always"
          171  +         stdDeviation="0.065422039"
          172  +         id="feGaussianBlur3066" />
          173  +    </filter>
          174  +    <linearGradient
          175  +       inkscape:collect="always"
          176  +       xlink:href="#linearGradient2866"
          177  +       id="linearGradient1533"
          178  +       gradientUnits="userSpaceOnUse"
          179  +       x1="8.3939333"
          180  +       y1="288.1091"
          181  +       x2="7.0097656"
          182  +       y2="287.25977" />
          183  +    <radialGradient
          184  +       inkscape:collect="always"
          185  +       xlink:href="#linearGradient1468"
          186  +       id="radialGradient1535"
          187  +       gradientUnits="userSpaceOnUse"
          188  +       gradientTransform="matrix(1,0,0,1.5198212,0,-149.75763)"
          189  +       cx="8.525074"
          190  +       cy="288.10031"
          191  +       fx="8.525074"
          192  +       fy="288.10031"
          193  +       r="0.43142718" />
          194  +    <linearGradient
          195  +       inkscape:collect="always"
          196  +       xlink:href="#linearGradient1403"
          197  +       id="linearGradient1537"
          198  +       gradientUnits="userSpaceOnUse"
          199  +       x1="8.3939333"
          200  +       y1="288.1091"
          201  +       x2="7.0158253"
          202  +       y2="287.32819" />
          203  +    <radialGradient
          204  +       inkscape:collect="always"
          205  +       xlink:href="#linearGradient1966"
          206  +       id="radialGradient1539"
          207  +       gradientUnits="userSpaceOnUse"
          208  +       gradientTransform="matrix(1,0,0,1.2339206,0,-67.391253)"
          209  +       cx="8.7198324"
          210  +       cy="288.09686"
          211  +       fx="8.7198324"
          212  +       fy="288.09686"
          213  +       r="0.27354568" />
          214  +  </defs>
          215  +  <sodipodi:namedview
          216  +     id="base"
          217  +     pagecolor="#181818"
          218  +     bordercolor="#666666"
          219  +     borderopacity="1.0"
          220  +     inkscape:pageopacity="0"
          221  +     inkscape:pageshadow="2"
          222  +     inkscape:zoom="7.919596"
          223  +     inkscape:cx="7.7101412"
          224  +     inkscape:cy="36.101286"
          225  +     inkscape:document-units="mm"
          226  +     inkscape:current-layer="layer1"
          227  +     showgrid="false"
          228  +     units="px"
          229  +     inkscape:window-width="949"
          230  +     inkscape:window-height="1028"
          231  +     inkscape:window-x="963"
          232  +     inkscape:window-y="44"
          233  +     inkscape:window-maximized="0"
          234  +     showguides="false"
          235  +     fit-margin-top="0"
          236  +     fit-margin-left="0"
          237  +     fit-margin-right="0"
          238  +     fit-margin-bottom="0" />
          239  +  <metadata
          240  +     id="metadata5">
          241  +    <rdf:RDF>
          242  +      <cc:Work
          243  +         rdf:about="">
          244  +        <dc:format>image/svg+xml</dc:format>
          245  +        <dc:type
          246  +           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
          247  +        <dc:title></dc:title>
          248  +      </cc:Work>
          249  +    </rdf:RDF>
          250  +  </metadata>
          251  +  <g
          252  +     inkscape:label="Layer 1"
          253  +     inkscape:groupmode="layer"
          254  +     id="layer1"
          255  +     transform="translate(-2.6134661,-283.36966)">
          256  +    <g
          257  +       id="g1452"
          258  +       transform="matrix(2.0546825,0,0,1.965062,-10.834174,-279.0744)"
          259  +       style="stroke-width:0.49766773">
          260  +      <path
          261  +         sodipodi:type="inkscape:offset"
          262  +         inkscape:radius="0.051069338"
          263  +         inkscape:original="M 7.015625 287.32812 L 6.5898438 287.41211 C 6.5898438 287.41211 6.7325506 288.06384 6.7910156 288.16406 C 6.8494806 288.26426 8.5195312 288.33789 8.5195312 288.33789 L 8.5273438 287.88867 C 8.5273438 287.88867 7.158409 287.98057 7.125 287.88867 C 7.0915917 287.7968 7.015625 287.32812 7.015625 287.32812 z "
          264  +         style="fill:url(#linearGradient1533);fill-opacity:1;stroke:none;stroke-width:0.13167457px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter3064)"
          265  +         id="path2512"
          266  +         d="m 7.0058594,287.27734 -0.4257813,0.084 a 0.05107445,0.05107445 0 0 0 -0.041016,0.0625 c 0,0 0.036909,0.16168 0.080078,0.33789 0.021585,0.0881 0.044485,0.17955 0.066406,0.25586 0.021922,0.0763 0.038348,0.13382 0.060547,0.17187 0.016645,0.0285 0.035211,0.0334 0.054687,0.041 0.019476,0.008 0.043217,0.0134 0.070313,0.0195 0.054191,0.0123 0.125713,0.0226 0.2089843,0.0332 0.1665426,0.0212 0.3808629,0.0411 0.59375,0.0566 0.4257743,0.031 0.84375,0.0488 0.84375,0.0488 a 0.05107445,0.05107445 0 0 0 0.052734,-0.0508 l 0.00781,-0.44922 a 0.05107445,0.05107445 0 0 0 -0.054687,-0.0508 c 0,0 -0.3402778,0.0237 -0.6855469,0.0352 -0.1726345,0.006 -0.3476553,0.008 -0.4785156,0.004 -0.06543,-0.002 -0.1194572,-0.006 -0.15625,-0.0117 -0.015583,-0.002 -0.025917,-0.006 -0.033203,-0.008 -0.00622,-0.0201 -0.015078,-0.0559 -0.025391,-0.10547 -0.011663,-0.0561 -0.025573,-0.12344 -0.037109,-0.1875 -0.023072,-0.12812 -0.041016,-0.24414 -0.041016,-0.24414 a 0.05107445,0.05107445 0 0 0 -0.060547,-0.043 z" />
          267  +      <path
          268  +         d="m 8.3984375,287.4707 a 0.12639828,0.12639828 0 0 0 -0.125,0.10157 c -0.077254,0.36258 -0.064406,0.70957 -0.00195,1.04296 a 0.12639828,0.12639828 0 0 0 0.234375,0.0391 c 0.1095654,-0.19397 0.2246157,-0.35162 0.4375,-0.44532 a 0.12639828,0.12639828 0 0 0 -0.011719,-0.23632 c -0.170448,-0.055 -0.3069446,-0.1953 -0.421875,-0.42969 a 0.12639828,0.12639828 0 0 0 -0.1113281,-0.0723 z"
          269  +         id="path1430"
          270  +         style="fill:url(#radialGradient1535);fill-opacity:1;stroke:none;stroke-width:0.13167457px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
          271  +         inkscape:original="M 8.3964844 287.59766 C 8.3232974 287.94116 8.3363034 288.27053 8.3964844 288.5918 C 8.5106543 288.38968 8.6461782 288.2022 8.8925781 288.09375 C 8.6798713 288.02515 8.5201615 287.84989 8.3964844 287.59766 z "
          272  +         inkscape:radius="0.12638564"
          273  +         sodipodi:type="inkscape:offset" />
          274  +      <path
          275  +         inkscape:connector-curvature="0"
          276  +         id="path1397"
          277  +         d="m 8.519216,288.33878 c 0,0 -1.6704347,-0.0752 -1.7288997,-0.1754 -0.058465,-0.10022 -0.200452,-0.75169 -0.200452,-0.75169 l 0.4259608,-0.0835 c 0,0 0.07517,0.46773 0.1085783,0.5596 0.033409,0.0919 1.403165,0 1.403165,0 z"
          278  +         style="fill:url(#linearGradient1537);fill-opacity:1;stroke:none;stroke-width:0.13167457px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
          279  +      <path
          280  +         sodipodi:nodetypes="cccc"
          281  +         inkscape:connector-curvature="0"
          282  +         d="m 8.3960186,287.59752 c 0.1236771,0.25223 0.2842532,0.42835 0.49696,0.49695 -0.2463999,0.10845 -0.3827901,0.29483 -0.49696,0.49695 -0.060181,-0.32127 -0.073187,-0.6504 0,-0.9939 z"
          283  +         style="fill:#d8c0ff;fill-opacity:1;stroke:none;stroke-width:0.13167457px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
          284  +         id="path1409" />
          285  +      <path
          286  +         id="path1960"
          287  +         style="fill:url(#radialGradient1539);fill-opacity:1;stroke:none;stroke-width:0.13167457px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter2508)"
          288  +         d="m 8.3960186,287.59752 c 0.1236771,0.25223 0.2842532,0.42835 0.49696,0.49695 -0.2463999,0.10845 -0.3827901,0.29483 -0.49696,0.49695 -0.060181,-0.32127 -0.073187,-0.6504 0,-0.9939 z"
          289  +         inkscape:connector-curvature="0"
          290  +         sodipodi:nodetypes="cccc" />
          291  +    </g>
          292  +    <use
          293  +       x="0"
          294  +       y="0"
          295  +       xlink:href="#g1452"
          296  +       id="use1460"
          297  +       transform="rotate(180,5.2593307,286.0155)"
          298  +       width="100%"
          299  +       height="100%"
          300  +       style="stroke-width:0.49766773" />
          301  +  </g>
          302  +</svg>

Modified static/style.scss from [4fd9d6949f] to [322d30fa17].

    79     79   	text-shadow: 1px 1px black;
    80     80   	text-decoration: none;
    81     81   	text-align: center;
    82     82   	cursor: default;
    83     83   	user-select: none;
    84     84   	-webkit-user-drag: none;
    85     85   	-webkit-app-region: no-drag;
    86         -	background: linear-gradient(to bottom,
           86  +	--icon: url(/s/heart.webp);
           87  +	background-image: linear-gradient(to bottom,
    87     88   		otone(-47%),
    88     89   		otone(-50%) 15%,
    89     90   		otone(-50%) 75%,
    90     91   		otone(-53%)
    91     92   	);
    92     93   	&:hover, &:focus {
    93     94   		@extend %glow;
................................................................................
   482    483   }
   483    484   
   484    485   div.thread {
   485    486   	margin-left: 0.3in;
   486    487   	& + div.post { margin-top: 0.3in; }
   487    488   }
   488    489   
          490  +a[href].username {
          491  +	>.nym { font-weight: bold; }
          492  +	color: tone(0%,-0.4);
          493  +	> span.nym { color: tone(10%) }
          494  +	> span.handle { color: tone(-5%) }
          495  +	&:hover {
          496  +		> span.nym { color: white; }
          497  +		> span.handle { color: tone(15%) }
          498  +	}
          499  +}
   489    500   div.post {
   490    501   	@extend %box;
   491    502   	display: grid;
   492         -	grid-template-columns: 1in 1fr max-content;
          503  +	margin: unset;
          504  +	grid-template-columns: 1in 1fr max-content max-content;
   493    505   	grid-template-rows: min-content max-content;
   494    506   	margin-bottom: 0.1in;
   495    507   	>.avatar {
   496    508   		grid-column: 1/2; grid-row: 1/2;
   497    509   		img { display: block; width: 1in; height: 1in; margin:0; }
   498    510   		background: linear-gradient(to bottom, tone(-53%), tone(-57%));
   499    511   	}
................................................................................
   501    513   		display: block;
   502    514   		grid-column: 1/3;
   503    515   		grid-row: 2/3;
   504    516   		text-align: left;
   505    517   		text-decoration: none;
   506    518   		padding: 0.1in;
   507    519   		padding-left: 0.15in;
   508         -		>.nym { font-weight: bold; }
   509         -		color: tone(0%,-0.4);
   510         -		> span.nym { color: tone(10%) }
   511         -		> span.handle { color: tone(-5%) }
   512    520   		background: linear-gradient(to right, tone(-55%), transparent);
   513         -		&:hover {
   514         -			> span.nym { color: white; }
   515         -			> span.handle { color: tone(15%) }
   516         -		}
   517    521   	}
   518    522   	>.content {
   519         -		grid-column: 2/4; grid-row: 1/2;
          523  +		grid-column: 2/5; grid-row: 1/2;
   520    524   		padding: 0.2in;
   521    525   		@extend %serif;
   522    526   		font-size: 110%;
   523    527   		text-align: justify;
   524    528   		color: tone(25%);
   525    529   	}
   526    530   	> a[href].permalink {
   527    531   		display: block;
   528         -		grid-column: 3/4; grid-row: 2/3;
          532  +		grid-column: 4/5; grid-row: 2/3;
   529    533   		font-size: 80%;
   530    534   		text-align: right;
   531    535   		padding: 0.1in;
   532    536   		padding-right: 0.15in;
   533    537   		font-style: italic;
   534    538   		background: linear-gradient(to left, tone(-55%,-0.5), transparent);
   535    539   	}
          540  +	div.stats {
          541  +		display: flex;
          542  +		grid-column: 3/4; grid-row: 2/3;
          543  +		justify-content: center;
          544  +		> .like, > .rt {
          545  +			margin: 0.5em 0.3em;
          546  +			padding-left: 1.3em;
          547  +			background-size: 1.1em;
          548  +			background-repeat: no-repeat;
          549  +			min-width: 0.3em;
          550  +			&:empty {
          551  +				transition: 0.3s;
          552  +				opacity: 0.1;
          553  +				&:hover { opacity: 0.6 !important; }
          554  +			}
          555  +		}
          556  +		> .like {
          557  +			background-image: url(/s/heart.webp);
          558  +		}
          559  +		> .rt {
          560  +			background-image: url(/s/retweet.webp);
          561  +		}
          562  +	}
   536    563   }
          564  +
          565  +div.post:hover div.stats { > .like, > .rt { &:empty {opacity: 0.3;} } }
   537    566   
   538    567   a[href].rawlink {
   539    568   	@extend %teletype;
   540    569   }
   541    570   
   542    571   body.doc main {
   543    572   	@extend %serif;
................................................................................
   810    839   		&+label:hover {
   811    840   			background-color: otone(-35%);
   812    841   			color: white;
   813    842   		}
   814    843   		&:checked+label {
   815    844   			border-top: 1px solid otone(-10%);
   816    845   			border-bottom: 1px solid otone(-50%);
   817         -			background: linear-gradient(to bottom, otone(-25%,-0.2), otone(-28%,-0.4) 35%, otone(-30%,-0.7));
          846  +			background: linear-gradient(to bottom, otone(-25%,-0.2), otone(-28%,-0.3) 35%, otone(-30%,-0.5));
   818    847   			color: white;
   819    848   			box-shadow: 0 0 0 1px tone(-60%);
   820    849   			&:hover {
   821    850   				border-top: 1px solid otone(10%);
   822    851   				border-bottom: 1px solid otone(-60%);
   823    852   				font-weight: bold;
   824    853   			}
................................................................................
   870    899   				transform: rotate(90deg) scale(1.1);
   871    900   				color: tone(-20%);
   872    901   				text-shadow: 0 0 8px tone(-30%);
   873    902   			}
   874    903   		}
   875    904   	}
   876    905   }
          906  +
          907  +div.lede {
          908  +	display: grid;
          909  +	grid-template-columns: 1fr min-content;
          910  +	grid-template-rows: 1.5em 1fr;
          911  +	padding: 0.1in 0.3in;
          912  +	margin: 0 -0.2in;
          913  +	margin-top: 0.2in;
          914  +	border-radius: 3px;
          915  +	background: linear-gradient(to bottom, tone(-40%,-0.5), transparent);
          916  +	border-top: 1px solid tone(-5%,-0.7);
          917  +	> .promo {
          918  +		grid-row: 1/2; grid-column: 1/2;
          919  +		font-style: italic;
          920  +		font-size: 90%;
          921  +		color: tone(-10%);
          922  +		> img {
          923  +			vertical-align: middle;
          924  +			margin-right: 0.4em;
          925  +			width: 1em; height: 1em;
          926  +		}
          927  +	}
          928  +	> a[href].del {
          929  +		grid-row: 1/2; grid-column: 2/3;
          930  +		text-decoration: none;
          931  +	}
          932  +	> .post {
          933  +		grid-row: 2/3; grid-column: 1/3;
          934  +	}
          935  +}

Modified store.t from [da1c9184b0] to [6a465decce].

     2      2   local m = {
     3      3   	timepoint = lib.osclock.time_t;
     4      4   	scope = lib.enum {
     5      5   		'public', 'private', 'local';
     6      6   		'personal', 'direct', 'circle';
     7      7   	};
     8      8   	notiftype = lib.enum {
     9         -		'mention', 'like', 'rt', 'react'
            9  +		'none', 'mention', 'like', 'rt', 'react'
    10     10   	};
    11     11   
    12     12   	relation = lib.set {
    13     13   		'silence', -- messages will not be accepted
    14     14   		'collapse', -- posts will be collapsed by default
    15     15   		'disemvowel', -- posts will be ritually humiliated, but shown
    16     16   		'avoid', -- posts will be kept out of the timeline but will show on users' posts and in conversations
................................................................................
    18     18   		'mute', -- posts will be completely hidden at all times
    19     19   		'block', -- no interactions will be permitted, but posts will remain visible
    20     20   	};
    21     21   	credset = lib.set {
    22     22   		'pw', 'otp', 'challenge', 'trust'
    23     23   	};
    24     24   	privset = lib.set {
    25         -		'post', 'edit', 'acct', 'upload', 'censor', 'admin', 'invite'
           25  +		'post', 'edit', 'account', 'upload', 'moderate', 'admin', 'invite'
    26     26   	};
    27     27   	powerset = lib.set {
    28     28   		-- user powers -- default on
    29     29   		'login', -- not locked out
    30     30   		'visible', -- account & posts can be seen by others
    31     31   		'post', -- can do poasts
    32     32   		'shout', -- posts show up on local timeline
    33     33   		'propagate', -- posts are sent to other instances
    34     34   		'artifact', -- upload, claim, and manage artifacts
    35         -		'acct', -- configure own account
           35  +		'account', -- configure own account
    36     36   		'edit'; -- edit own poasts
           37  +		'snitch'; -- can issue badthink reports
    37     38   
    38     39   		-- admin powers -- default off
    39     40   		'purge', -- permanently delete users
    40     41   		'config', -- change daemon policy & config UI
    41     42   		'censor', -- dispose of badthink
    42     43   		'discipline', -- enforced timeouts, stripping badges and epithets, punitive actions that do not permanently deprive of powers; can remove own injunctions but not others'
    43     44   		'vacate', -- can remove others' injunctions, but not apply them
    44     45   		'cred', -- alter credentials
    45     46   		'elevate', 'demote', -- change user rank, give and take powers, including the ability to log in
    46     47   		'rebrand', -- modify site's brand identity
    47     48   		'herald', -- grant serverwide epithets and badges
           49  +		'crier', -- can promote content to the instance page
    48     50   		'invite' -- *unlimited* invites
    49     51   	};
    50     52   	prepmode = lib.enum {
    51     53   		'full','conf','admin'
    52     54   	}
    53     55   }
    54     56   
................................................................................
    84     86   	var pow: m.powerset pow:clear()
    85     87   	(pow.login     << true)
    86     88   	(pow.visible   << true)
    87     89   	(pow.post      << true)
    88     90   	(pow.shout     << true)
    89     91   	(pow.propagate << true)
    90     92   	(pow.artifact  << true)
    91         -	(pow.acct      << true)
           93  +	(pow.account   << true)
    92     94   	(pow.edit      << true)
    93     95   	return m.rights { rank = 0, quota = 1000, invites = 0, powers = pow; }
    94     96   end
    95     97   
    96     98   struct m.actor {
    97     99   	id: uint64
    98    100   	nym: str
................................................................................
   212    214   	mentions: lib.mem.ptr(uint64)
   213    215   	circles: lib.mem.ptr(uint64) --only meaningful if scope is set to circle
   214    216   	convoheaduri: str
   215    217   	parent: uint64
   216    218   -- ephemera
   217    219   	localpost: bool
   218    220   	accent: int16
   219         -	depth: uint16 -- used in conversations to indicate tree depth
          221  +	rts: uint32
          222  +	likes: uint32
          223  +	rtdby: uint64 -- 0 if not rt
          224  +	rtact: uint64 -- 0 if not rt, id of rt action otherwise
   220    225   	source: &m.source
   221    226   
   222    227   	-- save :: bool -> {} (defined in acl.t due to dep. hell)
   223    228   }
   224    229   
   225    230   m.user_conf_funcs = function(be,n,ty,rty,rty2)
   226    231   	rty = rty or ty
................................................................................
   381    386   			-- origin: inet
   382    387   			-- cookie issue time: m.timepoint
   383    388   	actor_auth_register_uid: {&m.source, uint64, uint64} -> {}
   384    389   		-- notifies the backend module of the UID that has been assigned for
   385    390   		-- an authentication ID
   386    391   			-- aid: uint64
   387    392   			-- uid: uint64
          393  +	actor_notifs_fetch: {&m.source, uint64} -> lib.mem.lstptr(m.notif)
   388    394   
   389    395   	auth_enum_uid:    {&m.source, uint64}    -> lib.mem.lstptr(m.auth)
   390    396   	auth_enum_handle: {&m.source, rawstring} -> lib.mem.lstptr(m.auth)
   391    397   	auth_attach_pw: {&m.source, uint64, bool, pstr, pstr} -> {}
   392    398   		-- uid: uint64
   393    399   		-- reset: bool (delete other passwords?)
   394    400   		-- pw: pstring
................................................................................
   413    419   	post_enum_author_uid: {&m.source, uint64, m.range} -> lib.mem.lstptr(m.post)
   414    420   	post_enum_parent: {&m.source, uint64} -> lib.mem.lstptr(m.post)
   415    421   	post_attach_ctl: {&m.source, uint64, uint64, bool} -> {}
   416    422   		-- attaches or detaches an existing database artifact
   417    423   			-- post id: uint64
   418    424   			-- artifact id: uint64
   419    425   			-- detach: bool
          426  +	post_retweet: {&m.source, uint64, uint64, bool} -> {}
          427  +	post_like: {&m.source, uint64, uint64, bool} -> {}
          428  +			-- undo: bool
          429  +	post_react: {&m.source, uint64, uint64, pstring} -> {}
          430  +			-- emoji: pstring (null to delete previous reaction, otherwise adds/changes)
          431  +	post_liked_uid: {&m.source, uint64, uint64} -> bool
          432  +	post_reacted_uid: {&m.source, uint64, uint64} -> bool
   420    433   
   421    434   	thread_latest_arrival_calc: {&m.source, uint64} -> m.timepoint
   422    435   
   423    436   	artifact_instantiate: {&m.source, lib.mem.ptr(uint8), lib.mem.ptr(int8)} -> uint64
   424    437   		-- instantiate an artifact in the database, either installing a new
   425    438   		-- artifact or returning the id of an existing artifact with the same hash
   426    439   			-- artifact: bytea

Modified str.t from [1e93ad8eb5] to [638f6c2759].

   178    178   		self.buf = [rawstring](lib.mem.heapr_raw(self.buf, self.space))
   179    179   	end
   180    180   	lib.mem.cpy(self.buf + self.sz, str, len)
   181    181   	self.sz = self.sz + len
   182    182   	self.buf[self.sz] = 0
   183    183   	return self
   184    184   end;
          185  +
          186  +terra m.acc:ipush(i: intptr)
          187  +	var decbuf: int8[21]
          188  +	var si = lib.math.decstr_friendly(i, &decbuf[20])
          189  +	var len: intptr = [decbuf.type.N] - (si - &decbuf[0])
          190  +	return self:push(si,len)
          191  +end
          192  +
          193  +terra m.acc:shpush(i: uint64)
          194  +	var sbuf: int8[lib.math.shorthand.maxlen]
          195  +	var len = lib.math.shorthand.gen(i,&sbuf[0])
          196  +	return self:push(&sbuf[0], len)
          197  +end
   185    198   
   186    199   m.lit = macro(function(str)
   187    200   	if str:asvalue() ~= nil then
   188    201   		return `[lib.mem.ref(int8)] {ptr = [str:asvalue()], ct = [#(str:asvalue())]}
   189    202   	else
   190    203   		return `[lib.mem.ref(int8)] {ptr = nil, ct = 0}
   191    204   	end

Modified view/confirm.tpl from [3b921f59eb] to [0d2952df9c].

     1      1   <form class="message" method="post">
     2         -	<img class="icon" src="/s/query.svg">
            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      6   		<a class="button" href="@:cancel">cancel</a>
     7      7   		<button name="act" value="confirm">confirm</button>
     8      8   	</menu>
     9      9   </form>

Modified view/tweet.tpl from [e117899db4] to [aefe28dd4e].

     1      1   <div class="post"@attr>
     2      2   	<div class="avatar"><img src="@:avatar"></div>
     3      3   	<a class="username" href="/@:acctlink">@nym</a>
     4      4   	<div class="content">
     5      5   		<div class="subject">@!subject</div>
     6      6   		<div class="text">@text</div>
     7      7   	</div>
            8  +	@stats
     8      9   	<a class="permalink" href="@permalink">@when</a>
     9     10   </div>