Index: backend/pgsql.t ================================================================== --- backend/pgsql.t +++ backend/pgsql.t @@ -695,11 +695,11 @@ end; } local sqlvars = {} for i, n in ipairs(lib.store.noticetype.members) do - sqlvars['notice:' .. n] = lib.store.noticetype[n] + sqlvars['notice:' .. n] = lib.store.noticetype[n]:asvalue() end for i, n in ipairs(lib.store.relation.members) do sqlvars['rel:' .. n] = lib.store.relation.idvmap[n] end @@ -849,10 +849,11 @@ p.ptr.accent = r:int(int16,row,12) p.ptr.rtdby = r:int(uint64,row,13) p.ptr.rtact = r:int(uint64,row,14) p.ptr.likes = r:int(uint32,row,15) p.ptr.rts = r:int(uint32,row,16) + p.ptr.isreply = r:bool(row,17) p.ptr.localpost = r:bool(row,0) return p end local terra row_to_actor(r: &pqr, row: intptr): lib.mem.ptr(lib.store.actor) @@ -1418,10 +1419,36 @@ actor_purge_uid = [terra( src: &lib.store.source, uid: uint64 ) queries.actor_purge_uid.exec(src,uid) end]; + + actor_notice_enum = [terra( + src: &lib.store.source, + uid: uint64 + ): lib.mem.ptr(lib.store.notice) + var r = queries.actor_notice_enum.exec(src,uid); + if r.sz == 0 then return [lib.mem.ptr(lib.store.notice)].null() end + defer r:free() + + var notes = lib.mem.heapa(lib.store.notice, r.sz) + for i=0, r.sz do + var n = notes.ptr + i + n.kind = r:int(uint16,i,0) + n.when = r:int(int64,i,1) + n.who = r:int(int64,i,2) + n.what = r:int(uint64,i,3) + if n.kind == lib.store.noticetype.reply then + n.reply = r:int(uint64,i,4) + elseif n.kind == lib.store.noticetype.react then + var react = r:_string(i,5) + lib.str.ncpy(n.reaction, react.ptr, lib.math.smallest(react.ct,[(`n.reaction).tree.type.N])) + end + end + + return notes + end]; auth_enum_uid = [terra( src: &lib.store.source, uid: uint64 ): lib.mem.ptr(lib.mem.ptr(lib.store.auth)) Index: backend/schema/pgsql-views.sql ================================================================== --- backend/schema/pgsql-views.sql +++ backend/schema/pgsql-views.sql @@ -72,15 +72,17 @@ discovered bigint, edited bigint, parent bigint, convoheaduri text, chgcount integer, +-- ephemeral accent smallint, rtdby bigint, -- note that these must be 0 if the record rtid bigint, -- in question does not represent an RT! n_likes integer, - n_rts integer + n_rts integer, + isreply bool -- true if parent in (table posts); saves us a bunch of queries ); create or replace function pg_temp.parsavpg_translate_post(parsav_posts,bigint,bigint) returns pg_temp.parsavpg_intern_post as $$ @@ -89,11 +91,12 @@ ($1).subject,($1).acl, ($1).body, ($1).posted, ($1).discovered, ($1).edited, ($1).parent, ($1).convoheaduri,($1).chgcount, coalesce(c.value, -1)::smallint, $2 as rtdby, $3 as rtid, - re.likes, re.rts + re.likes, re.rts, + ($1).parent in (select id from parsav_posts) from parsav_actors as a left join parsav_actor_conf_ints as c on c.key = 'ui-accent' and c.uid = a.id left join pg_temp.parsavpg_post_react_counts as re Index: config.lua ================================================================== --- config.lua +++ config.lua @@ -58,10 +58,17 @@ {'heart.webp', 'image/webp'}; {'retweet.webp', 'image/webp'}; {'padlock.svg', 'image/svg+xml'}; {'warn.svg', 'image/svg+xml'}; {'query.webp', 'image/webp'}; + {'reply.webp', 'image/webp'}; + -- keep in mind before you add anything to this list: these are not + -- just files parsav can access, they are files that are *kept in + -- memory* for fast access the entire time parsav is running, and + -- which need to be loaded into memory before the program can even + -- start. it's imperative to keep these as small and few in number + -- as is realistically possible. }; default_ui_accent = tonumber(default('parsav_ui_default_accent',323)); } if os.getenv('parsav_let_me_be_an_idiot') == "i know what i'm doing" then conf.braingeniousmode = true -- SOUND GENERAL QUARTERS Index: makefile ================================================================== --- makefile +++ makefile @@ -1,9 +1,9 @@ dl = git dbg-flags = $(if $(dbg),-g) -images = static/default-avatar.webp static/query.webp static/heart.webp static/retweet.webp +images = static/default-avatar.webp static/query.webp static/heart.webp static/retweet.webp static/reply.webp #$(addsuffix .webp, $(basename $(wildcard static/*.svg))) styles = $(addsuffix .css, $(basename $(wildcard static/*.scss))) parsav parsavd: parsav.t config.lua pkgdata.lua $(images) $(styles) terra $(dbg-flags) $< Index: parsav.t ================================================================== --- parsav.t +++ parsav.t @@ -240,11 +240,11 @@ elseif #tbl >= 2^16 then ty = uint32 elseif #tbl >= 2^8 then ty = uint16 end local o = { t = ty, members = tbl } local strings = {} for i, name in ipairs(tbl) do - o[name] = i - 1 + o[name] = `[ty]([i - 1]) strings[i] = `[lib.mem.ref(int8)]{ptr=[name], ct=[#name]} end o._str = terra(val: ty) var l = array([strings]) return l[val] @@ -435,10 +435,11 @@ 'render:compose'; 'render:tweet'; 'render:tweet-page'; 'render:user-page'; 'render:timeline'; + 'render:notices'; 'render:docpage'; 'render:conf:profile'; 'render:conf:sec'; ADDED render/notices.t Index: render/notices.t ================================================================== --- render/notices.t +++ render/notices.t @@ -0,0 +1,80 @@ +-- vim: ft=terra +local pstr = lib.mem.ptr(int8) +local P = lib.str.plit +local terra cs(s: rawstring) + return pstr { ptr = s, ct = lib.str.sz(s) } +end + +local terra +render_notices( + co: &lib.srv.convo +): {} + var notes = co.srv:actor_notice_enum(co.who.id) + + if notes.ct == 0 then + co:complain(200,'no news is good news',"you don't have any notices to review") + return + end + defer notes:free() + + var pg: lib.str.acc pg:init(512) defer pg:free() + var pflink: lib.str.acc pflink:init(64) + var body: lib.str.acc body:init(256) + var latest: lib.store.timepoint = 0 + for i=0,notes.ct do + if notes(i).when > latest then latest = notes(i).when end + var who = co.srv:actor_fetch_uid(notes(i).who) defer who:free() + if not who then lib.bail('schema integrity violation: nonexistent actor referenced in notification, this is almost certainly an SQL error or bug in the backend implementation') end + pflink:cue(lib.str.sz(who(0).xid) + 4) + if who(0).origin == 0 then pflink:lpush('/') + else pflink:lpush('/@') end + pflink:push(who(0).xid,0) + var n = data.view.notice { + avatar = cs(who(0).avatar); + nym = lib.render.nym(who.ptr,0,nil,true); + pflink = pstr{ptr = pflink.buf; ct = pflink.sz}; + } + var notweet = true + var what = co.srv:post_fetch(notes(i).what) defer what:free() + switch notes(i).kind do + case lib.store.noticetype.rt then + n.kind = P'rt' + n.act = P'retweeted your post' + end + case lib.store.noticetype.like then + n.kind = P'like' + n.act = P'likes your post' + end + case lib.store.noticetype.reply then + n.kind = P'reply' + n.act = P'replied to your post' + notweet = false + end + else goto skip end + do var idbuf: int8[lib.math.shorthand.maxlen] + var idlen = lib.math.shorthand.gen(notes(i).what, idbuf) + var b = lib.smackdown.html(pstr {ptr=what(0).body,ct=0},true) defer b:free() + body:lpush(' '):ppush(b):lpush('') + end + if not notweet then + var reply = co.srv:post_fetch(notes(i).reply) + lib.render.tweet(co,reply.ptr,&body) + reply:free() + end + n.ref = pstr {ptr = body.buf, ct = body.sz} + + n:append(&pg) + ::skip:: n.nym:free() + pflink:reset() + body:reset() + end + pflink:free() + pg:lpush('
') + co:livepage([lib.srv.convo.page] { + title = P'notices', class = P'notices'; + body = pstr {ptr = pg.buf, ct = pg.sz}; + cache = false; + }, latest) +end + +return render_notices Index: render/profile.t ================================================================== --- render/profile.t +++ render/profile.t @@ -36,11 +36,11 @@ var sn_follows = cs(lib.math.decstr_friendly(stats.follows, sn_posts.ptr - 1)) var sn_followers = cs(lib.math.decstr_friendly(stats.followers, sn_follows.ptr - 1)) var sn_mutuals = cs(lib.math.decstr_friendly(stats.mutuals, sn_followers.ptr - 1)) var bio = lib.str.plit 'tall, dark, and mysterious' if actor.bio ~= nil then - bio = lib.smackdown.html(cs(actor.bio)) + bio = lib.smackdown.html(cs(actor.bio),false) end var fullname = lib.render.nym(actor,0,nil,false) defer fullname:free() var comments: lib.str.acc comments:init(64) if co.srv.cfg.master == actor.id then Index: render/tweet.t ================================================================== --- render/tweet.t +++ render/tweet.t @@ -39,11 +39,11 @@ avistr:compose('/avi/',author.handle) end var timestr: int8[26] lib.osclock.ctime_r(&p.posted, ×tr[0]) for i=0,26 do if timestr[i] == @'\n' then timestr[i] = 0 break end end -- 🙄 - var bhtml = lib.smackdown.html([lib.mem.ptr(int8)] {ptr=p.body,ct=0}) + var bhtml = lib.smackdown.html([lib.mem.ptr(int8)] {ptr=p.body,ct=0},false) defer bhtml:free() var idbuf: int8[lib.math.shorthand.maxlen] var idlen = lib.math.shorthand.gen(p.id, idbuf) var permalink: lib.str.acc permalink:compose('/post/',{idbuf,idlen}) @@ -56,11 +56,26 @@ avatar = cs(author.avatar); acctlink = cs(author.xid); permalink = permalink:finalize(); attr = pstr{'',0}; stats = pstr{'',0}; + extra = pstr{'',0}; } + if p.isreply then + var parent = co.srv:post_fetch(p.parent) defer parent:free() + if not parent then + lib.bail('schema integrity violation - could not match post to parent') + end + var pauth = co.srv:actor_fetch_uid(parent(0).author) defer pauth:free() + var pidbuf: int8[lib.math.shorthand.maxlen] + var pidlen = lib.math.shorthand.gen(p.parent, pidbuf) + var pa: lib.str.acc pa:init(128) + pa:lpush('in reply to ') + lib.render.nym(pauth.ptr,0,&pa,true) + pa:lpush('') + tpl.extra = pa:finalize() + end if p.rts + p.likes > 0 then var s: lib.str.acc s:init(128) s:lpush('
') if p.rts > 0 then s:lpush('
' ):dpush(p.rts ):lpush('
') end if p.likes > 0 then s:lpush('
'):dpush(p.likes):lpush('
') end @@ -87,20 +102,23 @@ if acc ~= nil then if retweeter ~= nil then push_promo_header(co, acc, retweeter, p.rtact) end tpl:append(acc) if retweeter ~= nil then acc:lpush('
') end if p.rts + p.likes > 0 then tpl.stats:free() end + if tpl.extra.ct > 0 then tpl.extra:free() end return [lib.mem.ptr(int8)]{ptr=nil,ct=0} end if retweeter ~= nil then var rta: lib.str.acc rta:init(512) push_promo_header(co, &rta, retweeter, p.rtact) tpl:append(&rta) rta:lpush('') + if tpl.extra.ct > 0 then tpl.extra:free() end return rta:finalize() else var txt = tpl:tostr() + if tpl.extra.ct > 0 then tpl.extra:free() end if p.rts + p.likes > 0 then tpl.stats:free() end return txt end end return render_tweet Index: route.t ================================================================== --- route.t +++ route.t @@ -166,10 +166,11 @@ if subj == nil then subj = '' end var p = lib.store.post { author = co.who.id, acl = acl; body = text, subject = subj; + parent = 0; } var newid = p:publish(co.srv) var idbuf: int8[lib.math.shorthand.maxlen] var idlen = lib.math.shorthand.gen(newid, idbuf) @@ -382,10 +383,26 @@ lib.render.conf(co,path,msg) do return end ::nopriv:: co:complain(403,'insufficient privileges','you do not have the necessary powers to perform this action') end + +terra http.user_notices(co: &lib.srv.convo, meth: method.t) + if meth == method.post then + var act = co:ppostv('act') + if act:cmp(lib.str.plit'clear') then + co.srv:actor_conf_int_set(co.who.id, 'notice-clear-time', lib.osclock.time(nil)) + co:reroute('/') + return + else goto badop end + end + + lib.render.notices(co) + do return end + + ::badop :: do co:complain(405, 'invalid operation', 'the operation you have attempted on this post is not meaningful') return end +end do local branches = quote end local filename, flen = symbol(&int8), symbol(intptr) local page = symbol(lib.http.page) local send = label() @@ -444,19 +461,22 @@ elseif uri.ptr[1] == @'s' and uri.ptr[2] == @'/' and uri.ct > 3 then if not meth_get(meth) then goto wrongmeth end if not http.static_content(co, uri.ptr + 3, uri.ct - 3) then goto notfound end elseif lib.str.ncmp('/avi/', uri.ptr, 5) == 0 then http.local_avatar(co, [lib.mem.ptr(int8)] {ptr = uri.ptr + 5, ct = uri.ct - 5}) - elseif lib.str.ncmp('/compose', uri.ptr, lib.math.biggest(uri.ct,8)) == 0 then + elseif uri:cmp(lib.str.plit '/notices') then + if co.aid == 0 then co:reroute('/login') return end + http.user_notices(co,meth) + elseif uri:cmp(lib.str.plit '/compose') then if co.aid == 0 then co:reroute('/login') return end http.post_compose(co,meth) - elseif lib.str.ncmp('/login', uri.ptr, lib.math.biggest(uri.ct,6)) == 0 then + elseif uri:cmp(lib.str.plit '/login') then if co.aid == 0 then http.login_form(co, meth) else co:reroute('/') end - elseif lib.str.ncmp('/logout', uri.ptr, lib.math.biggest(uri.ct,7)) == 0 then + elseif uri:cmp(lib.str.plit '/logout') then if co.aid == 0 then goto notfound else co:reroute_cookie('/','auth=; Path=/') end else -- hierarchical routes Index: smackdown.t ================================================================== --- smackdown.t +++ smackdown.t @@ -54,11 +54,11 @@ if sl == nil then return nil else sl = sl + nc end if sl >= l+max or isws(@sl) then return sl-nc end return nil end -terra m.html(input: pstr) +terra m.html(input: pstr, firstline: bool) if input.ct == 0 then input.ct = lib.str.sz(input.ptr) end var md = lib.html.sanitize(input,false) var styled: lib.str.acc styled:init(md.ct) ADDED static/reply.svg Index: static/reply.svg ================================================================== --- static/reply.svg +++ static/reply.svg @@ -0,0 +1,350 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + Index: static/style.scss ================================================================== --- static/style.scss +++ static/style.scss @@ -226,12 +226,14 @@ } > a[href].bell { content: url(/s/bell.svg); height: 2em; padding: 0.125in 0.10in; + filter: drop-shadow(1px 1px 3px tone(-5%)); &:hover { - filter: drop-shadow(0 0 10px tone(-5%)); + filter: drop-shadow(1px 1px 3px tone(-5%)) + drop-shadow(0 0 10px tone(-5%)); } } } } } @@ -382,16 +384,16 @@ text-align: center; padding: 0.3in; } > .msg:first-child { padding-top: 0; } > .user { - width: min-content; margin: auto; + width: max-content; margin: auto; background: tone(-20%,-0.3); border: 1px solid black; color: tone(-50%); padding: 0.1in; - > img { width: 1in; height: 1in; border: 1px solid black; } + > img { display: block; width: 1in; height: 1in; margin: auto; border: 1px solid black; } > .name { @extend %serif; text-align: center; font-size: 130%; font-weight: bold; margin-top: 0.08in; } } >form { display: grid; grid-template-columns: 1fr 1fr; @@ -537,11 +539,11 @@ color: tone(25%); } > a[href].permalink { display: block; grid-column: 4/5; grid-row: 2/3; - font-size: 80%; + font-size: 90%; text-align: right; padding: 0.1in; padding-right: 0.15in; font-style: italic; background: linear-gradient(to left, tone(-55%,-0.5), transparent); @@ -557,11 +559,11 @@ background-repeat: no-repeat; min-width: 0.3em; &:focus { outline: none; opacity: 0.9 !important; - filter: brightness(1.7) drop-shadow(0 0 15px rgb(255,150,200)); + filter: drop-shadow(0 0 7px tone(-10%)); } &:empty { transition: 0.3s; opacity: 0.0001; // qutebrowser won't show hints if opacity=0 :( &:hover, &:focus { opacity: 0.6 !important; } @@ -951,5 +953,48 @@ } > .post { grid-row: 2/3; grid-column: 1/3; } } + +body.notices { + form { text-align: center; } + div.notice { + padding: 0.15in; + background: linear-gradient(to bottom, tone(10%, -0.9), transparent); + border: 1px solid tone(-60%); + & + div.notice { border-top: none; } + &.rt, &.like, &.reply { &::before { + display: inline-block; + width: 1em; height: 1em; + margin-right: 1ex; + background-size: contain; + vertical-align: bottom; + content: ""; // 🙄 + }} + &.rt::before { background-image: url(/s/retweet.webp); } + &.like::before { background-image: url(/s/heart.webp); } + &.reply::before { background-image: url(/s/reply.webp); } + > .action { + display: inline-block; + color: tone(5%); + > .id { + display: inline-block; + > img { + width: 1em; height: 1em; + vertical-align: middle; + margin-right: 0.5ex; + } + } + } + > a[href].quote { + &::before { content: "“"; } + &::after { content: "”"; } + font-style: italic; color: tone(20%); + text-decoration: none; + } + > article.post { + margin: 0.1in 0.2in; + margin-left: 0.4in; + } + } +} Index: store.t ================================================================== --- store.t +++ store.t @@ -9,10 +9,11 @@ 'none', 'mention', 'reply', 'like', 'rt', 'react' }; relation = lib.set { 'follow', + 'subscribe', -- get a notification for every post 'mute', -- posts will be completely hidden at all times 'block', -- no interactions will be permitted, but posts will remain visible 'silence', -- messages will not be accepted 'collapse', -- posts will be collapsed by default 'disemvowel', -- posts will be ritually humiliated, but shown @@ -222,10 +223,11 @@ accent: int16 rts: uint32 likes: uint32 rtdby: uint64 -- 0 if not rt rtact: uint64 -- 0 if not rt, id of rt action otherwise + isreply: bool source: &m.source -- save :: bool -> {} (defined in acl.t due to dep. hell) } @@ -253,11 +255,11 @@ when: uint64 who: uint64 what: uint64 union { reply: uint64 - reaction: int8[16] + reaction: int8[32] -- are you shitting me, unichode } } struct m.inet { pv: uint8 -- 0 = null, 4 = ipv4, 6 = ipv6 @@ -391,11 +393,11 @@ actor_auth_register_uid: {&m.source, uint64, uint64} -> {} -- notifies the backend module of the UID that has been assigned for -- an authentication ID -- aid: uint64 -- uid: uint64 - actor_notice_enum: {&m.source, uint64} -> lib.mem.lstptr(m.notice) + actor_notice_enum: {&m.source, uint64} -> lib.mem.ptr(m.notice) actor_rel_create: {&m.source, uint16, uint64, uint64} -> {} actor_rel_destroy: {&m.source, uint16, uint64, uint64} -> {} actor_rel_calc: {&m.source, uint64, uint64} -> m.relationship auth_enum_uid: {&m.source, uint64} -> lib.mem.lstptr(m.auth) Index: str.t ================================================================== --- str.t +++ str.t @@ -162,10 +162,14 @@ if self.space - self.sz < self.run then self.space = self.sz + self.run self.buf = [rawstring](lib.mem.heapr_raw(self.buf, self.space)) end end + +terra m.acc:reset() -- semantic convenience function + self.sz = 0 +end terra m.acc:push(str: rawstring, len: intptr) --var llen = len if str == nil then return self end --if str[len - 1] == 0xA then llen = llen - 1 end -- don't display newlines in debug output Index: view/load.lua ================================================================== --- view/load.lua +++ view/load.lua @@ -7,10 +7,11 @@ 'docskel'; 'confirm'; 'tweet'; 'profile'; 'compose'; + 'notice'; 'login-username'; 'login-challenge'; 'conf'; ADDED view/notice.tpl Index: view/notice.tpl ================================================================== --- view/notice.tpl +++ view/notice.tpl @@ -0,0 +1,8 @@ +
+
+
+ + @nym +
@act +
@ref +
Index: view/tweet.tpl ================================================================== --- view/tweet.tpl +++ view/tweet.tpl @@ -1,10 +1,10 @@
@nym -
+
@extra
@!subject
@text
@stats