563 lines
15 KiB
JavaScript
563 lines
15 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},
|
|
unread: {type: Array},
|
|
tab: {type: String},
|
|
broadcasts: {type: Array},
|
|
connections: {type: Array},
|
|
loading: {type: Boolean},
|
|
loaded: {type: Boolean},
|
|
following: {type: Array},
|
|
users: {type: Object},
|
|
ids: {type: Array},
|
|
channels: {type: Array},
|
|
channels_unread: {type: Object},
|
|
channels_latest: {type: Object},
|
|
};
|
|
}
|
|
|
|
static styles = styles;
|
|
|
|
constructor() {
|
|
super();
|
|
let self = this;
|
|
this.hash = '#';
|
|
this.unread = [];
|
|
this.tab = 'news';
|
|
this.broadcasts = [];
|
|
this.connections = [];
|
|
this.following = [];
|
|
this.users = {};
|
|
this.loaded = false;
|
|
this.channels = [];
|
|
this.channels_unread = {};
|
|
this.channels_latest = {};
|
|
this.loading_channels_latest = 0;
|
|
this.loading_channels_latest_scheduled = 0;
|
|
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.whoami = whoami ?? (ids.length ? ids[0] : undefined);
|
|
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 = ['', '@'].concat(this.channels);
|
|
let index = channel_names.indexOf(this.hash.substring(1));
|
|
if (index != -1) {
|
|
index += delta;
|
|
this.set_hash(
|
|
'#' +
|
|
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(ids, users) {
|
|
const k_cache_version = 1;
|
|
let cache = await tfrpc.rpc.databaseGet('about');
|
|
cache = cache ? JSON.parse(cache) : {};
|
|
if (cache.version !== k_cache_version) {
|
|
cache = {
|
|
version: k_cache_version,
|
|
about: {},
|
|
last_row_id: 0,
|
|
};
|
|
}
|
|
let max_row_id = (
|
|
await tfrpc.rpc.query(
|
|
`
|
|
SELECT MAX(rowid) AS max_row_id FROM messages
|
|
`,
|
|
[]
|
|
)
|
|
)[0].max_row_id;
|
|
for (let id of Object.keys(cache.about)) {
|
|
if (ids.indexOf(id) == -1) {
|
|
delete cache.about[id];
|
|
}
|
|
}
|
|
|
|
let abouts = await tfrpc.rpc.query(
|
|
`
|
|
SELECT
|
|
messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
|
|
FROM
|
|
messages,
|
|
json_each(?1) AS following
|
|
WHERE
|
|
messages.author = following.value AND
|
|
messages.rowid > ?3 AND
|
|
messages.rowid <= ?4 AND
|
|
json_extract(messages.content, '$.type') = 'about'
|
|
UNION
|
|
SELECT
|
|
messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
|
|
FROM
|
|
messages,
|
|
json_each(?2) AS following
|
|
WHERE
|
|
messages.author = following.value AND
|
|
messages.rowid <= ?4 AND
|
|
json_extract(messages.content, '$.type') = 'about'
|
|
ORDER BY messages.author, messages.sequence
|
|
`,
|
|
[
|
|
JSON.stringify(ids.filter((id) => cache.about[id])),
|
|
JSON.stringify(ids.filter((id) => !cache.about[id])),
|
|
cache.last_row_id,
|
|
max_row_id,
|
|
]
|
|
);
|
|
for (let about of abouts) {
|
|
let content = JSON.parse(about.content);
|
|
if (content.about === about.author) {
|
|
delete content.type;
|
|
delete content.about;
|
|
cache.about[about.author] = Object.assign(
|
|
cache.about[about.author] || {},
|
|
content
|
|
);
|
|
}
|
|
}
|
|
cache.last_row_id = max_row_id;
|
|
await tfrpc.rpc.databaseSet('about', JSON.stringify(cache));
|
|
users = users || {};
|
|
for (let id of Object.keys(cache.about)) {
|
|
users[id] = Object.assign(users[id] || {}, cache.about[id]);
|
|
}
|
|
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) {
|
|
let content = JSON.parse(message.content);
|
|
if (content?.type == 'channel') {
|
|
this.load_channels();
|
|
}
|
|
}
|
|
}
|
|
if (messages && messages.length) {
|
|
this.unread = [...this.unread, ...messages];
|
|
this.unread = this.unread.slice(this.unread.length - 1024);
|
|
}
|
|
this.schedule_load_channels_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 create_identity() {
|
|
if (confirm('Are you sure you want to create a new identity?')) {
|
|
await tfrpc.rpc.createIdentity();
|
|
this.ids = (await tfrpc.rpc.getIdentities()) || [];
|
|
if (this.ids && !this.whoami) {
|
|
this.whoami = this.ids[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
async get_latest_private(following) {
|
|
let latest = (
|
|
await tfrpc.rpc.query('SELECT MAX(rowid) AS latest FROM messages')
|
|
)[0].latest;
|
|
const k_chunk_count = 256;
|
|
while (latest - k_chunk_count >= 0) {
|
|
let messages = await tfrpc.rpc.query(
|
|
`
|
|
SELECT messages.rowid, messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
|
|
FROM messages
|
|
JOIN json_each(?1) AS following ON messages.author = following.value
|
|
WHERE
|
|
messages.rowid > ?2 AND
|
|
messages.rowid <= ?3 AND
|
|
json(messages.content) LIKE '"%'
|
|
ORDER BY sequence DESC
|
|
`,
|
|
[JSON.stringify(following), latest - k_chunk_count, latest]
|
|
);
|
|
messages = (await this.decrypt(messages)).filter((x) => x.decrypted);
|
|
if (messages.length) {
|
|
return Math.max(...messages.map((x) => x.rowid));
|
|
}
|
|
latest -= k_chunk_count;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
async load_channels_latest(following) {
|
|
this.loading_channels_latest++;
|
|
try {
|
|
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
|
|
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
|
|
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
|
|
`,
|
|
[
|
|
JSON.stringify(this.channels),
|
|
JSON.stringify(following),
|
|
'"' + this.whoami.replace('"', '""') + '"',
|
|
]
|
|
);
|
|
this.channels_latest = Object.fromEntries(
|
|
channels.map((x) => [x.channel, x.rowid])
|
|
);
|
|
console.log('latest', this.channels_latest);
|
|
console.log('unread', this.channels_unread);
|
|
console.log('channels took', (new Date() - start_time) / 1000.0);
|
|
let self = this;
|
|
latest_private.then(function (latest) {
|
|
self.channels_latest = Object.assign({}, self.channels_latest, {
|
|
'🔐': latest,
|
|
});
|
|
console.log('private took', (new Date() - start_time) / 1000.0);
|
|
});
|
|
} finally {
|
|
this.loading_channels_latest--;
|
|
}
|
|
}
|
|
|
|
_schedule_load_channels_latest_timer() {
|
|
--this.loading_channels_latest_scheduled;
|
|
this.schedule_load_channels_latest();
|
|
}
|
|
|
|
schedule_load_channels_latest() {
|
|
if (!this.loading_channels_latest) {
|
|
this.load_channels_latest(this.following);
|
|
} else if (!this.loading_channels_latest_scheduled) {
|
|
this.loading_channels_latest_scheduled++;
|
|
setTimeout(this._schedule_load_channels_latest_timer, 5000);
|
|
}
|
|
}
|
|
|
|
async load() {
|
|
let whoami = this.whoami;
|
|
let following = await tfrpc.rpc.following([whoami], 2);
|
|
let users = {};
|
|
let by_count = [];
|
|
for (let [id, v] of Object.entries(following)) {
|
|
users[id] = {
|
|
following: v.of,
|
|
blocking: v.ob,
|
|
followed: v.if,
|
|
blocked: v.ib,
|
|
};
|
|
by_count.push({count: v.of, id: id});
|
|
}
|
|
let channels_latest = this.load_channels_latest(Object.keys(following));
|
|
this.channels_unread = JSON.parse(
|
|
(await tfrpc.rpc.databaseGet('unread')) ?? '{}'
|
|
);
|
|
let start_time = new Date();
|
|
users = await this.fetch_about(Object.keys(following).sort(), users);
|
|
console.log(
|
|
'about took',
|
|
(new Date() - start_time) / 1000.0,
|
|
'seconds for',
|
|
Object.keys(users).length,
|
|
'users'
|
|
);
|
|
start_time = new Date();
|
|
await channels_latest;
|
|
this.following = Object.keys(following);
|
|
this.users = users;
|
|
console.log(`load finished ${whoami} => ${this.whoami}`);
|
|
this.whoami = whoami;
|
|
this.loaded = whoami;
|
|
}
|
|
|
|
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}
|
|
.unread=${this.unread}
|
|
@refresh=${() => (this.unread = [])}
|
|
?loading=${this.loading}
|
|
.channels=${this.channels}
|
|
.channels_latest=${this.channels_latest}
|
|
.channels_unread=${this.channels_unread}
|
|
@channelsetunread=${this.channel_set_unread}
|
|
></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: sticky; top: 0; z-index: 10"
|
|
>
|
|
<button
|
|
class="w3-bar-item w3-button w3-circle w3-ripple"
|
|
@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.loaded
|
|
? this.loading
|
|
? html`<div
|
|
class="w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge"
|
|
>
|
|
Loading...
|
|
</div>
|
|
${this.render_tab()}`
|
|
: html`<div>Select or create an identity.</div>`
|
|
: this.render_tab();
|
|
return html`
|
|
<div
|
|
style="width: 100vw; min-height: 100vh; height: 100%"
|
|
class="w3-theme-dark"
|
|
>
|
|
${tabs} ${contents}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define('tf-app', TfElement);
|