This is a dupe of ssb.

git-svn-id: https://www.unprompted.com/svn/projects/tildefriends/trunk@3943 ed5197a5-7fde-0310-b194-c3ffbd925b24
This commit is contained in:
Cory McWilliams 2022-07-31 20:01:41 +00:00
parent 6c2fd6d90f
commit 5cc7641788
13 changed files with 0 additions and 13336 deletions

View File

@ -1,550 +0,0 @@
"use strict";
const k_posts_max = 20;
const k_votes_max = 20;
var g_ready = false;
var g_selected = null;
var g_blocking_cache = {};
var g_following_cache = {};
var g_following_deep_cache = {};
var g_sequence = {};
async function following(db, id) {
if (g_following_cache[id]) {
return g_following_cache[id];
}
var o = await db.get(id + ":following");
const k_version = 5;
var f = o ? JSON.parse(o) : o;
if (!f || f.version != k_version) {
f = {users: [], sequence: 0, version: k_version};
}
f.users = new Set(f.users);
await ssb.sqlStream(
"SELECT "+
" sequence, "+
" json_extract(content, '$.contact') AS contact, "+
" json_extract(content, '$.following') AS following "+
"FROM messages "+
"WHERE "+
" author = ?1 AND "+
" sequence > ?2 AND "+
" json_extract(content, '$.type') = 'contact' "+
"UNION SELECT MAX(sequence) AS sequence, NULL, NULL FROM messages WHERE author = ?1 "+
"ORDER BY sequence",
[id, f.sequence],
function(row) {
if (row.following) {
f.users.add(row.contact);
} else {
f.users.delete(row.contact);
}
if (row.sequence) {
f.sequence = row.sequence;
}
});
g_sequence[id] = f.sequence;
var as_set = f.users;
f.users = Array.from(f.users).sort();
var j = JSON.stringify(f);
if (o != j) {
await db.set(id + ":following", j);
}
f.users = as_set;
g_following_cache[id] = f.users;
return f.users;
}
async function followingDeep(db, seed_ids, depth, blocked) {
if (depth <= 0) {
return seed_ids;
}
var key = JSON.stringify([seed_ids, depth, blocked]);
if (g_following_deep_cache[key]) {
return g_following_deep_cache[key];
}
var f = await Promise.all(seed_ids.map(x => following(db, x).then(x => [...x])));
var ids = [].concat(...f);
if (blocked) {
ids = ids.filter(x => !blocked.has(x));
}
var x = await followingDeep(db, [...new Set(ids)].sort(), depth - 1, blocked);
x = [...new Set([].concat(...x, ...seed_ids))].sort();
g_following_deep_cache[key] = x;
return x;
}
async function blocking(db, id) {
if (g_blocking_cache[id]) {
return g_blocking_cache[id];
}
var o = await db.get(id + ":blocking");
const k_version = 5;
var f = o ? JSON.parse(o) : o;
if (!f || f.version != k_version) {
f = {users: [], sequence: 0, version: k_version};
}
f.users = new Set(f.users);
if (!g_sequence[id] || g_sequence[id] > f.sequence) {
await ssb.sqlStream(
"SELECT "+
" sequence, "+
" json_extract(content, '$.contact') AS contact, "+
" json_extract(content, '$.blocking') AS blocking "+
"FROM messages "+
"WHERE "+
" author = ?1 AND "+
" sequence > ?2 AND "+
" json_extract(content, '$.type') = 'contact' "+
"UNION SELECT MAX(sequence) AS sequence, NULL, NULL FROM messages WHERE author = ?1 "+
"ORDER BY sequence",
[id, f.sequence],
function(row) {
if (row.blocking) {
f.users.add(row.contact);
} else {
f.users.delete(row.contact);
}
if (row.sequence) {
f.sequence = row.sequence;
}
});
g_sequence[id] = f.sequence;
}
var as_set = f.users;
f.users = Array.from(f.users).sort();
var j = JSON.stringify(f);
if (o != j) {
await db.set(id + ":blocking", j);
}
f.users = as_set;
g_blocking_cache[id] = f.users;
return f.users;
}
async function getAbout(db, id) {
var o = await db.get(id + ":about");
const k_version = 5;
var f = o ? JSON.parse(o) : o;
if (!f || f.version != k_version) {
f = {about: {}, sequence: 0, version: k_version};
}
if (g_sequence[id] === undefined || g_sequence[id] > f.sequence) {
await ssb.sqlStream(
"SELECT "+
" sequence, "+
" content "+
"FROM messages "+
"WHERE "+
" author = ?1 AND "+
" sequence > ?2 AND "+
" json_extract(content, '$.type') = 'about' AND "+
" json_extract(content, '$.about') = ?1 "+
"UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 "+
"ORDER BY sequence",
[id, f.sequence],
function(row) {
if (row.content) {
var about = {};
try {
about = JSON.parse(row.content);
} catch {
}
delete about.about;
delete about.type;
f.about = Object.assign(f.about, about);
}
if (row.sequence) {
f.sequence = Math.max(f.sequence, row.sequence);
}
});
g_sequence[id] = f.sequence;
var j = JSON.stringify(f);
if (o != j) {
await db.set(id + ":about", j);
}
}
return f.about;
}
function fnv32a(value)
{
var result = 0x811c9dc5;
for (var i = 0; i < value.length; i++) {
result ^= value.charCodeAt(i);
result += (result << 1) + (result << 4) + (result << 7) + (result << 8) + (result << 24);
}
return result >>> 0;
}
async function getRecentPostsSingleId(db, id, limit) {
var recent = [];
await ssb.sqlStream(
"SELECT "+
" rowid, "+
" id, "+
" timestamp "+
"FROM messages "+
"WHERE "+
" author = ? AND "+
" json_extract(content, '$.type') = 'post' "+
"ORDER BY sequence DESC LIMIT ?",
[id, limit],
function(row) {
if (row.id) {
recent.push({id: row.id, timestamp: row.timestamp});
}
});
recent.sort((x, y) => y.timestamp - x.timestamp);
return recent.map(x => x.id);
}
async function getRecentPostIds(db, id, ids, limit) {
if (ids.length == 1) {
return await getRecentPostsSingleId(db, ids[0], limit);
}
const k_version = 11;
const k_batch_max = 32;
var o = await db.get(id + ':recent_posts');
var recent = [];
var f = o ? JSON.parse(o) : o;
var ids_hash = fnv32a(JSON.stringify(ids));
if (!f || f.version != k_version || f.ids_hash != ids_hash) {
f = {recent: [], rowid: 0, version: k_version, ids_hash: ids_hash};
}
var row_id_max = 0;
await ssb.sqlStream(
"SELECT MAX(rowid) as rowid FROM messages",
[],
function(row) {
row_id_max = row.rowid;
});
for (var i = 0; i < ids.length; i += k_batch_max) {
var ids_batch = ids.slice(i, Math.min(i + k_batch_max, ids.length));
await ssb.sqlStream(
"SELECT "+
" rowid, "+
" id, "+
" timestamp "+
"FROM messages "+
"WHERE "+
" rowid > ? AND "+
" rowid <= ? AND "+
" author IN (" + ids_batch.map(x => '?').join(", ") + ") "+
"ORDER BY timestamp DESC LIMIT ?",
[].concat([f.rowid, row_id_max], ids_batch, [limit]),
function(row) {
if (row.id) {
recent.push({id: row.id, timestamp: row.timestamp});
}
});
}
f.rowid = row_id_max;
f.recent = [].concat(recent, f.recent);
var have = {};
f.recent = f.recent.filter(function(x) {
if (!have[x.id]) {
have[x.id] = true;
return true;
}
});
f.recent.sort((x, y) => y.timestamp - x.timestamp);
f.recent = f.recent.slice(0, limit);
var j = JSON.stringify(f);
if (o != j) {
await db.set(id + ":recent_posts", j);
}
return f.recent.map(x => x.id);
}
async function getRelatedPostIds(db, message, ids, limit) {
const k_batch_max = 16;
var recent = [];
var row_id_max = 0;
await ssb.sqlStream(
"SELECT MAX(rowid) as rowid FROM messages",
[],
function(row) {
row_id_max = row.rowid;
});
var id = message.id;
try {
id = JSON.parse(message.content).root || id;
} catch {
}
for (var i = 0; i < ids.length; i += k_batch_max) {
var ids_batch = ids.slice(i, Math.min(i + k_batch_max, ids.length));
await ssb.sqlStream(
"SELECT "+
" rowid, "+
" id, "+
" timestamp "+
"FROM messages "+
"WHERE "+
" timestamp >= ? AND "+
" rowid <= ? AND "+
" author IN (" + ids_batch.map(x => '?').join(", ") + ") AND "+
" json_extract(content, '$.type') = 'post' AND "+
" (id = ? OR json_extract(content, '$.root') = ?) "+
"ORDER BY timestamp DESC LIMIT ?",
[].concat([message.timestamp || 0, row_id_max], ids_batch, [message.id, id, limit]),
function(row) {
if (row.id) {
recent.push({id: row.id, timestamp: row.timestamp});
}
});
}
recent.sort((x, y) => y.timestamp - x.timestamp);
recent = recent.slice(0, limit);
return recent.map(x => x.id);
}
async function getVotes(db, id) {
var o = await db.get(id + ":votes");
const k_version = 7;
var f = o ? JSON.parse(o) : o;
if (!f || f.version != k_version) {
f = {votes: [], sequence: 0, version: k_version};
}
if (g_sequence[id] === undefined || g_sequence[id] > f.sequence) {
var votes = [];
await ssb.sqlStream(
"SELECT "+
" author, "+
" id, "+
" sequence, "+
" timestamp, "+
" content "+
"FROM messages "+
"WHERE "+
" author = ? AND "+
" sequence > ? AND "+
" json_extract(content, '$.type') = 'vote' "+
"UNION SELECT NULL, NULL, MAX(sequence) AS sequence, NULL, NULL FROM messages WHERE author = ? "+
"ORDER BY sequence DESC LIMIT ?",
[id, f.sequence, id, k_votes_max],
function(row) {
if (row.id) {
votes.push(row);
}
if (row.sequence) {
f.sequence = Math.max(f.sequence, row.sequence);
}
});
g_sequence[id] = f.sequence;
f.votes = [].concat(votes, f.votes).slice(0, k_votes_max);
var j = JSON.stringify(f);
if (o != j) {
await db.set(id + ":votes", j);
}
}
return f.votes;
}
async function getPosts(db, ids) {
var posts = [];
if (ids.length) {
await ssb.sqlStream(
"SELECT rowid, * FROM messages WHERE id IN (" + ids.map(x => "?").join(", ") + ") ORDER BY timestamp DESC",
ids,
row => posts.push(row));
}
return posts;
}
async function ready() {
g_ready = true;
return refresh(g_selected);
}
ssb.addEventListener('broadcasts', async function() {
await app.postMessage({broadcasts: await ssb.getBroadcasts()});
});
core.register('onConnectionsChanged', async function() {
var connections = await ssb.connections();
await app.postMessage({connections: connections});
});
async function updateSequences(db) {
var k_batch_max = 100;
var changes = {};
var keys = Object.keys(g_sequence);
for (var i = 0; i < keys.length; i += k_batch_max) {
var ids_batch = keys.slice(i, Math.min(i + k_batch_max, keys.length));
await ssb.sqlStream(
"SELECT "+
" author, "+
" MAX(sequence) AS sequence "+
"FROM messages "+
"WHERE "+
" author IN (" + ids_batch.map(x => '?').join(", ") + ")",
ids_batch,
function(row) {
if (g_sequence[row.author] != row.sequence) {
g_sequence[row.author] = row.sequence;
changes[row.author] = row.sequence;
}
});
}
return changes;
}
async function refresh(selected) {
var timing = [];
timing.push({name: 'start', time: new Date()});
g_following_cache = {};
g_following_deep_cache = {};
await app.postMessage({clear: true});
var whoami = await ssb.whoami();
var db = await database("ssb");
g_sequence = {};
try {
g_sequence = JSON.parse(await db.get('sequence'));
} catch (e) {
}
await updateSequences(db);
timing.push({name: 'init', time: new Date()});
var blocked = await blocking(db, whoami);
timing.push({name: 'blocked', time: new Date()});
var all_followed = await followingDeep(db, [whoami], 2, blocked);
timing.push({name: 'all_followed', time: new Date()});
if (selected) {
g_selected = selected;
} else {
g_selected = all_followed;
}
await Promise.all([
app.postMessage({whoami: whoami}),
app.postMessage({hash: selected && selected.length == 1 ? selected[0] : null}),
ssb.getBroadcasts().then(broadcasts => app.postMessage({broadcasts: broadcasts})),
ssb.connections().then(connections => app.postMessage({connections: connections})),
core.apps().then(apps => app.postMessage({apps: apps})),
]);
timing.push({name: 'core', time: new Date()});
var ids;
if (selected && selected.length == 1 && selected[0].startsWith('%')) {
var m = await getPosts(db, selected);
m = m.length ? m[0] : {id: selected[0]};
ids = await getRelatedPostIds(db, m, all_followed, k_posts_max);
} else {
ids = await getRecentPostIds(db, whoami, g_selected, k_posts_max);
}
timing.push({name: 'get_post_ids', time: new Date()});
var posts = await getPosts(db, ids);
timing.push({name: 'get_posts', time: new Date()});
var roots = posts.map(function(x) {
try {
return JSON.parse(x.content).root;
} catch {
return null;
}
});
var have = new Set(posts.map(x => x.id));
roots = [...new Set(roots)].filter(x => x && !have.has(x));
var all_posts = [].concat(posts, await getPosts(db, roots));
timing.push({name: 'get_root_posts', time: new Date()});
await Promise.all(all_posts.map(x => app.postMessage({message: x})));
timing.push({name: 'send_posts', time: new Date()});
await Promise.all(all_followed.map(id => getAbout(db, id).then(results => Object.keys(results).length ? app.postMessage({user: {user: id, about: results}}) : null)));
timing.push({name: 'about', time: new Date()});
await Promise.all(all_followed.map(id => getVotes(db, id).then(results => results.length ? app.postMessage({votes: results}) : null)));
timing.push({name: 'votes', time: new Date()});
await all_followed.map(
id => app.postMessage(
{
following: {
id: id,
users: [...(g_following_cache[id] || [])],
}
}
)
);
timing.push({name: 'following', time: new Date()});
await app.postMessage({blocking: {id: whoami, users: [...(g_blocking_cache[whoami] || [])]}});
timing.push({name: 'send_blocking', time: new Date()});
await db.set('sequence', JSON.stringify(g_sequence));
var times = {};
var previous = null;
for (let t of timing) {
times[t.name] = (t.time - (previous || t).time) / 1000.0 + ' s';
previous = t;
}
await app.postMessage({ready: true, times: times});
}
ssb.addEventListener('message', async function(id) {
var db = await database("ssb");
var posts = await getPosts(db, [id]);
for (let post of posts) {
if (post.author == await ssb.whoami() ||
JSON.parse(post.content).type != 'post') {
await app.postMessage({message: post});
} else {
await app.postMessage({unread: 1});
}
}
});
async function addAppSources(message) {
if (message.mentions) {
for (let mention of message.mentions) {
if (mention.type == 'application/tildefriends') {
var blob = await ssb.blobGet(mention.link);
var json = JSON.parse(utf8Decode(blob));
for (let file of Object.keys(json.files)) {
message.mentions.push({
name: file,
link: json.files[file],
});
}
}
}
}
}
core.register('message', async function(m) {
if (m.message == 'ready') {
await ready();
} else if (m.message) {
if (m.message.connect) {
await ssb.connect(m.message.connect);
} else if (m.message.appendMessage) {
await addAppSources(m.message.appendMessage);
await ssb.appendMessage(m.message.appendMessage);
} else if (m.message.refresh) {
await refresh(g_selected);
}
} else if (m.event == 'hashChange') {
if (m.hash.length > 1) {
g_selected = [m.hash.substring(1)];
} else {
g_selected = null;
}
if (g_ready) {
await refresh(g_selected);
}
} else if (m.event == 'storeBlobComplete') {
await app.postMessage({storeBlobComplete: m.path});
} else if (m.event == 'focus' || m.event == 'blur') {
/* Shh. */
} else {
print(JSON.stringify(m));
}
});
async function main() {
if (core.user &&
core.user.credentials &&
core.user.credentials.permissions &&
core.user.credentials.permissions.administration) {
await app.setDocument(utf8Decode(await getFile("index.html")));
} else {
await app.setDocument('<div style="color: #f00">Only the administrator can use this app at this time. Login at the top right.</div>');
}
}
main();

