Files
.gitea
apps
admin
api
apps
blog
db
follow
identity
intro
issues
journal
room
sneaker
ssb
app.js
commonmark-hashtag.js
commonmark.min.js
emojis.js
emojis.json
filesaver.min.js
filesaver.min.js.map
index.html
lit-all.min.js
lit-all.min.js.map
script.js
tf-app.js
tf-compose.js
tf-message.js
tf-news.js
tf-profile.js
tf-reactions-modal.js
tf-styles.js
tf-tab-connections.js
tf-tab-news-feed.js
tf-tab-news.js
tf-tab-query.js
tf-tab-search.js
tf-tag.js
tf-user.js
tf-utils.js
tribute.css
tribute.esm.js
storage
test
todo
web
welcome
wiki
admin.json
api.json
apps.json
blog.json
db.json
follow.json
identity.json
intro.json
issues.json
journal.json
room.json
sneaker.json
ssb.json
storage.json
test.json
todo.json
web.json
welcome.json
wiki.json
core
deps
docs
metadata
src
tools
.clang-format
.dockerignore
.git-blame-ignore-revs
.gitignore
.gitmodules
.prettierignore
.prettierrc.yaml
CONTRIBUTING.md
Dockerfile
Doxyfile
GNUmakefile
LICENSE
README.md
default.nix
flake.lock
flake.nix
package-lock.json
package.json
tildefriends/apps/ssb/tf-app.js

692 lines
18 KiB
JavaScript