File diff suppressed because one or more lines are too long

View File

@ -1,171 +0,0 @@
<html>
<head>
<meta content="width=device-width,initial-scale=1,minimal-ui" name="viewport">
<link rel="stylesheet" href="roboto.css">
<link rel="stylesheet" href="material-icons.css">
<link rel="stylesheet" href="vue-material.min.css">
<link rel="stylesheet" href="vue-material-theme-default-dark.css">
<script src="vue.js"></script>
<script src="vue-material.js"></script>
<script src="commonmark.min.js"></script>
<script src="tf-shared.js"></script>
<script src="tf-user.js"></script>
<script src="tf-message.js"></script>
<script src="tf.js"></script>
</head>
<body style="color: #fff">
<div id="app">
<md-dialog :md-active.sync="show_connect_dialog">
<md-dialog-title>Connect</md-dialog-title>
<md-dialog-content>
<md-field>
<label>net:127.0.0.1:8008~shs:id</label>
<md-input v-model="connect"></md-input>
</md-field>
</md-dialog-content>
<md-dialog-actions>
<md-button class="md-primary" @click="ssb_connect(connect); connect = null; show_connect_dialog = false">Connect</md-button>
<md-button @click="connect = null; show_connect_dialog = false">Cancel</md-button>
</md-dialog-actions>
</md-dialog>
<md-app style="position: absolute; height: 100%; width: 100%">
<md-app-toolbar class="md-primary">
<span class="md-title">Tilde Scuttlebutt</span>
</md-app-toolbar>
<md-app-content>
<div class="md-layout">
<div class="md-layout-item md-size-20">
<md-list>
<md-subheader>Broadcasts</md-subheader>
<md-list-item v-for="broadcast in broadcasts" v-bind:key="JSON.stringify(broadcast)" @click="ssb_connect(broadcast)">{{broadcast.address}}:{{broadcast.port}} <tf-user :id="broadcast.pubkey"></tf-user></md-list-item>
<md-subheader>Connections</md-subheader>
<md-list-item v-for="connection in connections" v-bind:key="'connection-' + JSON.stringify(connection)"><tf-user :id="connection"></tf-user></md-list-item>
<md-list-item @click="show_connect_dialog = true">Connect</md-list-item>
</md-list>
</div>
<div class="md-layout-item md-size-80">
<md-app-toolbar class="md-secondary" v-show="unread > 0">
<md-button @click="refresh()" class="md-raised md-primary">Refresh</md-button>
<span class="md-title">{{unread}} unread item{{unread == 1 ? '' : 's'}}</span>
</md-app-toolbar>
Welcome, <tf-user :id="whoami"></tf-user>.
<span v-if="load_time" style="float: right">
Loaded in {{load_time}} seconds.
<md-tooltip v-if="Object.keys(times).length" style="height: auto">
<div v-for="key in Object.keys(times)">{{key}}: {{times[key]}}</div>
</md-tooltip>
</span>
<md-card class="md-elevation-8">
<md-card-header>
<div class="md-title">What's up?</div>
</md-card-header>
<md-card-content>
<md-chip v-if="reply_root || reply_branch" md-deletable @md-delete="reply_root = null; reply_branch = null">Replying in thread {{reply_root}} to message {{reply_branch}}</md-chip>
<md-chip v-for="link in Object.keys(mentions)" v-bind:key="link" md-deletable @md-delete="remove_from_mentions(link)">
{{mentions[link].name}}: {{link}}
</md-chip>
<div class="md-layout">
<md-field class="md-layout-item">
<label>Post a message</label>
<md-textarea id="post_text" v-model="post_text" v-on:paste="paste" md-autogrow></md-textarea>
</md-field>
<md-card class="md-layout-item" v-if="post_text && post_text.length">
<md-card-content v-html="markdown(post_text)"></md-card-content>
<md-card-content>
<md-card-media v-for="link in Object.keys(mentions)" v-bind:key="link">
<img v-if="(mentions[link].type || '').startsWith('image/')" :src="'/' + link + '/view'" class="md-elevation-4" style="margin: 4px; max-width: 320px; max-height: 240px">
</md-card-media>
</md-card-content>
</md-card>
</div>
</md-card-content>
<md-card-actions>
<md-button class="md-icon-button" @click="attach">
<md-icon>attach_file</md-icon>
</md-button>
<md-menu>
<md-button md-menu-trigger>Share App</md-button>
<md-menu-content>
<md-menu-item v-for="app in Object.keys(apps)" v-bind:key="app" @click="add_app_to_mentions(app)">
{{app}}
</md-menu-item>
</md-menu-content>
</md-menu>
<md-button class="md-raised md-primary" v-on:click="post_message()">Submit Post</md-button>
</md-card-actions>
</md-card>
<md-button v-if="selected" class="md-raised md-primary" style="margin: 1em" @click="set_hash(null)">
<md-icon>home</md-icon> Home
</md-button>
<md-card v-if="selected && selected.charAt(0) == '@'" class="md-raised md-elevation-8" style="margin: 1em">
<md-card-header>
<md-card-header-text>
<div class="md-title">{{users[selected] &amp;&amp; users[selected].name ? users[selected].name : selected}}</div>
<div class="md-subhead" v-if="users[selected] && users[selected].name">{{selected}}</div>
</md-card-header-text>
<md-card-media v-if="users[selected] && users[selected].image" class="md-medium">
<div><img :src="'/' + (typeof(users[selected].image) == 'string' ? users[selected].image : users[selected].image.link) + '/view'"></div>
</md-card-media>
</md-card-header>
<md-card-content>
<div v-if="selected == whoami">
<md-field>
<label>Name</label>
<md-input v-model="edit_profile_name"></md-input>
</md-field>
<md-field>
<label>Description</label>
<md-textarea v-model="edit_profile_description"></md-textarea>
</md-field>
</div>
<template v-if="users[selected]">
<div v-if="users[selected].name">{{selected}}</div>
<div v-html="markdown(users[selected].description)"></div>
</template>
<md-card-actions>
<md-menu md-size="small" v-if="users[selected] && users[selected].followers">
<md-button md-menu-trigger>{{Object.keys(users[selected].followers).length}} followers</md-button>
<md-menu-content>
<md-menu-item v-for="id of Object.keys(users[selected].followers)" v-bind:key="id"><tf-user :id="id"></tf-user></md-menu-item>
</md-menu-content>
</md-menu>
<md-menu md-size="small" v-if="users[selected] && users[selected].following">
<md-button md-menu-trigger>{{Object.keys(users[selected].following).length}} following</md-button>
<md-menu-content>
<md-menu-item v-for="id of Object.keys(users[selected].following)" v-bind:key="id"><tf-user :id="id"></tf-user></md-menu-item>
</md-menu-content>
</md-menu>
<template v-if="selected != whoami && users[whoami] && users[whoami].blocking">
<md-button @click="block(selected)" v-if="!users[whoami].blocking[selected]" class="md-raised md-secondary">Block</md-button>
<md-button @click="unblock(selected)" v-else class="md-raised md-secondary">Unblock</md-button>
</template>
<template v-if="selected != whoami && users[whoami] && users[whoami].following">
<md-button @click="follow(selected)" v-if="!users[whoami].following[selected]" class="md-raised md-secondary">Follow</md-button>
<md-button @click="unfollow(selected)" v-else class="md-raised md-secondary">Unfollow</md-button>
</template>
<md-button @click="save_profile" v-if="selected == whoami" class="md-primary md-raised">Save Profile</md-button>
</md-card-actions>
</md-card-content>
</md-card>
<template v-if="messages.length">
<tf-message
v-for="message in messages"
v-bind:message="message"
v-bind:messages="messages"
v-bind:key="message.id"
v-bind:votes="votes"></tf-message>
</template>
<md-empty-state v-else-if="loading" md-label="Loading...">
<md-progress-spinner md-mode="indeterminate"></md-progress-spinner>
</md-empty-state>
<md-empty-state v-else md-label="Nothing to see here."></md-empty-state>
</div>
</div>
</md-app-content>
</md-app>
</div>
</body>
</html>

View File

@ -1,20 +0,0 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/materialicons/v118/flUhRq6tzZclQEJ-Vdg-IuiaDsNZ.ttf) format('truetype');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
}

View File

@ -1,44 +0,0 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/materialicons/v118/flUhRq6tzZclQEJ-Vdg-IuiaDsNZ.ttf) format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/roboto/v29/KFOkCnqEu92Fr1Mu52xP.ttf) format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Me5Q.ttf) format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src: url(https://fonts.gstatic.com/s/roboto/v29/KFOlCnqEu92Fr1MmEU9vAw.ttf) format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/roboto/v29/KFOlCnqEu92Fr1MmWUlvAw.ttf) format('truetype');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
}

View File

@ -1,165 +0,0 @@
"use strict";
Vue.component('tf-message', {
props: ['message', 'messages', 'votes'],
data: function() { return { showRaw: false } },
computed: {
content_json: function() {
try {
return JSON.parse(this.message.content);
} catch {
return undefined;
}
},
content_raw: function() {
try {
return JSON.stringify(JSON.parse(this.message.content), null, 2);
} catch {
return this.message.content;
}
},
timestamp_relative: function() {
var units = [
{value: 1, name: 'milliseconds'},
{value: 1000, name: 'seconds'},
{value: 1000 * 60, name: 'minutes'},
{value: 1000 * 60 * 60, name: 'hours'},
{value: 1000 * 60 * 60 * 24, name: 'days'},
{value: 1000 * 60 * 60 * 24 * 7, name: 'weeks'},
{value: 1000 * 60 * 60 * 24 * 30, name: 'months'},
{value: 1000 * 60 * 60 * 24 * 365, name: 'years'},
];
var v = new Date().valueOf() - this.message.timestamp;
var result = null;
for (let unit of units) {
if (v >= unit.value) {
result = Math.round(v / unit.value) + ' ' + unit.name + ' ago';
}
}
return result;
},
},
methods: {
markdown: markdown,
set_reply: function() {
g_data.reply_root = this.content_json.root || this.message.id;
g_data.reply_branch = this.message.id;
},
vote: function(event) {
var reaction = event.srcElement.innerText;
var message = this.message.id;
if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) {
window.parent.postMessage(
{
appendMessage: {
type: 'vote',
vote: {
link: message,
value: 1,
expression: reaction,
},
},
},
'*');
}
},
show_message: function() {
window.parent.postMessage({action: 'setHash', hash: this.message.id}, '*');
},
expand_image: function(event) {
var div = document.createElement('div');
div.style.left = 0;
div.style.top = 0;
div.style.width = '100%';
div.style.height = '100%';
div.style.position = 'fixed';
div.style.background = '#000';
div.style.zIndex = 100;
div.style.display = 'grid';
var img = document.createElement('img');
img.src = event.srcElement.src;
img.style.maxWidth = '100%';
img.style.maxHeight = '100%';
img.style.display = 'block';
img.style.margin = 'auto';
img.style.objectFit = 'contain';
img.style.width = '100%';
div.appendChild(img);
div.onclick = function() { document.body.removeChild(div); };
document.body.appendChild(div);
},
},
template: `<md-app class="md-elevation-8" style="margin: 1em" v-if="!content_json || ['pub', 'vote'].indexOf(content_json.type) == -1">
<md-app-toolbar>
<h3>
<md-button class="md-icon-button md-dense" @click="show_message">
<md-icon>percent</md-icon>
</md-button>
<tf-user :id="message.author" v-if="message.author"></tf-user>
</h3>
<template v-if="message.author">
<div style="font-size: x-small">
{{timestamp_relative}}
<md-tooltip style="height: auto">
<div>{{new Date(message.timestamp)}}</div>
<div>{{message.id}}</div>
</md-tooltip>
</div>
<div class="md-toolbar-section-end">
<md-menu>
<md-switch v-model="showRaw"></md-switch>
<md-tooltip>Show Raw Message</md-tooltip>
</md-menu>
</div>
</template>
<template v-else>
<h3>Missing</h3>
</template>
</md-app-toolbar>
<md-app-content>
<template v-if="message.author">
<div v-if="showRaw">
<h1>{{message.id}}</h1>
<pre style="word-wrap: break-all; white-space: pre-wrap">{{content_raw}}</pre>
</div>
<div v-else>
<div v-if="content_json && content_json.type == 'post'">
<div v-html="this.markdown(content_json.text)"></div>
<span v-for="mention in content_json.mentions" v-if="mention.link && typeof(mention.link) == 'string' && mention.link.startsWith('&')">
<a v-if="mention.type == 'application/tildefriends'" :href="'/' + mention.link + '/'" target="_top">{{mention.name}}</a>
<img v-else class="md-elevation-4" style="margin: 4px; max-width: 320px; max-height: 240px" :src="'/' + mention.link + '/view'" v-on:click="expand_image"></img>
</span>
</div>
<div v-else-if="content_json && content_json.type == 'tildefriends-app'">
<div v-html="this.markdown(content_json.text)"></div>
<md-button target="_top" :href="'/' + message.id + '/'">{{content_json.name || 'tildefriends-app'}}</md-button>
</div>
<div v-else-if="content_json && content_json.type == 'contact'"><tf-user :id="message.author"></tf-user> {{content_json.following ? '==&gt;' : '=/=&gt;'}} <tf-user :id="content_json.contact"></tf-user></div>
<div v-else>{{message.content}}</div>
</div>
</template>
<template v-else>
{{message.id}}
</template>
<tf-message v-for="sub_message in (message.children || [])" v-bind:message="sub_message" v-bind:messages="messages" v-bind:votes="votes" v-bind:key="sub_message.id"></tf-message>
<md-chip md-clickable v-for="v in Object.keys(votes[message.id] || {})" v-bind:key="v" @click="vote">
{{v + (votes[message.id][v].length > 1 ? ' (' + votes[message.id][v].length + ')' : '')}}
<md-tooltip style="height: auto">
<div v-for="vote in votes[message.id][v]" v-bind:key="vote.author">{{vote.author}}</div>
</md-tooltip>
</md-chip>
<md-card-actions v-if="message.author">
<md-button class="md-icon-button" @click="set_reply">
<md-icon>reply</md-icon>
</md-button>
<md-menu>
<md-menu-content>
<md-menu-item @click="vote">Like</md-menu-item>
</md-menu-content>
<md-button class="md-icon-button" md-menu-trigger>
<md-icon>thumb_up</md-icon>
</md-button>
</md-menu>
</md-card-actions>
</md-app-content>
</md-app>`,
});