import {LitElement, html, css, guard, until} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js';
class TfElement extends LitElement {
static get properties() {
return {
whoami: {type: String},
hash: {type: String},
tab: {type: String},
broadcasts: {type: Array},
connections: {type: Array},
loading: {type: Boolean},
loading_about: {type: Number},
loaded: {type: Boolean},
following: {type: Array},
users: {type: Object},
ids: {type: Array},
channels: {type: Array},
channels_unread: {type: Object},
channels_latest: {type: Object},
guest: {type: Boolean},
url: {type: String},
private_messages: {type: Array},
recent_reactions: {type: Array},
};
}
static styles = styles;
constructor() {
super();
let self = this;
this.hash = '#';
this.tab = 'news';
this.broadcasts = [];
this.connections = [];
this.following = [];
this.users = {};
this.loaded = false;
this.loading_about = 0;
this.channels = [];
this.channels_unread = {};
this.channels_latest = {};
this.loading_latest = 0;
this.loading_latest_scheduled = 0;
this.recent_reactions = [];
tfrpc.rpc.getBroadcasts().then((b) => {
self.broadcasts = b || [];
});
tfrpc.rpc.getConnections().then((c) => {
self.connections = c || [];
});
tfrpc.rpc.getHash().then((hash) => self.set_hash(hash));
tfrpc.register(function hashChanged(hash) {
self.set_hash(hash);
});
tfrpc.register(async function notifyNewMessage(id) {
await self.fetch_new_message(id);
});
tfrpc.register(function set(name, value) {
if (name === 'broadcasts') {
self.broadcasts = value;
} else if (name === 'connections') {
self.connections = value;
} else if (name === 'identity') {
self.whoami = value;
}
});
this.initial_load();
}
async initial_load() {
let whoami = await tfrpc.rpc.getActiveIdentity();
let ids = (await tfrpc.rpc.getIdentities()) || [];
this.url = await tfrpc.rpc.url();
this.whoami = whoami ?? (ids.length ? ids[0] : undefined);
this.guest = !this.whoami?.length;
this.ids = ids;
await this.load_channels();
}
async load_channels() {
let channels = await tfrpc.rpc.query(
`
SELECT
content ->> 'channel' AS channel,
content ->> 'subscribed' AS subscribed
FROM
messages
WHERE
author = ? AND
content ->> 'type' = 'channel'
ORDER BY sequence
`,
[this.whoami]
);
let channel_map = {};
for (let row of channels) {
if (row.subscribed) {
channel_map[row.channel] = true;
} else {
delete channel_map[row.channel];
}
}
this.channels = Object.keys(channel_map).sort();
}
connectedCallback() {
super.connectedCallback();
this._keydown = this.keydown.bind(this);
window.addEventListener('keydown', this._keydown);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('keydown', this._keydown);
}
keydown(event) {
if (event.altKey && event.key == 'ArrowUp') {
this.next_channel(-1);
event.preventDefault();
} else if (event.altKey && event.key == 'ArrowDown') {
this.next_channel(1);
event.preventDefault();
}
}
next_channel(delta) {
let channel_names = ['', '@', '🔐', ...this.channels.map((x) => '#' + x)];
let index = channel_names.indexOf(this.hash.substring(1));
index = index != -1 ? index + delta : 0;
tfrpc.rpc.setHash(
'#' +
encodeURIComponent(
channel_names[(index + channel_names.length) % channel_names.length]
)
);
}
set_hash(hash) {
this.hash = decodeURIComponent(hash || '#');
if (this.hash.startsWith('#q=')) {
this.tab = 'search';
} else if (this.hash === '#connections') {
this.tab = 'connections';
} else if (this.hash.startsWith('#sql=')) {
this.tab = 'query';
} else {
this.tab = 'news';
}
}
async fetch_about(following, users) {
this.loading_about++;
let ids = Object.keys(following).sort();
const k_cache_version = 3;
let cache = await tfrpc.rpc.databaseGet('about');
let original_cache = cache;
cache = cache ? JSON.parse(cache) : {};
if (cache.version !== k_cache_version) {
cache = {
version: k_cache_version,
about: {},
};
}
let ids_out_of_date = ids.filter(
(x) =>
(users[x]?.seq && !cache.about[x]?.seq) ||
(users[x]?.seq && users[x]?.seq > cache.about[x].seq)
);
for (let id of Object.keys(cache.about)) {
if (ids.indexOf(id) == -1) {
delete cache.about[id];
} else {
users[id] = Object.assign(cache.about[id], users[id] || {});
}
}
console.log(
'loading about for',
ids.length,
'accounts',
ids_out_of_date.length,
'out of date'
);
if (ids_out_of_date.length) {
try {
let rows = await tfrpc.rpc.query(
`
SELECT all_abouts.author, json(json_group_object(all_abouts.key, all_abouts.value)) AS about
FROM (
SELECT
messages.author,
fields.key,
RANK() OVER (PARTITION BY messages.author, fields.key ORDER BY messages.sequence DESC) AS rank,
fields.value
FROM messages JOIN json_each(messages.content) AS fields
WHERE
messages.content ->> '$.type' = 'about' AND
messages.content ->> '$.about' = messages.author AND
NOT fields.key IN ('about', 'type')) all_abouts
JOIN json_each(?) AS following ON all_abouts.author = following.value
WHERE rank = 1
GROUP BY all_abouts.author
`,
[JSON.stringify(ids_out_of_date)]
);
users = users || {};
for (let row of rows) {
users[row.author] = Object.assign(
users[row.author] || {},
JSON.parse(row.about)
);
cache.about[row.author] = Object.assign(
{seq: users[row.author].seq},
JSON.parse(row.about)
);
}
} catch (e) {
console.log(e);
}
}
for (let id of ids_out_of_date) {
if (!cache.about[id]?.seq) {
cache.about[id] = Object.assign(cache.about[id] ?? {}, {
seq: users[id]?.seq ?? 0,
});
}
}
this.loading_about--;
let new_cache = JSON.stringify(cache);
if (new_cache != original_cache) {
let start_time = new Date();
tfrpc.rpc.databaseSet('about', new_cache).then(function () {
console.log('saving about took', (new Date() - start_time) / 1000);
});
}
return Object.assign({}, users);
}
async fetch_new_message(id) {
let messages = await tfrpc.rpc.query(
`
SELECT messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
FROM messages
JOIN json_each(?) AS following ON messages.author = following.value
WHERE messages.id = ?
`,
[JSON.stringify(this.following), id]
);
for (let message of messages) {
if (
message.author == this.whoami &&
JSON.parse(message.content)?.type == 'channel'
) {
this.load_channels();
}
}
this.schedule_load_latest();
}
async _handle_whoami_changed(event) {
let old_id = this.whoami;
let new_id = event.srcElement.selected;
console.log('received', new_id);
if (this.whoami !== new_id) {
console.log(event);
this.whoami = new_id;
console.log(`whoami ${old_id} => ${new_id}`);
await tfrpc.rpc.localStorageSet('whoami', new_id);
}
}
async get_latest_private(following) {
const k_version = 1;
// { "version": 1, "range": [1234, 5678], messages: [ "%1.sha256", "%2.sha256", ... ], latest: rowid }
let cache = JSON.parse(
(await tfrpc.rpc.databaseGet(`private:${this.whoami}`)) ?? '{}'
);
if (cache.version !== k_version) {
cache = {
version: k_version,
messages: [],
range: [],
};
}
let latest = (
await tfrpc.rpc.query('SELECT MAX(rowid) AS latest FROM messages')
)[0].latest;
let ranges = [];
const k_chunk_size = 512;
if (cache.range.length) {
for (let i = cache.range[1]; i < latest; i += k_chunk_size) {
ranges.push([i, Math.min(i + k_chunk_size, latest), true]);
}
for (let i = cache.range[0]; i >= 0; i -= k_chunk_size) {
ranges.push([
Math.max(i - k_chunk_size, 0),
Math.min(cache.range[0], i + k_chunk_size),
false,
]);
}
} else {
for (let i = 0; i < latest; i += k_chunk_size) {
ranges.push([i, Math.min(i + k_chunk_size, latest), true]);
}
}
for (let range of ranges) {
let messages = await tfrpc.rpc.query(
`
SELECT messages.rowid, messages.id, json(content) AS content
FROM messages
WHERE
messages.rowid > ?1 AND
messages.rowid <= ?2 AND
json(messages.content) LIKE '"%'
ORDER BY sequence DESC
`,
[range[0], range[1]]
);
messages = (await this.decrypt(messages)).filter((x) => x.decrypted);
if (messages.length) {
cache.latest = Math.max(
cache.latest ?? 0,
...messages.map((x) => x.rowid)
);
if (range[2]) {
cache.messages = [...cache.messages, ...messages.map((x) => x.id)];
} else {
cache.messages = [...messages.map((x) => x.id), ...cache.messages];
}
}
cache.range[0] = Math.min(cache.range[0] ?? range[0], range[0]);
cache.range[1] = Math.max(cache.range[1] ?? range[1], range[1]);
await tfrpc.rpc.databaseSet(
`private:${this.whoami}`,
JSON.stringify(cache)
);
}
return [cache.latest, cache.messages];
}
async load_channels_latest(following) {
let start_time = new Date();
let latest_private = this.get_latest_private(following);
let channels = await tfrpc.rpc.query(
`
SELECT channels.value AS channel, MAX(messages.rowid) AS rowid FROM messages
JOIN json_each(?1) AS channels ON messages.content ->> 'channel' = channels.value
JOIN json_each(?2) AS following ON messages.author = following.value
WHERE
messages.content ->> 'type' = 'post' AND
messages.content ->> 'root' IS NULL AND
messages.author != ?4
GROUP by channel
UNION
SELECT '' AS channel, MAX(messages.rowid) AS rowid FROM messages
JOIN json_each(?2) AS following ON messages.author = following.value
WHERE
messages.content ->> 'type' = 'post' AND
messages.content ->> 'root' IS NULL AND
messages.author != ?4
UNION
SELECT '@' AS channel, MAX(messages.rowid) AS rowid FROM messages_fts(?3)
JOIN messages ON messages.rowid = messages_fts.rowid
JOIN json_each(?2) AS following ON messages.author = following.value
WHERE messages.author != ?4
`,
[
JSON.stringify(this.channels),
JSON.stringify(following),
'"' + this.whoami.replace('"', '""') + '"',
this.whoami,
]
);
this.channels_latest = Object.fromEntries(
channels.map((x) => [x.channel, x.rowid])
);
console.log('channels took', (new Date() - start_time) / 1000.0);
let self = this;
start_time = new Date();
latest_private.then(function (latest) {
self.channels_latest = Object.assign({}, self.channels_latest, {
'🔐': latest[0],
});
console.log('private took', (new Date() - start_time) / 1000.0);
self.private_messages = latest[1];
});
}
_schedule_load_latest_timer() {
--this.loading_latest_scheduled;
this.schedule_load_latest();
}
schedule_load_latest() {
if (!this.loading_latest) {
this.shadowRoot.getElementById('tf-tab-news')?.load_latest();
this.load();
} else if (!this.loading_latest_scheduled) {
this.loading_latest_scheduled++;
setTimeout(this._schedule_load_latest_timer.bind(this), 5000);
}
}
async fetch_user_info(users) {
let info = await tfrpc.rpc.query(
`
SELECT messages_stats.author, messages_stats.max_sequence, messages_stats.max_timestamp AS max_ts FROM messages_stats
JOIN json_each(?) AS following
ON messages_stats.author = following.value
`,
[JSON.stringify(Object.keys(users))]
);
for (let row of info) {
users[row.author] = Object.assign(users[row.author], {
seq: row.max_sequence,
ts: row.max_ts,
});
}
return users;
}
async load_recent_reactions() {
this.recent_reactions = (
await tfrpc.rpc.query(
`
SELECT DISTINCT content ->> '$.vote.expression' AS value
FROM messages
WHERE author = ? AND
content ->> '$.type' = 'vote'
ORDER BY timestamp DESC LIMIT 10
`,
[this.whoami]
)
).map((x) => x.value);
}
async load() {
this.loading_latest = true;
try {
let start_time = new Date();
let whoami = this.whoami;
let following = await tfrpc.rpc.following([whoami], 2);
let old_users = this.users ?? {};
let users = {};
let by_count = [];
for (let [id, v] of Object.entries(following)) {
users[id] = Object.assign(
{
following: v.of,
blocking: v.ob,
followed: v.if,
blocked: v.ib,
follow_depth: following[id]?.d,
},
old_users[id]
);
by_count.push({count: v.of, id: id});
}
let reactions = this.load_recent_reactions();
this.load_channels_latest(Object.keys(following));
this.channels_unread = JSON.parse(
(await tfrpc.rpc.databaseGet('unread')) ?? '{}'
);
this.following = Object.keys(following);
let about_start_time = new Date();
start_time = new Date();
users = await this.fetch_user_info(users);
console.log(
'user info took',
(new Date() - start_time) / 1000.0,
'seconds'
);
this.users = users;
let self = this;
this.fetch_about(following, users).then(function (result) {
self.users = result;
console.log(
'about took',
(new Date() - about_start_time) / 1000.0,
'seconds for',
Object.keys(users).length,
'users'
);
});
console.log(
`load finished ${whoami} => ${this.whoami} in ${(new Date() - start_time) / 1000}`
);
await reactions;
this.whoami = whoami;
this.loaded = whoami;
} finally {
this.loading_latest = false;
}
}
channel_set_unread(event) {
this.channels_unread[event.detail.channel ?? ''] = event.detail.unread;
this.channels_unread = Object.assign({}, this.channels_unread);
tfrpc.rpc.databaseSet('unread', JSON.stringify(this.channels_unread));
}
async decrypt(messages) {
let whoami = this.whoami;
return Promise.all(
messages.map(async function (message) {
let content;
try {
content = JSON.parse(message?.content);
} catch {}
if (typeof content === 'string') {
let decrypted;
try {
decrypted = await tfrpc.rpc.try_decrypt(whoami, content);
} catch {}
if (decrypted) {
try {
message.decrypted = JSON.parse(decrypted);
} catch {
message.decrypted = decrypted;
}
}
}
return message;
})
);
}
render_tab() {
let following = this.following;
let users = this.users;
if (this.tab === 'news') {
return html`
<tf-tab-news
id="tf-tab-news"
.following=${this.following}
whoami=${this.whoami}
.users=${this.users}
hash=${this.hash}
?loading=${this.loading || this.loading_about != 0}
.channels=${this.channels}
.channels_latest=${this.channels_latest}
.channels_unread=${this.channels_unread}
@channelsetunread=${this.channel_set_unread}
.connections=${this.connections}
.private_messages=${this.private_messages}
.recent_reactions=${this.recent_reactions}
></tf-tab-news>
`;
} else if (this.tab === 'connections') {
return html`
<tf-tab-connections
.users=${this.users}
.connections=${this.connections}
.broadcasts=${this.broadcasts}
></tf-tab-connections>
`;
} else if (this.tab === 'search') {
return html`
<tf-tab-search
.following=${this.following}
whoami=${this.whoami}
.users=${this.users}
query=${this.hash?.startsWith('#q=')
? decodeURIComponent(this.hash.substring(3))
: null}
></tf-tab-search>
`;
} else if (this.tab === 'query') {
return html`
<tf-tab-query
.following=${this.following}
whoami=${this.whoami}
.users=${this.users}
query=${this.hash?.startsWith('#sql=')
? decodeURIComponent(this.hash.substring(5))
: null}
></tf-tab-query>
`;
}
}
async set_tab(tab) {
this.tab = tab;
if (tab === 'news') {
await tfrpc.rpc.setHash('#');
} else if (tab === 'connections') {
await tfrpc.rpc.setHash('#connections');
} else if (tab === 'query') {
await tfrpc.rpc.setHash('#sql=');
}
}
refresh() {
tfrpc.rpc.sync();
}
render() {
let self = this;
if (!this.loading && this.whoami && this.loaded !== this.whoami) {
this.loading = true;
this.load().finally(function () {
self.loading = false;
});
}
const k_tabs = {
'📰': 'news',
'📡': 'connections',
'🔍': 'search',
'👩‍💻': 'query',
};
let tabs = html`
<div
class="w3-bar w3-theme-l1"
style="position: static; top: 0; z-index: 10"
>
<button
class=${'w3-bar-item w3-button w3-circle w3-ripple' +
(this.connections?.some((x) => x.flags.one_shot) ? ' w3-spin' : '')}
style="width: 1.5em; height: 1.5em; padding: 8px"
@click=${this.refresh}
>
</button>
${Object.entries(k_tabs).map(
([k, v]) => html`
<button
title=${v}
class="w3-bar-item w3-padding w3-hover-theme tab ${self.tab == v
? 'w3-theme-l2'
: 'w3-theme-l1'}"
@click=${() => self.set_tab(v)}
>
${k}
<span class=${self.tab == v ? '' : 'w3-hide-small'}
>${v.charAt(0).toUpperCase() + v.substring(1)}</span
>
</button>
`
)}
</div>
`;
let contents = this.guest
? html`<div
class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge w3-container"
>
<p>⚠️🦀 Must be logged in to Tilde Friends to scuttle here. 🦀⚠️</p>
<footer class="w3-center">
<a
class="w3-button w3-theme-d1"
href=${`/login?return=${encodeURIComponent(this.url)}`}
>Login</a
>
</footer>
</div>`
: !this.loaded || this.loading
? html`<div
class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge"
>
<span class="w3-spin" style="display: inline-block">🦀</span>
Loading...
</div>`
: this.render_tab();
return html`
<div
style="width: 100vw; min-height: 100vh; height: 100vh; display: flex; flex-direction: column"
class="w3-theme-dark"
>
<div style="flex: 0 0">${tabs}</div>
<div style="flex: 1 1; overflow: auto; contain: layout">
${contents}
</div>
</div>
`;
}
}
customElements.define('tf-app', TfElement);