View File

@ -1,28 +0,0 @@
"use strict";
function markdown(md) {
var reader = new commonmark.Parser({safe: true});
var writer = new commonmark.HtmlRenderer();
var parsed = reader.parse(md || '');
var walker = parsed.walker();
var event, node;
while ((event = walker.next())) {
node = event.node;
if (event.entering) {
if (node.type == 'link') {
if (node.destination.startsWith('@') &&
node.destination.endsWith('.ed25519')) {
node.destination = '#' + node.destination;
} else if (node.destination.startsWith('%') &&
node.destination.endsWith('.sha256')) {
node.destination = '#' + node.destination;
}
} else if (node.type == 'image') {
if (node.destination.startsWith('&')) {
node.destination = '/' + node.destination + '/view';
}
}
}
}
return writer.render(parsed);
}

View File

@ -1,25 +0,0 @@
"use strict";
Vue.component('tf-user', {
props: ['id'],
computed: {
following: {
get: function() {
return g_data.users &&
g_data.users[g_data.whoami] &&
g_data.users[g_data.whoami].following &&
g_data.users[g_data.whoami].following[this.id];
},
},
whoami: { get: function() { return g_data.whoami; } },
users: { get: function() { return g_data.users; } },
},
methods: {
show_user: function() {
window.parent.postMessage({action: 'setHash', hash: this.id}, '*');
},
},
template: `<md-chip md-clickable :class="following ? 'md-accent' : ''" @click="show_user()">
{{users[id] && users[id].name ? users[id].name : id}}
<md-tooltip v-if="users[id] && users[id].name">{{id}}</md-tooltip>
</md-chip>`,
});

View File

@ -1,302 +0,0 @@
"use strict";
var g_data = {
whoami: null,
connections: [],
messages: [],
messages_by_id: {},
users: {},
broadcasts: [],
show_connect_dialog: false,
show_user_dialog: null,
connect: null,
pubs: [],
votes: {},
apps: {},
reply_root: null,
reply_branch: null,
mentions: {},
unread: 0,
loading: true,
selected: null,
edit_profile_name: null,
edit_profile_description: null,
load_time: null,
post_text: null,
times: {},
};
var g_load_start = new Date();
var g_data_initial = JSON.parse(JSON.stringify(g_data));
var g_message_queue = [];
var g_process_pending = false;
function updateEditUser() {
g_data.edit_profile_name = g_data.users[g_data.whoami] ? g_data.users[g_data.whoami].name : null;
g_data.edit_profile_description = g_data.users[g_data.whoami] ? g_data.users[g_data.whoami].description : null;
}
function processMessages() {
for (let event of g_message_queue) {
var key = Object.keys(event.data)[0];
if (key == 'message') {
var new_message = event.data.message;
new_message.children = [];
var found = false;
var root = JSON.parse(new_message.content).root;
/* If we had inserted a fake root, replace it if we see the real message. */
if (g_data.messages_by_id[new_message.id]) {
var old_message = g_data.messages_by_id[new_message.id];
new_message.children = old_message.children;
for (let child of new_message.children) {
child.parent = new_message;
}
if (old_message.parent) {
old_message.parent.children = old_message.parent.children.filter(x => x != old_message);
} else {
g_data.messages = g_data.messages.filter(x => x != old_message);
}
}
Vue.set(g_data.messages_by_id, new_message.id, new_message);
if (root) {
/* If we don't know of the message's root, add it. */
if (!g_data.messages_by_id[root]) {
var fake_root = {
id: root,
children: [],
timestamp: new_message.timestamp,
content: '{}',
};
Vue.set(g_data.messages_by_id, root, fake_root);
g_data.messages.push(fake_root);
g_data.messages.sort((x, y) => y.timestamp - x.timestamp);
found = true;
}
var message = g_data.messages_by_id[root];
new_message.parent = message;
message.children.push(new_message);
message.children.sort((x, y) => x.timestamp - y.timestamp);
} else {
/* This is just a new message with no root. Add it. */
g_data.messages.push(new_message);
g_data.messages.sort((x, y) => y.timestamp - x.timestamp);
g_data.messages = g_data.messages.slice(0, 32);
}
} else if (key + 's' in g_data && Array.isArray(g_data[key + 's'])) {
g_data[key + 's'].push(event.data[key]);
} else if (key == 'user') {
Vue.set(g_data.users, event.data.user.user, Object.assign({}, g_data.users[event.data.user.user] || {}, event.data.user.about));
if (event.data.user.user == g_data.whoami) {
updateEditUser();
}
} else if (key == 'following') {
if (!g_data.users[event.data.following.id]) {
Vue.set(g_data.users, event.data.following.id, {});
}
if (!g_data.users[event.data.following.id].following) {
Vue.set(g_data.users[event.data.following.id], 'following', {});
}
for (let user of event.data.following.users) {
Vue.set(g_data.users[event.data.following.id].following, user, true);
if (!g_data.users[user]) {
Vue.set(g_data.users, user, {});
}
if (!g_data.users[user].followers) {
Vue.set(g_data.users[user], 'followers', {});
}
Vue.set(g_data.users[user].followers, event.data.following.id, true);
}
} else if (key == 'blocking') {
if (!g_data.users[event.data.blocking.id]) {
Vue.set(g_data.users, event.data.blocking.id, {});
}
if (!g_data.users[event.data.blocking.id].blocking) {
Vue.set(g_data.users[event.data.blocking.id], 'blocking', {});
}
for (let user of event.data.blocking.users) {
Vue.set(g_data.users[event.data.blocking.id].blocking, user, true);
}
} else if (key == 'broadcasts') {
g_data.broadcasts = event.data.broadcasts;
} else if (key == 'pubs') {
g_data.pubs = event.data.pubs;
} else if (key == 'apps') {
g_data.apps = event.data.apps;
} else if (key == 'votes') {
event.data.votes.forEach(function(vote) {
var content = JSON.parse(vote.content);
var link = content.vote.link;
if (!g_data.votes[link]) {
Vue.set(g_data.votes, link, {});
}
if (!g_data.votes[link][content.vote.expression]) {
Vue.set(g_data.votes[link], content.vote.expression, []);
}
g_data.votes[link][content.vote.expression].push({author: vote.author, value: content.vote.value});
});
} else if (key == 'clear') {
g_load_start = new Date();
g_data.loading = true;
Object.keys(g_data_initial).forEach(function(key) {
Vue.set(g_data, key, JSON.parse(JSON.stringify(g_data_initial[key])));
});
} else if (key == 'ready') {
g_data.load_time = (new Date() - g_load_start) / 1000;
g_data.loading = false;
g_data.times = event.data.times;
} else if (key == 'unread') {
g_data.unread += event.data.unread;
} else if (key == 'hash') {
g_data.selected = event.data.hash;
if (g_data.selected == g_data.whoami) {
updateEditUser();
}
} else if (key == 'storeBlobComplete') {
var blob = event.data.storeBlobComplete;
g_data.post_text = (g_data.post_text || '') + `\n![${blob.name}](${blob.path.substring(1)})`;
Vue.set(g_data.mentions, blob.path.substring(1), {
link: blob.path.substring(1),
name: blob.name,
type: blob.type,
});
} else {
g_data[key] = event.data[key];
}
}
g_message_queue = [];
g_process_pending = false;
}
window.addEventListener('message', function(event) {
g_message_queue.push(event);
if (!g_process_pending) {
g_process_pending = true;
setTimeout(processMessages, 250);
}
});
window.addEventListener('load', function() {
Vue.use(VueMaterial.default);
var vue = new Vue({
el: '#app',
data: g_data,
methods: {
post_message: function() {
var message = {
type: 'post',
text: document.getElementById('post_text').value,
};
if (g_data.reply_root || g_data.reply_branch) {
message.root = g_data.reply_root;
message.branch = g_data.reply_branch;
}
if (Object.keys(g_data.mentions).length) {
message.mentions = Object.values(g_data.mentions);
}
window.parent.postMessage({appendMessage: message}, '*');
g_data.post_text = null;
Vue.set(g_data, 'mentions', {});
g_data.reply_root = null;
g_data.reply_branch = null;
},
ssb_connect: function(connection) {
window.parent.postMessage({connect: connection}, '*');
},
content_json: function(message) {
try {
return JSON.parse(message.content);
} catch {
return undefined;
}
},
markdown: markdown,
refresh: function() {
window.parent.postMessage({refresh: true}, '*');
},
add_app_to_mentions: function(app) {
Vue.set(g_data.mentions, g_data.apps[app], {
link: g_data.apps[app],
name: app,
type: 'application/tildefriends',
});
},
remove_from_mentions: function(link) {
Vue.delete(g_data.mentions, link);
},
save_profile: function() {
var message = {appendMessage: {
type: 'about',
about: g_data.selected,
name: g_data.edit_profile_name,
description: g_data.edit_profile_description,
}};
window.parent.postMessage(message, '*');
},
follow: function(id) {
if (confirm('Are you sure you want to follow ' + id + '?')) {
window.parent.postMessage({appendMessage: {type: "contact", following: true, contact: id}}, '*');
}
},
unfollow: function(id) {
if (confirm('Are you sure you want to unfollow ' + id + '?')) {
window.parent.postMessage({appendMessage: {type: "contact", following: false, contact: id}}, '*');
}
},
block: function(id) {
if (confirm('Are you sure you want to block ' + id + '?')) {
window.parent.postMessage({appendMessage: {type: "contact", blocking: true, contact: id}}, '*');
}
},
unblock: function(id) {
if (confirm('Are you sure you want to unblock ' + id + '?')) {
window.parent.postMessage({appendMessage: {type: "contact", blocking: false, contact: id}}, '*');
}
},
set_hash(hash) {
window.parent.postMessage({action: 'setHash', hash: hash ? hash : '#'}, '*');
},
attach() {
var input = document.createElement('input');
input.type = 'file';
input.onchange = function(event) {
var file = event.target.files[0];
file.arrayBuffer().then(function(buffer) {
window.parent.postMessage({action: 'storeBlob',
blob: {
name: file.name,
type: file.type,
buffer: buffer,
}
}, '*');
}).catch(function(e) {
console.log('error', e);
});
};
input.click();
},
paste(event) {
var items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (let item of items) {
var file = item.getAsFile();
if (file) {
file.arrayBuffer().then(function(buffer) {
window.parent.postMessage({
action: 'storeBlob',
blob: {
name: file.name,
type: file.type,
buffer: buffer,
},
}, '*');
});
event.preventDefault();
break;
}
}
}
}
});
window.parent.postMessage('ready', '*');
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff