federation !

main
jerl 2024-06-12 05:00:50 -05:00
parent f34099de9c
commit 02294b0327
31 changed files with 1199 additions and 5240 deletions

View File

@ -1,21 +1,40 @@
# vybe server
very very unfinished !! right now it can do a chatroom
## vybe
## usage
vybe is a work-in-progress decentralized **communication network**.
a vybe instance (server) features user accounts and **threads**.
user accounts are owned by PGP keys stored in users' clients.
each thread features a chatroom, a **space**, and audio streams.
a space is like a text blackboard, basically. you can write text and move it around and resize it with your mouse.
once you have an account on any instance, you can connect to and interact with any other instance, right from within your home instance ui.
to run your own instance, clone this repo and:
> `npm install`
`npm install`
then
`node index.js`
## clients
> `node .`
there are two clients! you can choose one
it will run on port 1312 by default; you can change this by setting PORT on first-run:
### vybeclient
> `PORT=1234 node .`
standard official client, written w/ vanilla js. served on :3435 by index.js
after first-run, the port is configured in instance.json.
### vyber
currently, you'll need to proxy it through a webserver like nginx or Caddy, which needs to have https enabled for things to work correctly. if you want the easy option, i recommend Caddy. example Caddy config:
alternative client, developed by june, w/ webcomponents. start by running `npm start` in the vyber-client folder
> ```
> vybe.my.domain {
> reverse_proxy localhost:1312
> }
> ```
then go to `https://vybe.my.domain` to start using vybe!
let me know if you have any questions or issues. my website is [jerl.zone](https://jerl.zone).

View File

@ -1,254 +1,5 @@
import { render, html } from '/uhtml.js';
import loadMessages from '/message.js';
import loadSpace from '/space.js';
import loadStreams from '/stream.js';
function setVisibility() {
document.getElementById('visibility').innerText = `${
window.currentThread.permissions.everyone.view.value ?
'this thread is visible to everyone' :
'members can view this thread'}
${window.currentThread.permissions.everyone.post.value ?
'anyone can post' :
window.currentThread.permissions.members.post.value ?
'only members can post' : 'some members can post'}`;
}
function chooseThread() {
const edit = document.getElementById('edit');
if (window.currentThread) {
document.getElementById(`thread${window.currentThread.id}`).classList.remove('active');
let editform = document.getElementById('editthread');
if (editform) {
editform.remove();
edit.textContent = 'edit';
}
if (window.currentThread.id === this.thread.id) {
document.getElementById('thread').classList.add('hidden');
window.currentThread = null;
return;
}
}
else
document.getElementById('thread').classList.remove('hidden');
if (this.thread.permissions.admin)
edit.classList.remove('hidden');
else
edit.classList.add('hidden');
document.getElementById('threadname').textContent = this.thread.name;
this.classList.add('active');
window.currentThread = this.thread;
loadStreams();
if (this.tab)
switchTab(document.getElementById(this.tab));
else // load first tab that has any content
loadMessages(true, messages => {
if (messages.length)
switchTab(document.getElementById(this.tab = 'messagetab'));
else
loadSpace(spans => {
if (spans.length)
switchTab(document.getElementById(this.tab = 'spacetab'));
else if (window.currentThread.streams.length)
switchTab(document.getElementById(this.tab = 'streamtab'));
else
switchTab(document.getElementById(
this.tab = spans.length ? 'spacetab' :
window.currentThread.streams.length ? 'streamtab' : 'messagetab'));
});
});
window.emit('get_thread', { thread: this.thread.id }, msg => {
window.currentThread = msg.thread;
setVisibility();
document.getElementById('memberlist').replaceChildren(
...msg.thread.members.map(member =>
html.node`<p class='member'>${member.name}</p>`)
);
});
}
function switchTab(tab) {
for (let tab of document.querySelectorAll('.tab'))
tab.classList.remove('active');
for (let tab of document.querySelectorAll('.tabcontent'))
tab.classList.add('hidden');
tab.classList.add('active');
document
.getElementById(tab.id.slice(0, -3))
.classList.remove('hidden');
if (tab.id === 'messagetab')
loadMessages(true);
else if (tab.id === 'spacetab')
loadSpace();
}
async function createThread(event) {
event.preventDefault();
let name = document.getElementById('newthreadname').value;
if (!name) {
document.getElementById('newnameempty').classList.remove('hidden');
return;
}
const perms = document.querySelector(
'input[name="newpermissions"]:checked'
).value;
let permissions;
if (perms === 'public')
permissions = { view_limited: false, post_limited: false };
else if (perms === 'private_post')
permissions = { view_limited: false, post_limited: true };
else if (perms === 'private_view') {
permissions = { view_limited: true, post_limited: true };
// generate key
/* wip
var buf = new Uint8Array(32);
crypto.getRandomValues(buf);
const key = aesjs.utils.hex.fromBytes(Array.from(buf));
// sign it to each of the members
for (let i = 0; i < newmembers.length; i++) {
const member = newmembers[i];
const sig = await openpgp.encrypt({
message: await openpgp.createMessage({ text: key }),
signingKeys: window.keys.priv
});
}
*/
}
window.emit('create_thread',
{ name, permissions, members: window.threadmembers },
msg => {
chooseThread.call(document.getElementById('thread' + msg.id));
// since the form exists, this will perform cleanup
newThread();
document.getElementById('loadmore').classList.add('hidden');
}
);
}
function addMember() {
const name = document.getElementById('membername');
if (!name.value)
return;
window.threadmembers.push({ name: name.value });
document.getElementById('newmembers')
.appendChild(html.node`<p class='member'>${name.value}</p>`);
name.value = '';
}
function newThread() {
let form = document.getElementById('createthread');
if (form) {
form.remove();
document.getElementById('newthread').textContent = 'create';
return;
}
window.threadmembers = [{
name: window.name,
permissions: { admin: 'true' }
}];
document.querySelector('#home + .separator').insertAdjacentElement('afterend', html.node`
<form id='createthread' class='column' onsubmit=${createThread}>
<h3>create thread</h3>
<label for='newthreadname' class='heading'>thread name</label>
<p id='newnameempty' class='hidden'>name cannot be empty</p>
<input type='text' id='newthreadname' />
<p id='permissions'>thread permissions</p>
<input type='radio' name='newpermissions'
id='public' value='public' checked />
<label for='public'>anyone can view and post</label><br>
<input type='radio' name='newpermissions'
id='private_post' value='private_post' />
<label for='private_post'>anyone can view, only members can post</label><br>
<input type='radio' name='newpermissions'
id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br>
<label class='heading' for='membername'>members</label>
<input type='text' id='membername' placeholder='username' onkeydown=${event => {
if (event.key === 'Enter') {
event.preventDefault();
addMember();
}
}} />
<button id='addmember' type='button' onclick=${addMember}>add</button>
<div id='newmembers'>
<p class='member'>${window.name}</p>
</div>
<br>
<button id='submitthread' type='submit'>create</button>
</form>`);
document.getElementById('newthread').textContent = 'cancel';
}
async function saveThread(event) {
event.preventDefault();
let name = document.getElementById('editthreadname').value;
if (!name) {
document.getElementById('nameempty').classList.remove('hidden');
return;
}
const perms = document.querySelector(
'input[name="permissions"]:checked'
).value;
let permissions;
if (perms === 'public')
permissions = { view_limited: false, post_limited: false };
else if (perms === 'private_post')
permissions = { view_limited: false, post_limited: true };
else if (perms === 'private_view') {
permissions = { view_limited: true, post_limited: true };
// todo: generate key and encrypt
}
window.emit('edit_thread',
{ id: window.currentThread.id, name, permissions },
msg => {
if (!msg.success) {
console.log('edit_thread failed: ', msg.message);
return;
}
editThread();
});
}
function editThread() {
let form = document.getElementById('editthread');
if (form) {
form.remove();
document.getElementById('edit').textContent = 'edit';
return;
}
form = html.node`
<form id='editthread' class='column' onsubmit=${saveThread}>
<label for='editthreadname' class='heading'>thread name</label>
<input type='text' id='editthreadname' />
<p id='nameempty' class='hidden'>name cannot be empty</p>
<p id='permissions'>thread permissions</p>
<input type='radio' name='permissions'
id='public' value='public' />
<label for='public'>anyone can view and post</label><br>
<input type='radio' name='permissions'
id='private_post' value='private_post' />
<label for='private_post'>anyone can view, only members can post</label><br>
<input type='radio' name='permissions'
id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br>
<br>
<button id='savethread'>save</button>
</form>`;
form['editthreadname'].value = window.currentThread.name;
if (window.currentThread.permissions.everyone.post.value)
form['public'].checked = true;
else if (window.currentThread.permissions.everyone.view.value)
form['private_post'].checked = true;
else
form['private_view'].checked = true;
document.querySelector('#thread').append(form);
document.getElementById('edit').textContent = 'cancel';
}
function clickedTab(event) {
switchTab(event.target);
document.getElementById(`thread${window.currentThread.id}`).tab = event.target.id;
}
import loadThreads from '/thread.js';
function changeName(e) {
let displayname = document.getElementById('newname').value;
@ -265,14 +16,140 @@ function changeName(e) {
})
}
window.connectInstance = async url => {
let instance = window.instances[url];
let connected;
function connecting(resolve, reject) {
instance.socket.on('connect', resolve);
instance.socket.on('connect_error', error => {
console.log(`error connecting to ${url}: ${error}`);
if (connected)
return;
instance.socket.disconnect();
reject(error.message);
});
}
if (instance) {
if (!instance.socket?.connected) {
if (instance.socket)
instance.socket.connect();
else
instance.socket = io(url);
await new Promise(connecting);
}
}
else {
instance = window.instances[url] = {
socket: io(url),
url
};
await new Promise(connecting);
}
connected = true;
return instance;
};
window.getUser = async (id, name) => {
let instance = window.instancelist[0];
if (id) {
id = id.split('@');
instance = await connectInstance(id[1]);
id = id[0];
}
return new Promise((resolve, reject) =>
instance.socket.emit('get_user', { id, name }, msg => {
if (!msg.success)
return reject('get_user failed: ' + msg.message);
resolve(msg.user);
})
);
};
async function authenticateInstance(div, select) {
return new Promise((resolve, reject) =>
div.instance.socket.emit('authenticate', {
name: window.name,
id: window.id + '@' + location.host,
message: window.signedMessage,
pubkey: window.keys.armored.publicKey
}, msg => {
if (!msg.success) {
reject(`${div.instance.url} authentication failed: ${msg.message}`);
return;
}
div.instance.id = msg.instance.id;
div.id = 'instance' + msg.instance.id;
div.instance.emit = (event, data, callback) =>
div.instance.socket.emit(event, {
...data,
__session: window.session,
}, callback);
loadThreads(div, select);
resolve();
}));
}
function expandInstance(event) {
this.parentElement.parentElement.children['threads'].classList.toggle('hidden');
this.children[0].classList.toggle('collapsed');
}
function instanceClicked(event) {}
function saveInstances() {
localStorage.setItem('instances', JSON.stringify(
window.instancelist.map(i => ({
id: i.id,
url: i.url
}))
));
}
async function addInstance() {
this.textContent = this.textContent.trim().toLowerCase();
let instancediv = this.parentElement.parentElement;
if (!this.textContent)
return instancediv.remove();
let url = (/^[\w-]+:.+/.test(this.textContent) ? '' : 'https://') + this.textContent;
if (window.instancelist.find(i => i.url === url))
return instancediv.remove();
let instance = await connectInstance(url);
instancediv.instance = instance;
await authenticateInstance(instancediv, true);
this.contentEditable = false;
/* expander is initially just a span with a non-space space
in it to make the title span style work */
let expander = this.parentElement.children[0];
expander.classList.add('expander');
expander.onclick = expandInstance;
expander.innerHTML = `<span class='arrow'>◿</span>`;
window.instancelist.push(instance);
saveInstances();
this.onclick = instanceClicked;
}
// main app html
document.body.append(html.node`
<div id='home' class='column'>
<div id='threads'>
<div id='instancelist'>
<h3>vybe</h3>
<h4>threads</h4>
<div id='threadlist'>loading...</div>
<button id='newthread' onclick=${newThread}>create</button>
<p id='instances'>instances:<button onclick=${() => {
let div = html.node`
<div>
<div class='instance'>
<span></span>
<span onblur=${addInstance} onkeydown=${function(event) {
if (event.key === 'Enter') {
event.preventDefault();
addInstance.call(this);
}
}} class='title' contenteditable='true'>
</span>
</div>
</div>`;
document.getElementById('instancelist').append(div);
div.children[0].children[1].focus();
}}>add</button></p>
</div>
<div id='user' onclick=${() =>
document.getElementById('profile').classList.toggle('hidden')
@ -282,6 +159,7 @@ document.body.append(html.node`
<!-- create thread column goes here -->
<hr class='separator' color='#505050'>
<div id='profile' class='column hidden'>
<p><strong>profile</strong></p>
<label class='heading'>display name</label>
<input id='newname' onkeyup=${function(event) {
if (window.displayname === this.value)
@ -296,63 +174,31 @@ document.body.append(html.node`
<div id='authrequests'></div>
</div>
<hr class='separator' color='#505050'>
<div id='thread' class='column'>
<div id='content'>
<div id='titlebar'>
<span id='title'>thread: <strong id='threadname'>meow</strong></span>
<button id='edit' class='hidden' onclick=${editThread}>edit</button>
</div>
<div id='buttonbar'>
<div id='tabs'>
<button id='messagetab' class='tab active' onclick=${clickedTab}>messages
</button><button id='spacetab' class='tab' onclick=${clickedTab}>space
</button><button id='streamtab' class='tab' onclick=${clickedTab}>streams</button>
</div>
<button id='showmembers' onclick=${() =>
document.getElementById('members').classList.toggle('hidden')
}>members</button>
</div>
<div id='message' class='tabcontent'></div>
<div id='space' class='tabcontent hidden'></div>
<div id='stream' class='tabcontent hidden'></div>
</div>
<hr class='separator' color='#505050'>
<div id='members' class='hidden'>
<p id='visibility'></p>
<p><strong>members</strong></p>
<div id='memberlist'>
</div>
</div>
<hr class='separator' color='#505050'>
</div>
`);
document.getElementById('newname').value = window.displayname;
function makeThread(thread) {
let node = html.node`
<div class='thread' onclick=${chooseThread}>${
thread.name
}</div>`;
node.id = 'thread' + thread.id;
node.thread = thread;
return node;
for (let i = 0; i < instancelist.length; ++i) {
let instance = instancelist[i];
let div = html.node`
<div>
<div class='instance'>
<span class='expander' onclick=${expandInstance}>
<span class='arrow'></span>
</span>
<span class='title' onclick=${instanceClicked}>${instance.url}</span>
</div>
</div>`;
div.id = 'instance' + instance.id;
div.instance = instance;
document.getElementById('instancelist').append(div);
if (i === 0)
loadThreads(div, true);
else {
await connectInstance(instance.url);
authenticateInstance(div);
}
}
window.socket.on('thread', thread => {
let el = document.getElementById('thread' + thread.id);
if (el) {
el.thread = thread;
el.textContent = thread.name;
if (window.currentThread?.id === thread.id) {
Object.assign(window.currentThread, thread);
document.getElementById('threadname').textContent = thread.name;
setVisibility();
}
return;
}
document.getElementById('threadlist').prepend(makeThread(thread));
});
document.getElementById('newname').value = window.displayname;
function authRequest(authrequest) {
const p = html.node`
@ -374,12 +220,6 @@ function authRequest(authrequest) {
window.socket.on('authrequest', authRequest);
window.emit('list_threads', {}, msg => {
const threadlist = document.getElementById('threadlist');
threadlist.replaceChildren(...msg.threads.map(makeThread));
chooseThread.call(threadlist.firstChild);
});
export {
authRequest
};

View File

@ -12,33 +12,59 @@ function rand() {
}
async function auth() {
const sig = await openpgp.sign({
window.session = rand();
window.emit = (event, data, callback) =>
window.socket.emit(event, {
...data,
__session: window.session,
}, callback);
window.signedMessage = await openpgp.sign({
message: new openpgp.CleartextMessage('vybe_auth ' + window.session, ''),
signingKeys: window.keys.priv
});
window.socket.emit(
'authenticate',
{
window.socket.emit('authenticate', {
name: window.name,
message: sig,
id: `${window.id || ''}@${location.host}`,
message: signedMessage,
pubkey: window.keys.armored.publicKey
},
async msg => {
let register = document.getElementById('register');
if (!msg.success) {
console.log('authenticate failed', msg);
console.log('authenticate failed:', msg.message);
document.getElementById('result').innerText = msg.message;
register.classList.remove('hidden');
return;
}
localStorage.setItem('keys', JSON.stringify(window.keys.armored));
localStorage.setItem('name', window.name);
localStorage.setItem('name', window.name = msg.name);
localStorage.setItem('id', window.id = msg.id);
register.classList.add('hidden');
window.displayname = msg.displayname;
if (window.instancelist) {
if (window.instancelist[0].url !== location.host
|| window.instancelist[0].id !== msg.instance.id) {
console.log('instance url or id changed ??');
return;
}
}
else {
localStorage.setItem('instances', JSON.stringify(window.instancelist = [
{
id: msg.instance.id,
url: location.host
}
]));
}
instancelist[0].socket = window.socket;
instancelist[0].emit = window.emit;
window.instances = Object.fromEntries(instancelist.map(i => [i.url, i]));
const { authRequest } = await import('/app.js');
msg.authrequests.forEach(authRequest);
}
);
});
}
async function submit(event) {
@ -54,7 +80,7 @@ async function submit(event) {
window.keys = { priv, pub, armored: keys };
window.name = name;
if (this.id === 'registerform') {
window.emit('create_user',
window.socket.emit('create_user',
{ name, pubkey: keys.publicKey },
(msg) => {
if (!msg.success) {
@ -96,28 +122,14 @@ render(document.body, html`
</div>
`);
function gensession() {
window.session = rand();
window.emit = (type, data, callback) =>
window.socket.emit(
type,
{
...data,
__session: window.session,
},
callback
);
}
window.onload = async () => {
window.socket = io();
gensession();
let keys = localStorage.getItem('keys');
if (keys) {
window.name = localStorage.getItem('name');
window.id = localStorage.getItem('id');
window.instancelist = JSON.parse(localStorage.getItem('instances'));
keys = JSON.parse(keys);
window.keys = {
priv: await openpgp.readKey({ armoredKey: keys.privateKey }),
@ -130,7 +142,6 @@ window.onload = async () => {
document.getElementById('register').classList.remove('hidden');
window.socket.io.on('reconnect', async attempt => {
gensession();
if (window.keys)
await auth();
});

View File

@ -55,7 +55,7 @@
outline: none;
border: 1px solid #444;
&:focus {
padding-bottom: 3px;
padding-bottom: 2px;
border-bottom: 3px solid #777;
}
&::placeholder {
@ -70,20 +70,21 @@
margin-inline: 14px;
max-width: 800px;
}
:not(#register) > p {
margin: 5px 1px;
}
.thread:hover,
.tab:hover,
.instance > span:hover,
#user:hover {
background-color: #303030;
background-color: #333;
}
.tab.active,
.thread.active,
.tab.active,
button:hover {
background-color: #4f4f4f;
color: #fff;
}
p {
margin: 5px 1px;
}
label.heading {
margin: 10px 1px 4px;
display: block;
@ -109,13 +110,47 @@
.separator:last-child {
display: none;
}
.instance {
margin: 2px;
> span {
display: table-cell;
background-color: #222;
}
> .title {
padding-block: 6px;
width: 100%;
}
}
.expander {
padding-inline: 6px;
}
.arrow {
display: block;
position: relative;
top: -1px;
rotate: 45deg;
&.collapsed {
rotate: -45deg;
top: 0;
left: -3px;
}
}
#home {
margin: 0;
max-width: 250px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
#instancelist > :not(div) {
margin: 3px;
}
#instances {
display: flex;
justify-content: space-between;
}
#threads {
margin: 0 2px 6px;
min-height: 0;
display: flex;
flex-direction: column;
@ -124,8 +159,9 @@
overflow: auto;
}
#user {
margin: 2px;
padding: 6px;
background-color: #191919;
background-color: #222;
}
#profile {
max-width: 250px;

View File

@ -2,11 +2,11 @@ import { render, html } from '/uhtml.js';
let msg;
function sendMessage(e) {
e.preventDefault();
function sendMessage(event) {
event.preventDefault();
if (!msg.value)
return;
window.emit('send_message', {
window.currentInstance.emit('send_message', {
message: msg.value,
thread: window.currentThread.id
});
@ -15,23 +15,9 @@ function sendMessage(e) {
let earliestMessage;
window.socket.on('new_message', message => {
if (message.thread !== window.currentThread?.id)
return;
const messages = document.getElementById('messages');
let scroll = messages.scrollHeight - messages.scrollTop <= messages.clientHeight;
messages.appendChild(html.node`
<div class='message'>
<strong>${message.name}: </strong>
${message.content}
</div>`);
if (scroll)
messages.scroll(0, messages.scrollHeight - messages.clientHeight);
if (!earliestMessage)
earliestMessage = message.id;
});
function loadMessages(callback) {
let instance = window.currentInstance;
function loadMessages(firstRender, callback) {
if (!msg) {
render(document.getElementById('message'), html`
<button id='loadmore' class='hidden' onclick=${loadMessages}>
@ -54,32 +40,62 @@ function loadMessages(firstRender, callback) {
else
document.getElementById('msginput').classList.add('hidden');
}
window.emit('get_history', {
instance.emit('get_history', {
before: earliestMessage,
thread: window.currentThread.id
}, msg => {
}, async msg => {
if (!msg.success) {
console.log('get_history failed: ' + msg.message);
return;
}
callback && callback(msg.messages);
if (firstRender && messages.hasChildNodes())
return;
if (msg.messages.length > 0) {
earliestMessage = msg.messages[msg.messages.length - 1].id;
for (let message of msg.messages)
let users = {};
for (let message of msg.messages) {
if (!message.name) {
try {
message.name = (users[message.userid] || (
users[message.userid] = await window.getUser(message.userid)
)).displayname;
}
catch (e) {
console.log(`error getting user ${message.userid}`, e);
message.name = message.userid;
}
}
messages.prepend(html.node`
<div class='message'>
<strong>${message.name}: </strong>
${message.content}
</div>`);
}
}
if (msg.more)
document.getElementById('loadmore').classList.remove('hidden');
else
document.getElementById('loadmore').classList.add('hidden');
messages.scroll(0, messages.scrollHeight - messages.clientHeight);
});
if (!instance.messaged) {
instance.socket.on('new_message', message => {
if (message.thread !== window.currentThread?.id || instance !== window.currentInstance)
return;
const messages = document.getElementById('messages');
let scroll = messages.scrollTop + 10 >= messages.scrollHeight - messages.clientHeight;
messages.appendChild(html.node`
<div class='message'>
<strong>${message.name}: </strong>
${message.content}
</div>`);
if (scroll)
messages.scroll(0, messages.scrollHeight - messages.clientHeight);
if (!earliestMessage)
earliestMessage = message.id;
});
instance.messaged = true;
}
}
export default loadMessages;

View File

@ -1,7 +1,6 @@
let space;
let spaceId;
let scale = 1;
let scale = 1; // todo: make zooming work
let editing;
let dragging;
@ -26,8 +25,8 @@ function save(span) {
return;
}
saving = true;
window.emit('save_span', {
thread: spaceId,
window.currentInstance.emit('save_span', {
thread: window.currentThread.id,
id: span.id ? span.id.slice(4) : '',
content: span.innerText,
x: span.style.left.slice(0, -2),
@ -71,8 +70,9 @@ function add(s) {
span.onblur = function(event) {
if (this.innerText)
return;
save(this);
this.remove();
if (this.id)
save(this);
};
span.onwheel = function(event) {
event.preventDefault();
@ -107,23 +107,10 @@ function add(s) {
return span;
}
window.socket.on('span', msg => {
if (msg.thread !== spaceId)
return;
let span = document.getElementById('span' + msg.id);
if (span) {
span.innerText = msg.content;
span.x = msg.x;
span.y = msg.y;
span.scale = msg.scale;
span.style.transform = `translate(-50%, -50%) scale(${msg.scale})`;
}
else
add(msg);
});
export default function loadSpace(callback) {
if (!space) {
let instance = window.currentInstance;
if (!instance.spaceid) {
space = document.getElementById('space');
space.onmouseup = event => {
if (dragging) {
@ -143,18 +130,34 @@ export default function loadSpace(callback) {
});
editing.focus();
};
}
if (spaceId === window.window.currentThread.id)
instance.socket.on('span', msg => {
if (msg.thread !== instance.spaceid || window.currentInstance !== instance)
return;
spaceId = window.window.currentThread.id;
let span = document.getElementById('span' + msg.id);
if (span) {
span.innerText = msg.content;
span.style.left = `${msg.x}px`;
span.style.top = `${msg.y}px`;
span.scale = msg.scale;
span.style.transform = `translate(-50%, -50%) scale(${msg.scale})`;
}
else
add(msg);
});
}
else if (instance.spaceid === window.currentThread.id)
return;
instance.spaceid = window.currentThread.id;
space.innerHTML = '';
window.emit('get_space', { thread: window.window.currentThread.id }, msg => {
instance.emit('get_space', {
thread: window.currentThread.id
}, msg => {
if (!msg.success) {
console.log('get space failed: ' + msg.message);
return;
}
callback && callback(msg.spans);
for (const span of msg.spans)
add(span);
msg.spans.forEach(add);
});
};

View File

@ -1,20 +1,17 @@
import { render, html } from '/uhtml.js';
let streamid;
let handle;
let mediaStream;
let recorder;
async function stream() {
if (handle) {
clearInterval(handle);
handle = null;
if (recorder.state === 'recording')
recorder.stop();
window.emit('stream', {
id: streamid,
thread: window.currentThread.id,
let thread = window.currentThread;
if (thread.handle) {
clearInterval(thread.handle);
delete thread.handle;
if (thread.recorder.state === 'recording')
thread.recorder.stop();
thread.instance.emit('stream', {
id: thread.streamid,
thread: thread.id,
stop: true
});
document.getElementById('streaming').innerText = 'start streaming';
@ -30,21 +27,22 @@ async function stream() {
sampleSize: 16
}
});
if (!mediaStream)
if (!mediaStream) {
console.log("couldn't get media stream");
return;
window.emit('stream', {
thread: window.currentThread.id,
}
thread.instance.emit('stream', {
thread: thread.id,
name: document.getElementById('streamname').value
}, msg => {
if (!msg.success) {
console.log('stream failed: ', msg.message);
console.log('stream failed:', msg.message);
return;
}
streamid = msg.id;
thread.streamid = msg.id;
document.getElementById('streaming').innerText = 'stop streaming';
});
function record() {
let r = recorder = new MediaRecorder(mediaStream);
let r = thread.recorder = new MediaRecorder(mediaStream);
let chunks = [];
r.ondataavailable = event => {
if (!event.data.size)
@ -52,15 +50,15 @@ async function stream() {
chunks.push(event.data);
};
r.onstop = async () => {
if (!chunks.length || !handle)
if (!chunks.length || !thread.handle)
return;
//console.log(`${Date.now()} ${chunks.length}`);
window.emit('streamdata', {
id: streamid,
thread.instance.emit('streamdata', {
id: thread.streamid,
audio: await (new Blob(chunks, { type: chunks[0].type })).arrayBuffer()
}, msg => {
if (!msg.success)
console.log('streamdata failed: ', msg.message);
console.log('streamdata failed:', msg.message);
});
};
r.onstart = () => {
@ -69,29 +67,54 @@ async function stream() {
r.start();
}
record();
handle = setInterval(record, 500);
thread.handle = setInterval(record, 500);
});
}
let audioctx;
let streaming = {};
function addStream(stream) {
function loadStreams() {
let instance = window.currentInstance;
let div = document.getElementById('stream');
div.innerHTML = '';
if (window.currentThread.permissions.post) {
// why doesn't html` work here? html.node` does
render(div, html.node`
<button id='streaming' onclick=${stream}>
${window.currentThread.handle ? 'stop' : 'start'} streaming
</button>
<span>stream name:</span>
<input id='streamname' oninput=${function(event) {
if (window.currentThread.handle)
instance.emit('stream', {
id: window.currentThread.streamid,
thread: window.currentThread.id,
name: this.value
});
}}>`);
}
div.insertAdjacentHTML('beforeend', `
<p>streams:</p>
<div id='streams'></div>`);
function addStream(stream) {
let p = html.node`
<p>
<button id='play' onclick=${e => {
if (stream.playing) {
audioctx.suspend();
delete streaming[stream.id];
delete instance.streaming[stream.id];
stream.playing = false;
e.target.innerText = '▶';
}
else {
audioctx = new AudioContext();
streaming[stream.id] = stream;
instance.streaming[stream.id] = stream;
stream.playing = true;
e.target.innerText = '⏹';
}
window.emit('play_stream', {
instance.emit('play_stream', {
id: stream.id,
thread: window.currentThread.id,
playing: stream.playing
@ -99,63 +122,53 @@ function addStream(stream) {
if (!msg.success)
console.log('play stream failed: ', msg.message);
});
}}></button>
}}>${instance.streaming[stream.id] ? '⏹' : '▶'}</button>
${stream.user}<span id='name'>${stream.name ? ` - ${stream.name}` : ''}</span>
</p>`;
p.id = 'stream' + stream.id;
document.getElementById('streams').append(p);
}
function loadStreams() {
let div = document.getElementById('stream');
div.innerHTML = '';
if (window.currentThread.permissions.post) {
// why doesn't html` work here? html.node` does
render(div, html.node`
<button id='streaming' onclick=${stream}>start streaming</button>
<span>stream name:</span>
<input id='streamname' oninput=${event => {
if (handle)
window.emit('stream', {
id: streamid,
thread: window.currentThread.id,
name: event.target.value
});
}}>`);
}
div.insertAdjacentHTML('beforeend', `
<p>streams:</p>
<div id='streams'></div>`);
for (let stream of window.currentThread.streams)
addStream(stream);
}
window.socket.on('stream', async msg => {
if (msg.thread !== window.currentThread?.id)
return;
if (!instance.streaming) {
instance.streaming = {};
instance.socket.on('stream', async msg => {
let streams = document.querySelector(
`#instance${instance.id} > #threads > #threadlist > #thread${msg.thread}`)
.thread.streams;
let i = streams.findIndex(s => s.id === msg.id);
let p = document.getElementById('stream' + msg.id);
if (p) {
if (msg.stopped) {
if (i !== -1) {
streams.splice(i, 1);
if (msg.thread === window.currentThread?.id)
p.remove();
window.currentThread.streams.splice(
window.currentThread.streams.findIndex(s => s.id === msg.id), 1);
}
else
p.children['name'].innerText = msg.name ? ' - ' + msg.name : '';
}
else if (!msg.stopped) {
window.currentThread.streams.push(msg);
else if (i === -1) {
streams.push(msg);
if (msg.thread === window.currentThread?.id)
addStream(msg);
}
});
else {
streams[i].name = msg.name;
if (msg.thread === window.currentThread?.id)
p.children['name'].innerText = msg.name ? ' - ' + msg.name : '';
}
});
window.socket.on('streamdata', async msg => {
if (!streaming[msg.id])
instance.socket.on('streamdata', async msg => {
if (!instance.streaming[msg.id])
return;
let source = audioctx.createBufferSource();
source.buffer = await audioctx.decodeAudioData(msg.audio);
source.connect(audioctx.destination);
source.start(/*audioStartTime*/);
});
});
}
for (let stream of window.currentThread.streams)
addStream(stream);
}
export default loadStreams;

353
client/thread.js Normal file
View File

@ -0,0 +1,353 @@
import { render, html } from '/uhtml.js';
import loadMessages from '/message.js';
import loadSpace from '/space.js';
import loadStreams from '/stream.js';
function setVisibility() {
document.getElementById('visibility').innerText = `${
window.currentThread.permissions.everyone.view.value ?
'this thread is visible to everyone' :
'members can view this thread'}
${window.currentThread.permissions.everyone.post.value ?
'anyone can post' :
window.currentThread.permissions.members.post.value ?
'only members can post' : 'some members can post'}`;
}
function chooseThread() {
const edit = document.getElementById('edit');
let thread = this.thread;
if (window.currentThread) {
window.currentThread.div.classList.remove('active');
let editform = document.getElementById('editthread');
if (editform) {
editform.remove();
edit.textContent = 'edit';
}
if (window.currentThread.id === thread.id) {
document.getElementById('thread').classList.add('hidden');
window.currentThread = null;
return;
}
}
else
document.getElementById('thread').classList.remove('hidden');
if (thread.permissions.admin)
edit.classList.remove('hidden');
else
edit.classList.add('hidden');
document.getElementById('threadname').textContent = thread.name;
this.classList.add('active');
window.currentThread = thread;
window.currentInstance = thread.instance;
if (this.tab) {
switchTab(document.getElementById(this.tab));
if (this.tab === 'messagetab')
loadMessages();
else if (this.tab === 'spacetab')
loadSpace();
}
else // load first tab that has any content
loadMessages(messages => {
if (messages.length)
switchTab(document.getElementById(this.tab = 'messagetab'));
else
loadSpace(spans => {
if (spans.length)
switchTab(document.getElementById(this.tab = 'spacetab'));
else if (window.currentThread.streams.length)
switchTab(document.getElementById(this.tab = 'streamtab'));
else
switchTab(document.getElementById(
this.tab = spans.length ? 'spacetab' :
window.currentThread.streams.length ? 'streamtab' : 'messagetab'));
});
});
window.currentInstance.emit('get_thread', { thread: thread.id }, async msg => {
if (!msg.success) {
console.log('get_thread failed:', msg.message);
return;
}
Object.assign(thread, msg.thread);
loadStreams();
setVisibility();
document.getElementById('memberlist').replaceChildren(
...await Promise.all(msg.thread.members.map(async member =>
html.node`<p class='member'>${
member.name || (await window.getUser(member.id)).name
}</p>`))
);
});
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tabcontent').forEach(tab => tab.classList.add('hidden'));
tab.classList.add('active');
document.getElementById(tab.id.slice(0, -3)).classList.remove('hidden');
}
function newThread() {
let form = document.getElementById('createthread');
if (form) {
form.remove();
for (let create of document.querySelectorAll('#newthread')) {
create.textContent = 'create';
create.classList.remove('hidden');
}
return;
}
const instancediv = this.parentElement.parentElement;
let members = [{
id: String(window.id),
name: window.name,
permissions: { admin: 'true' }
}];
if (instancediv.instance !== window.instancelist[0]) {
members[0].id += '@' + location.host;
members[0].name += '@' + location.host;
}
async function addMember() {
const name = document.getElementById('membername');
if (!name.value)
return;
let at = name.value.split('@');
let url = at[1] || instancediv.instance.url;
let user = await window.getUser('@' + url, at[0]);
if (instancediv.instance.url !== url) {
user.id += '@' + url;
user.name += '@' + url;
}
if (members.find(member => member.name === user.value))
return;
members.push(user);
document.getElementById('newmembers')
.append(html.node`<p class='member'>${user.name}</p>`);
name.value = '';
}
document.querySelector('#home + .separator').insertAdjacentElement('afterend', html.node`
<form id='createthread' class='column' onsubmit=${event => {
event.preventDefault();
let name = document.getElementById('newthreadname').value;
if (!name) {
document.getElementById('newnameempty').classList.remove('hidden');
return;
}
const perms = document.querySelector(
'input[name="newpermissions"]:checked'
).value;
let permissions;
if (perms === 'public')
permissions = { view_limited: false, post_limited: false };
else if (perms === 'private_post')
permissions = { view_limited: false, post_limited: true };
else if (perms === 'private_view') {
permissions = { view_limited: true, post_limited: true };
// todo: generate key and encrypt
}
instancediv.instance.emit('create_thread',
{ name, permissions, members },
msg => {
if (!msg.success) {
console.log('create_thread error:', msg.message);
return;
}
// since the form exists, this will perform cleanup
newThread();
chooseThread.call(instancediv.children['threads'].children['threadlist']
.children['thread' + msg.id]);
document.getElementById('loadmore').classList.add('hidden');
}
);
}}>
<h3>create thread</h3>
<label for='newthreadname' class='heading'>thread name</label>
<p id='newnameempty' class='hidden'>name cannot be empty</p>
<input type='text' id='newthreadname' />
<p id='permissions'>thread permissions</p>
<input type='radio' name='newpermissions'
id='public' value='public' checked />
<label for='public'>anyone can view and post</label><br>
<input type='radio' name='newpermissions'
id='private_post' value='private_post' />
<label for='private_post'>anyone can view, only members can post</label><br>
<input type='radio' name='newpermissions'
id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br>
<label class='heading' for='membername'>members</label>
<input type='text' id='membername' placeholder='username' onkeydown=${event => {
if (event.key === 'Enter') {
event.preventDefault();
addMember();
}
}} />
<button id='addmember' type='button' onclick=${addMember}>add</button>
<div id='newmembers'>
<p class='member'>${members[0].name}</p>
</div>
<br>
<button id='submitthread' type='submit'>create</button>
</form>`);
for (let create of document.querySelectorAll('#newthread')) {
if (create === this)
create.textContent = 'cancel';
else
create.classList.add('hidden');
}
}
function editThread() {
let form = document.getElementById('editthread');
if (form) {
form.remove();
document.getElementById('edit').textContent = 'edit';
return;
}
form = html.node`
<form id='editthread' class='column' onsubmit=${event => {
event.preventDefault();
let name = document.getElementById('editthreadname').value;
if (!name) {
document.getElementById('nameempty').classList.remove('hidden');
return;
}
const perms = document.querySelector(
'input[name="permissions"]:checked'
).value;
let permissions;
if (perms === 'public')
permissions = { view_limited: false, post_limited: false };
else if (perms === 'private_post')
permissions = { view_limited: false, post_limited: true };
else if (perms === 'private_view') {
permissions = { view_limited: true, post_limited: true };
// todo: generate key and encrypt
}
window.currentInstance.emit('edit_thread',
{ id: window.currentThread.id, name, permissions },
msg => {
if (!msg.success) {
console.log('edit_thread failed: ', msg.message);
return;
}
editThread();
});
}}>
<label for='editthreadname' class='heading'>thread name</label>
<input type='text' id='editthreadname' />
<p id='nameempty' class='hidden'>name cannot be empty</p>
<p id='permissions'>thread permissions</p>
<input type='radio' name='permissions'
id='public' value='public' />
<label for='public'>anyone can view and post</label><br>
<input type='radio' name='permissions'
id='private_post' value='private_post' />
<label for='private_post'>anyone can view, only members can post</label><br>
<input type='radio' name='permissions'
id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br>
<br>
<button id='savethread'>save</button>
</form>`;
form['editthreadname'].value = window.currentThread.name;
if (window.currentThread.permissions.everyone.post.value)
form['public'].checked = true;
else if (window.currentThread.permissions.everyone.view.value)
form['private_post'].checked = true;
else
form['private_view'].checked = true;
document.querySelector('#thread').append(form);
document.getElementById('edit').textContent = 'cancel';
}
function clickedTab(event) {
switchTab(this);
document.getElementById(`thread${window.currentThread.id}`).tab = this.id;
if (this.id === 'messagetab')
loadMessages();
else if (this.id === 'spacetab')
loadSpace();
}
async function loadThreads(instancediv, select) {
function makeThread(thread) {
thread.instance = instancediv.instance;
let node = html.node`
<div class='thread' onclick=${chooseThread}>${
thread.name
}</div>`;
node.id = 'thread' + thread.id;
node.thread = thread;
thread.div = node;
return node;
}
if (!document.getElementById('thread'))
document.body.append(html.node`
<div id='thread' class='column hidden'>
<div id='content'>
<div id='titlebar'>
<span id='title'>thread: <strong id='threadname'>meow</strong></span>
<button id='edit' class='hidden' onclick=${editThread}>edit</button>
</div>
<div id='buttonbar'>
<div id='tabs'>
<button id='messagetab' class='tab active' onclick=${clickedTab}>messages
</button><button id='spacetab' class='tab' onclick=${clickedTab}>space
</button><button id='streamtab' class='tab' onclick=${clickedTab}>streams</button>
</div>
<button id='showmembers' onclick=${() =>
document.getElementById('members').classList.toggle('hidden')
}>members</button>
</div>
<div id='message' class='tabcontent'></div>
<div id='space' class='tabcontent hidden'></div>
<div id='stream' class='tabcontent hidden'></div>
</div>
<hr class='separator' color='#505050'>
<div id='members' class='hidden'>
<p id='visibility'></p>
<p><strong>members</strong></p>
<div id='memberlist'>
</div>
</div>
<hr class='separator' color='#505050'>
</div>`);
if (!instancediv.children['threads']) {
instancediv.append(html.node`
<div id='threads'>
<p><strong>threads</strong></p>
<div id='threadlist'>loading...</div>
<button id='newthread' onclick=${newThread}>create</button>
</div>`);
const threadlist = instancediv.children['threads'].children['threadlist'];
instancediv.instance.emit('list_threads', {}, msg => {
threadlist.replaceChildren(...msg.threads.map(makeThread));
if (select && msg.threads.length)
chooseThread.call(threadlist.firstChild);
});
instancediv.instance.socket.on('thread', thread => {
let el = threadlist.children['thread' + thread.id];
if (el) {
Object.assign(el.thread, thread);
el.textContent = thread.name;
if (window.currentThread?.id === thread.id) {
Object.assign(window.currentThread, thread);
document.getElementById('threadname').textContent = thread.name;
setVisibility();
}
return;
}
threadlist.prepend(makeThread(thread));
});
}
}
export default loadThreads;

View File

@ -16,38 +16,34 @@ create table key (
create table thread (
id integer primary key asc,
name text,
creator integer,
created timestamp default current_timestamp,
foreign key(creator) references user(id)
creator text,
created timestamp default current_timestamp
);
create table permission (
thread integer,
user integer,
user text,
type text,
mutable boolean,
permission text,
value text,
foreign key(user) references user(id),
foreign key(thread) references thread(id)
);
create table member (
thread integer,
user integer,
user text,
created timestamp default current_timestamp,
foreign key(user) references user(id),
foreign key(thread) references thread(id)
);
create table post (
id integer primary key asc,
user integer,
user text,
thread integer,
content text,
encrypted bool,
created timestamp default current_timestamp,
foreign key(user) references user(id),
foreign key(thread) references thread(id)
);

View File

@ -1,63 +0,0 @@
const fs = require('fs');
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const compression = require('compression');
const events = {};
for (let file of fs.readdirSync('./src/events')) {
file = require('./src/events/' + file);
for (const event in file)
events[event] = file[event];
}
const PORT = process.env.PORT || 3435;
const app = express();
app.use(compression());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: true
}
});
app.use(express.static('client'));
global.vybe = {
users: {},
threads: {},
streams: {}
};
io.on('connection', (socket) => {
for (let event in events) {
socket.on(event, (msg, callback) => {
if (!events[event]) {
callback('no such event ' + event);
return;
}
try {
events[event](msg, callback, socket);
}
catch (e) {
console.log(`${event} threw exception: `, e);
}
});
}
socket.on('disconnect', reason => {
let user = vybe.users[socket.username];
if (user)
user.sockets.splice(user.sockets.indexOf(socket), 1);
for (let id in vybe.streams) {
const stream = vybe.streams[id];
delete stream.listeners[socket.id];
if (stream.socket === socket.id)
stream.stop();
}
});
});
server.listen(PORT, () => {
console.log('server running on port ' + PORT);
});

View File

@ -1,8 +1,8 @@
{
"name": "server",
"name": "vybe",
"version": "1.0.0",
"description": "",
"main": "index.js",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
@ -15,6 +15,7 @@
"express": "^4.18.2",
"openpgp": "^5.8.0",
"socket.io": "^4.6.1",
"socket.io-client": "^4.7.5",
"sqlite3": "^5.1.6"
}
}

121
server.js Normal file
View File

@ -0,0 +1,121 @@
const fs = require('fs');
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const ioclient = require('socket.io-client');
const compression = require('compression');
const events = {};
for (let file of fs.readdirSync('./src/events')) {
file = require('./src/events/' + file);
for (const event in file)
events[event] = file[event];
}
function rand32() {
let str = '';
const lookups = 'bcdefghjklmnpqrstvwxyz0123456789'.split('');
while (str.length < 16) {
const n = Math.random() * lookups.length;
str += lookups[Math.floor(n)];
}
return str;
}
let instance;
function saveSettings() {
fs.writeFileSync('instance.json', JSON.stringify(instance, null, 2));
}
if (fs.existsSync('instance.json'))
instance = JSON.parse(fs.readFileSync('instance.json'));
else {
instance = {
id: rand32(),
port: process.env.PORT || 1312,
url: process.env.URL || null
};
saveSettings();
}
if (!instance.url)
console.log(
`!! no instance url !
it will be auto-set by the first user authentication !
it can be set in instance.json .`);
global.vybe = {
instance,
saveSettings,
instances: {},
users: {},
threads: {},
streams: {},
connectInstance: async url => {
let instance = vybe.instances[url];
function connecting(resolve, reject) {
instance.socket.on('connect', resolve);
instance.socket.on('connect_error', error => {
instance.socket.disconnect();
reject(error.message);
});
}
if (instance) {
if (!instance.socket.connected) {
instance.socket.connect();
await new Promise(connecting);
}
}
else {
instance = vybe.instances[url] = {
socket: ioclient('https://' + url)
};
await new Promise(connecting);
}
return instance;
}
};
const app = express();
app.use(compression());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: true
}
});
app.use(express.static('client'));
io.on('connection', (socket) => {
for (let event in events) {
socket.on(event, (msg, callback) => {
if (!events[event]) {
callback('no such event ' + event);
return;
}
try {
events[event](msg, callback, socket);
}
catch (e) {
console.log(`${event} threw exception: `, e);
}
});
}
socket.on('disconnect', reason => {
let user = vybe.users[socket.__userid];
if (user)
user.sockets.splice(user.sockets.indexOf(socket), 1);
for (let id in vybe.streams) {
const stream = vybe.streams[id];
delete stream.listeners[socket.id];
if (stream.socket === socket.id)
stream.stop();
}
});
});
server.listen(instance.port, () => {
console.log('server running on port ' + instance.port);
});

View File

@ -3,7 +3,7 @@ const db = require('./db');
const authwrap = (fn) => async (msg, respond, socket) => {
if (!respond)
respond = () => {};
if (!msg || !msg.__session || socket.auth !== msg.__session) {
if (!msg || !msg.__session || socket.__auth !== msg.__session) {
return respond({
success: false,
message: 'not authenticated'
@ -11,7 +11,7 @@ const authwrap = (fn) => async (msg, respond, socket) => {
}
return await fn({
...msg,
auth_user: vybe.users[socket.username]
auth_user: vybe.users[socket.__userid]
}, respond, socket);
};

View File

@ -13,11 +13,9 @@ const check_permission = async (user_id, thread_id) => {
).rows.length > 0;
let perms = { is_member };
for (let p of permissions.rows) {
if (p.type === 'everyone' && p.value === 'true')
perms[p.permission] = true;
else if (p.type === 'members' && is_member && p.value === 'true')
perms[p.permission] = true;
else if (p.type === 'user' && p.user === user_id && p.value === 'true')
if ((p.type === 'everyone' && p.value === 'true')
|| (p.type === 'members' && is_member && p.value === 'true')
|| (p.type === 'user' && p.user == user_id && p.value === 'true'))
perms[p.permission] = true;
}
return perms;

View File

@ -1,9 +1,10 @@
const sqlite3 = require('sqlite3');
const fs = require('fs');
const dbPath = 'vybe.db';
const path = 'vybe.db';
const db = new sqlite3.Database(dbPath);
const existed = fs.existsSync(path);
const db = new sqlite3.Database(path);
db.query = function (sql, params) {
let self = this;
@ -18,10 +19,9 @@ db.query = function (sql, params) {
};
(async () => {
if (fs.existsSync(dbPath))
return;
if (!existed)
for (let sql of fs.readFileSync('./db/1-init.sql').toString().split(';'))
if (sql)
if (sql.trim())
await db.query(sql);
})();

View File

@ -16,27 +16,25 @@ async function send_message(msg, respond) {
});
}
// add message and send it to everyone
const id = await db.query(
const id = (await db.query(
'insert into post (user, thread, content, encrypted) values (?, ?, ?, ?) returning id',
[msg.auth_user.id, msg.thread, msg.message, msg.encrypted]
);
)).rows[0].id;
// get thread members
const members = (
await db.query(
'select name from user join member on member.user = user.id where member.thread = ?',
[msg.thread]
)
).rows.map((i) => i.name);
await db.query('select user from member where member.thread = ?', msg.thread)
).rows.map(i => i.user);
// get perms
const permissions = await db.query(
"select * from permission where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'",
[msg.thread]);
for (let username in vybe.users) {
if (permissions.rows.length > 0 || members.includes(username)) {
for (let s of vybe.users[username].sockets) {
`select * from permission
where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'`,
msg.thread);
for (let id in vybe.users) {
if (permissions.rows.length > 0 || members.includes(id)) {
for (let s of vybe.users[id].sockets) {
s.emit('new_message', {
id: id.rows[0].id,
username: msg.auth_user.name,
id,
userid: msg.auth_user.id,
name: msg.auth_user.displayname,
content: msg.message,
thread: msg.thread
@ -46,7 +44,7 @@ async function send_message(msg, respond) {
}
return respond({
success: true,
id: id.rows[0].id
id
});
}
@ -69,20 +67,20 @@ async function get_history(msg, respond) {
message: "you can't view this thread"
});
}
const messages = await db.query(
`select coalesce(displayname, name) as name, post.id, content
const messages = (await db.query(
`select coalesce(displayname, name) as name, user as userid, post.id, content
from post
join user on post.user = user.id
left join user on post.user = user.id
where ${msg.before ? 'post.id < ? and' : ''}
thread = ?
order by post.created desc
limit 101`,
msg.before ? [msg.before, msg.thread] : [msg.thread]
);
)).rows;
return respond({
success: true,
messages: messages.rows.slice(0, 100),
more: messages.rows.length > 100
messages: messages.slice(0, 100),
more: messages.length > 100
});
}

View File

@ -17,7 +17,7 @@ async function get_space(msg, respond) {
}
const spans = await db.query(
'select id, content, x, y, scale from span where thread = ? and deleted = false',
[msg.thread]
msg.thread
);
return respond({
success: true,
@ -43,10 +43,11 @@ async function save_span(msg, respond, socket) {
if (msg.id) {
id = msg.id;
if (msg.content)
await db.query('update span set content=?, x=?, y=?, scale=?, deleted=false where id=?',
await db.query(
'update span set content = ?, x = ?, y = ?, scale = ?, deleted = false where id = ?',
[msg.content, msg.x, msg.y, msg.scale, msg.id]);
else
await db.query('update span set deleted=true where id=?', [msg.id]);
await db.query('update span set deleted = true where id = ?', msg.id);
}
else {
id = (await db.query(
@ -56,19 +57,17 @@ async function save_span(msg, respond, socket) {
}
// get thread members
const members = (
await db.query(
'select name from user join member on member.user = user.id where member.thread = ?',
[msg.thread]
)
).rows.map(i => i.name);
await db.query('select user from member where member.thread = ?', msg.thread)
).rows.map(i => i.user);
// get perms
const permissions = await db.query(
"select * from permission where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'",
[msg.thread]
`select * from permission where thread = ?
and type = 'everyone' and value = 'true' and permission = 'view'`,
msg.thread
);
for (let username in vybe.users) {
if (permissions.rows.length > 0 || members.includes(username)) {
for (let s of vybe.users[username].sockets) {
for (let userid in vybe.users) {
if (permissions.rows.length > 0 || members.includes(userid)) {
for (let s of vybe.users[userid].sockets) {
if (s !== socket)
s.emit('span', {
id,

View File

@ -17,25 +17,21 @@ async function stream(msg, respond, socket) {
});
async function send() {
const permissions = await db.query(
"select * from permission where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'",
[msg.thread]);
`select * from permission
where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'`,
msg.thread);
if (permissions.rows.length) {
for (let user in vybe.users) {
user = vybe.users[user];
for (let socket of user.sockets)
for (let id in vybe.users)
for (let socket of vybe.users[id].sockets)
socket.emit('stream', stream);
}
} else {
const members = (
for (let member of (
await db.query(
'select name from user join member on member.user = user.id where member.thread = ?',
[msg.thread]
)
).rows.map((i) => i.name);
for (let member of members) {
let user = vybe.users[member];
if (user)
for (let socket of user.sockets)
'select user from member where member.thread = ?', msg.thread)
).rows) {
member = vybe.users[member.user];
if (member)
for (let socket of member.sockets)
socket.emit('stream', stream);
}
}
@ -47,6 +43,11 @@ async function stream(msg, respond, socket) {
success: false,
message: 'stream not found'
});
if (msg.auth_user.id !== stream.userid)
return respond({
success: false,
message: "stream doesn't belong to user"
});
if (msg.stop) {
stream.stop();
return respond({
@ -66,6 +67,7 @@ async function stream(msg, respond, socket) {
thread.streams.push(stream);
vybe.streams[stream.id] = {
stream,
userid: msg.auth_user.id,
listeners: {},
socket: socket.id,
stop: () => {
@ -91,7 +93,7 @@ async function streamdata(msg, respond) {
message: 'stream not found'
});
}
if (stream.stream.user !== msg.auth_user.name) {
if (stream.userid !== msg.auth_user.id) {
return respond({
success: false,
message: "stream doesn't belong to user"

View File

@ -14,11 +14,16 @@ async function create_thread(msg, respond) {
success: false,
message: 'thread name 200 chars max'
});
if (!Array.isArray(msg.members) || msg.members.find(m => typeof m?.name !== 'string'))
if (!Array.isArray(msg.members) || msg.members.find(m => typeof m?.id !== 'string'))
return respond({
success: false,
message: 'invalid members'
});
if (msg.members.find(m => m.id == msg.auth_user.id)?.permissions?.admin !== 'true')
return respond({
success: false,
message: 'creator missing admin permission'
});
// add to db
const thread_id = (await db.query(
'insert into thread (name, creator) values (?, ?) returning id',
@ -59,25 +64,21 @@ async function create_thread(msg, respond) {
// add members
let members = {};
for (let member of msg.members) {
// get user id
const id = await db.query('select id from user where name = ?', [
member.name,
]);
if (id.rows.length === 0) {
console.log('user not found: ' + member.name);
let id = member.id.split('@');
id = id[1] === vybe.instance.url ? id[0] : member.id;
if (members[id])
continue;
}
await db.query(
'insert into member (thread, user) values (?, ?)',
[thread_id, id.rows[0].id]
[thread_id, id]
);
members[member.name] = true;
members[id] = true;
if (typeof member.permissions === 'object')
for (let permission in member.permissions)
await db.query(`
insert into permission (thread, type, user, mutable, permission, value)
values (?, ?, ?, ?, ?, ?)`,
[thread_id, 'user', id.rows[0].id, true, permission, member.permissions[permission]]);
[thread_id, 'user', id, true, permission, member.permissions[permission]]);
}
let thread = {
id: thread_id,
@ -85,30 +86,31 @@ async function create_thread(msg, respond) {
streams: []
};
if (!msg.permissions?.view_limited) {
for (let username in vybe.users) {
for (let socket of vybe.users[username].sockets) {
for (let id in vybe.users) {
for (let socket of vybe.users[id].sockets) {
socket.emit('thread', {
...thread,
permissions: {
view: true,
post: !msg.permissions?.post_limited || members[username],
admin: username === msg.auth_user.name
post: !msg.permissions?.post_limited || members[id],
admin: id === msg.auth_user.id
}
});
}
}
}
else {
for (let member of msg.members) {
if (!vybe.users[member.name])
for (let member in members) {
member = vybe.users[member];
if (!member)
continue;
for (let socket of vybe.users[member.name].sockets) {
for (let socket of member.sockets) {
socket.emit('thread', {
...thread,
permissions: {
view: true,
post: true,
admin: member.name === msg.auth_user.name
admin: member.id === msg.auth_user.id
}
});
}
@ -127,13 +129,12 @@ async function list_threads(msg, respond) {
`select name, id from thread
join permission on thread.id = permission.thread
left join member on thread.id = member.thread
where permission.permission = 'view'
and permission.value = 'true'
where permission.permission = 'view' and permission.value = 'true'
and ((permission.type = 'everyone') or
permission.type = 'members' and member.user = ?)
group by thread.id
order by thread.created desc`,
[msg.auth_user.id]
msg.auth_user.id
);
threads = await Promise.all(threads.rows.map(async thread => {
thread.streams = vybe.threads[thread.id]?.streams || [];
@ -161,19 +162,25 @@ async function get_thread(msg, respond) {
});
}
let thread = await db.query(
`select thread.name, user.name as user, user.id from thread
`select thread.name, member.user, user.name as username,
coalesce(user.displayname, user.name) as displayname
from thread
left join member on thread.id = member.thread
left join user on user.id = member.user
where thread.id = ?`,
[msg.thread]
msg.thread
);
const permissions = await db.query(
`select type, user, permission, value, mutable
from permission where thread = ?`,
[msg.thread]
msg.thread
);
let members = Object.fromEntries(thread.rows.map(member =>
[member.id, { name: member.user }]
[member.user, {
id: member.user,
name: member.username,
displayname: member.displayname
}]
));
for (let permission of permissions.rows) {
const member = members[permission.user];
@ -207,11 +214,8 @@ async function get_thread(msg, respond) {
id: msg.thread,
name: thread.rows[0].name,
permissions: perms,
members: Object.entries(members).map(member => ({
id: member[0],
...member[1]
})),
streams: vybe.threads[thread.id]?.streams || []
members: Object.values(members),
streams: vybe.threads[msg.thread]?.streams || []
};
return respond({
success: true,
@ -251,7 +255,7 @@ async function edit_thread(msg, respond) {
`select type, permission, value, mutable
from permission
where type != 'user' and thread = ?`,
[msg.id]
msg.id
)).rows) {
(permissions[p.type] || (permissions[p.type] = {}))
[p.permission] = {
@ -324,41 +328,41 @@ async function edit_thread(msg, respond) {
permissions.members.view.value = permissions.members.view.value === 'true';
permissions.members.post.value = permissions.members.post.value === 'true';
let members = Object.fromEntries((await db.query(
`select user.name, user.id from thread
`select member.user from thread
join member on thread.id = member.thread
join user on user.id = member.user
where thread.id = ?`,
[msg.id]
)).rows.map(row => [row.name, row.id]));
msg.id
)).rows.map(row => [row.user, true]));
let thread = {
id: msg.id,
name: msg.name,
streams: vybe.threads[msg.id]?.streams || []
};
if (!msg.permissions?.view_limited) {
for (let username in vybe.users)
for (let socket of vybe.users[username].sockets)
for (let id in vybe.users)
for (let socket of vybe.users[id].sockets)
socket.emit('thread', {
...thread,
permissions: {
view: true,
post: !msg.permissions?.post_limited || username in members,
admin: username === msg.auth_user.name && perms.admin,
post: !msg.permissions?.post_limited || id in members,
admin: id === msg.auth_user.id && perms.admin,
...permissions
}
});
}
else {
for (let membername in members) {
if (!vybe.users[membername])
for (let member in members) {
member = vybe.users[member];
if (!member)
continue;
for (let socket of vybe.users[membername].sockets)
for (let socket of member.sockets)
socket.emit('thread', {
...thread,
permissions: {
view: true,
post: true,
admin: membername === msg.auth_user.name && perms.admin,
admin: member.id === msg.auth_user.id && perms.admin,
...permissions
}
});

View File

@ -4,16 +4,16 @@ const openpgp = require('openpgp');
async function create_user(msg, respond) {
// validate inputs
if (!msg.name) {
if (typeof msg.name !== 'string') {
return respond({
success: false,
message: 'username required'
message: 'invalid username'
});
}
if (!msg.pubkey) {
if (typeof msg.pubkey !== 'string') {
return respond({
success: false,
message: 'public key required'
message: 'invalid public key'
});
}
// ensure username is not taken
@ -53,15 +53,52 @@ async function create_user(msg, respond) {
});
}
async function getUser(id, name) {
return (id ? await db.query(
`select name, id, displayname from user where id = ?`, id)
: await db.query(
`select name, id, displayname from user where name = ?`, name)
).rows[0];
}
async function authenticate(msg, respond, socket) {
if (!msg.name || !msg.message) {
if (typeof msg.name !== 'string'
|| typeof msg.id !== 'string'
|| typeof msg.message !== 'string') {
return respond({
success: false,
message: 'invalid message'
});
}
let user = (await db.query(
`select id, displayname from user where name = ?`, [msg.name])).rows[0];
let user;
let id = msg.id.split('@');
if (id.length !== 2 || !id[1])
return respond({
success: false,
message: 'invalid user id'
});
try {
// verify key and get session auth string
let pubkey = msg.pubkey;
const key = await openpgp.readKey({ armoredKey: pubkey });
const verification = await openpgp.verify({
message: await openpgp.readCleartextMessage({
cleartextMessage: msg.message
}),
verificationKeys: key,
expectSigned: true,
date: new Date(Date.now() + 60000 * 4) // slightly in the future to compensate for some system clocks
});
const data = verification.data.split(' ');
if (data[0] !== 'vybe_auth') {
return respond({
success: false,
message: 'bad auth message'
});
}
if (vybe.instance.url === id[1] || (!vybe.instance.url && !id[0])) {
// this should be user's home instance
user = await getUser(id[0], msg.name);
if (!user) {
return respond({
success: false,
@ -72,33 +109,8 @@ async function authenticate(msg, respond, socket) {
`select user.id from user
join key on key.user = user.id
where name = ? and pubkey = ? and active = true`,
[msg.name, msg.pubkey]
[msg.name, pubkey]
);
try {
const key = await openpgp.readKey({ armoredKey: msg.pubkey });
const verification = await openpgp.verify({
message: await openpgp.readCleartextMessage({
cleartextMessage: msg.message
}),
verificationKeys: key,
expectSigned: true,
date: new Date(Date.now() + 60000 * 4) // slightly in the future to compensate for some system times
});
const data = verification.data.split(' ');
if (data[0] !== 'vybe_auth') {
return respond({
success: false,
message: 'bad auth message'
});
}
if (!user.displayname)
user.displayname = msg.name;
user = vybe.users[msg.name] || (vybe.users[msg.name] = {
...user,
name: msg.name,
sockets: [],
authrequests: {}
});
if (result.rows.length === 0) {
// request auth from logged in sessions
let id = key.getFingerprint().slice(0, 8);
@ -118,14 +130,59 @@ async function authenticate(msg, respond, socket) {
return;
await db.query(
'insert into key (user, pubkey, active) values (?, ?, true)',
[user.id, msg.pubkey]);
[user.id, pubkey]);
}
// default instance url to first authenticated user's location.host
if (!vybe.instance.url) {
vybe.instance.url = id[1];
vybe.saveSettings();
}
}
else {
// connect to user's home instance and ask for their key
try {
let instance = await vybe.connectInstance(id[1]);
await new Promise((resolve, reject) => {
instance.socket.emit('get_keys', { ids: [id[0]] }, msg => {
if (!msg.success) {
console.log('get_keys error: ' + msg.message);
return reject('get_keys error: ' + msg.message);
}
if (!Array.isArray(msg.keys?.[id[0]]))
return reject('got invalid keys');
if (msg.keys[id[0]].indexOf(pubkey) === -1)
return reject('pubkey not authenticated');
resolve();
});
});
user = { id: msg.id, name: msg.name };
}
catch (error) {
console.log(`error authenticating with ${id[1]}: ${error}`);
return respond({
success: false,
message: `error authenticating with ${id[1]}: ${error}`
});
}
}
// this socket is now authenticated
socket.auth = data[1];
socket.username = msg.name;
if (!user.displayname)
user.displayname = user.name;
user = vybe.users[user.id] || (vybe.users[user.id] = {
...user,
sockets: [],
authrequests: {}
});
socket.__auth = data[1];
socket.__userid = user.id;
user.sockets.push(socket);
respond({
success: true,
instance: {
id: vybe.instance.id
},
id: user.id,
name: user.name,
displayname: user.displayname,
authrequests: Object.entries(user.authrequests).map(authrequest => ({
id: authrequest[0],
@ -143,7 +200,7 @@ async function authenticate(msg, respond, socket) {
}
async function authorize_key(msg, respond) {
let authrequest = vybe.users[msg.auth_user.name].authrequests[msg.id];
let authrequest = vybe.users[msg.auth_user.id].authrequests[msg.id];
if (!authrequest) {
return respond({
success: false,
@ -158,43 +215,58 @@ async function authorize_key(msg, respond) {
async function update_user(msg, respond) {
if ((await db.query(
`update user set displayname = ?
where name = ?
returning id`,
[msg.displayname, msg.auth_user.name])).rows.length === 0)
`select id from user where id = ?`, msg.auth_user.id)).rows.length === 0)
return respond({
success: false,
message: 'user not found'
});
vybe.users[msg.auth_user.name].displayname = msg.displayname;
await db.query(
`update user set displayname = ? where id = ?`,
[msg.displayname, msg.auth_user.id]);
vybe.users[msg.auth_user.id].displayname = msg.displayname;
respond({
success: true
});
}
async function get_user(msg, respond) {
if (typeof msg.name !== 'string' && typeof msg.id !== 'string')
return respond({
success: false,
message: 'name or id required'
});
let user = await getUser(msg.id, msg.name);
if (!user)
return respond({
success: false,
message: 'user not found'
});
if (!user.displayname)
user.displayname = user.name;
return respond({
success: true,
user
});
}
async function get_keys(msg, respond) {
// validate inputs
if (!msg.names) {
if (!Array.isArray(msg.ids)) {
return respond({
success: false,
message: 'user names required'
message: 'invalid ids'
});
}
if (typeof msg.names !== 'object') {
return respond({
success: false,
message: "can't iterate user names"
});
}
const keys = await db.query( // todo: encryption !
`select name from user where name in
(${msg.names.map((i) => '?').join(',')})`,
msg.names
);
let keys = Object.fromEntries(msg.ids.map(id => [id, []]));
(await db.query(
`select pubkey, user from key where user in
(${msg.ids.map(i => '?').join(',')})`,
msg.ids
)).rows.forEach(key => keys[key.user].push(key.pubkey));
// respond
respond({
success: true,
keys: keys.rows
keys
});
}
@ -203,5 +275,6 @@ module.exports = {
authenticate,
authorize_key: authwrap(authorize_key),
update_user: authwrap(update_user),
get_keys: authwrap(get_keys)
get_user,
get_keys
};

View File

@ -1,29 +0,0 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 2
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.json]
indent_size = 2
[*.{html,js,md}]
block_comment_start = /**
block_comment = *
block_comment_end = */

View File

@ -1,24 +0,0 @@
## editors
/.idea
/.vscode
## system files
.DS_Store
## npm
/node_modules/
/npm-debug.log
## testing
/coverage/
## temp folders
/.tmp/
# build
/_site/
/dist/
/out-tsc/
storybook-static
custom-elements.json

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 vyber-client
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,31 +0,0 @@
<p align="center">
<img width="200" src="https://open-wc.org/hero.png"></img>
</p>
## Open-wc Starter App
[![Built with open-wc recommendations](https://img.shields.io/badge/built%20with-open--wc-blue.svg)](https://github.com/open-wc)
## Quickstart
To get started:
```bash
npm init @open-wc
# requires node 10 & npm 6 or higher
```
## Scripts
- `start` runs your app for development, reloading on file changes
- `start:build` runs your app after it has been built using the build command
- `build` builds your app and outputs it in your `dist` directory
- `test` runs your test suite with Web Test Runner
- `lint` runs the linter for your project
- `format` fixes linting and formatting errors
## Tooling configs
For most of the tools, the configuration is in the `package.json` to reduce the amount of files in your project.
If you customize the configuration a lot, you can consider moving them to individual files.

View File

@ -1,29 +0,0 @@
<svg
width="244px"
height="244px"
viewBox="0 0 244 244"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="linearGradient-1">
<stop stop-color="#9B00FF" offset="0%"></stop>
<stop stop-color="#0077FF" offset="100%"></stop>
</linearGradient>
</defs>
<g
id="Page-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<path
d="M205.639259,176.936244 C207.430887,174.217233 209.093339,171.405629 210.617884,168.510161 M215.112174,158.724316 C216.385153,155.50304 217.495621,152.199852 218.433474,148.824851 M220.655293,138.874185 C221.231935,135.482212 221.637704,132.03207 221.863435,128.532919 M222,118.131039 C221.860539,114.466419 221.523806,110.85231 221.000113,107.299021 M218.885321,96.8583653 C218.001583,93.4468963 216.942225,90.1061026 215.717466,86.8461994 M211.549484,77.3039459 C209.957339,74.1238901 208.200597,71.0404957 206.290425,68.0649233 M200.180513,59.5598295 C181.848457,36.6639805 153.655709,22 122.036748,22 C66.7879774,22 22,66.771525 22,122 C22,177.228475 66.7879774,222 122.036748,222 C152.914668,222 180.52509,208.015313 198.875424,186.036326"
id="Shape"
stroke="url(#linearGradient-1)"
stroke-width="42.0804674"
></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,28 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="Description" content="Put your description here.">
<base href="/">
<style>
html,
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background-color: #ededed;
}
</style>
<title>vyber-client</title>
</head>
<body>
<vyber-client></vyber-client>
<script type="module" src="./src/vyber-client.js"></script>
</body>
</html>

View File

@ -1,35 +0,0 @@
{
"name": "vyber-client",
"description": "Webcomponent vyber-client following open-wc recommendations",
"license": "MIT",
"author": "vyber-client",
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "rimraf dist && rollup -c rollup.config.js && npm run analyze -- --exclude dist",
"start:build": "web-dev-server --root-dir dist --app-index index.html --open",
"analyze": "cem analyze --litelement",
"start": "web-dev-server"
},
"dependencies": {
"lit": "^2.0.2"
},
"devDependencies": {
"@babel/preset-env": "^7.16.4",
"@custom-elements-manifest/analyzer": "^0.4.17",
"@open-wc/building-rollup": "^2.0.2",
"@rollup/plugin-babel": "^5.3.0",
"@rollup/plugin-node-resolve": "^13.0.6",
"@web/dev-server": "^0.1.34",
"@web/rollup-plugin-html": "^1.11.0",
"@web/rollup-plugin-import-meta-assets": "^1.0.7",
"babel-plugin-template-html-minifier": "^4.1.0",
"deepmerge": "^4.2.2",
"esbuild": "^0.17.19",
"rimraf": "^3.0.2",
"rollup": "^2.60.0",
"rollup-plugin-esbuild": "^5.0.0",
"rollup-plugin-workbox": "^6.2.0"
},
"customElements": "custom-elements.json"
}

View File

@ -1,71 +0,0 @@
import nodeResolve from '@rollup/plugin-node-resolve';
import babel from '@rollup/plugin-babel';
import html from '@web/rollup-plugin-html';
import { importMetaAssets } from '@web/rollup-plugin-import-meta-assets';
import esbuild from 'rollup-plugin-esbuild';
import { generateSW } from 'rollup-plugin-workbox';
import path from 'path';
export default {
input: 'index.html',
output: {
entryFileNames: '[hash].js',
chunkFileNames: '[hash].js',
assetFileNames: '[hash][extname]',
format: 'es',
dir: 'dist',
},
preserveEntrySignatures: false,
plugins: [
/** Enable using HTML as rollup entrypoint */
html({
minify: true,
injectServiceWorker: true,
serviceWorkerPath: 'dist/sw.js',
}),
/** Resolve bare module imports */
nodeResolve(),
/** Minify JS, compile JS to a lower language target */
esbuild({
minify: true,
target: ['chrome64', 'firefox67', 'safari11.1'],
}),
/** Bundle assets references via import.meta.url */
importMetaAssets(),
/** Minify html and css tagged template literals */
babel({
plugins: [
[
require.resolve('babel-plugin-template-html-minifier'),
{
modules: { lit: ['html', { name: 'css', encapsulation: 'style' }] },
failOnError: false,
strictCSS: true,
htmlMinifier: {
collapseWhitespace: true,
conservativeCollapse: true,
removeComments: true,
caseSensitive: true,
minifyCSS: true,
},
},
],
],
}),
/** Create and inject a service worker */
generateSW({
globIgnores: ['polyfills/*.js', 'nomodule-*.js'],
navigateFallback: '/index.html',
// where to output the generated sw
swDest: path.join('dist', 'sw.js'),
// directory to match patterns against to be precached
globDirectory: path.join('dist'),
// cache any html js and css by default
globPatterns: ['**/*.{html,js,css,webmanifest}'],
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [{ urlPattern: 'polyfills/*.js', handler: 'CacheFirst' }],
}),
],
};

View File

@ -1,88 +0,0 @@
import { LitElement, html, css } from 'lit';
const logo = new URL('../assets/open-wc-logo.svg', import.meta.url).href;
class VyberClient extends LitElement {
static properties = {
header: { type: String },
}
static styles = css`
:host {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
font-size: calc(10px + 2vmin);
color: #1a2b42;
max-width: 960px;
margin: 0 auto;
text-align: center;
background-color: var(--vyber-client-background-color);
}
main {
flex-grow: 1;
}
.logo {
margin-top: 36px;
animation: app-logo-spin infinite 20s linear;
}
@keyframes app-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.app-footer {
font-size: calc(12px + 0.5vmin);
align-items: center;
}
.app-footer a {
margin-left: 5px;
}
`;
constructor() {
super();
this.header = 'My app';
}
render() {
return html`
<main>
<div class="logo"><img alt="open-wc logo" src=${logo} /></div>
<h1>${this.header}</h1>
<p>Edit <code>src/VyberClient.js</code> and save to reload.</p>
<a
class="app-link"
href="https://open-wc.org/guides/developing-components/code-examples/"
target="_blank"
rel="noopener noreferrer"
>
Code examples
</a>
</main>
<p class="app-footer">
🚽 Made with love by
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/open-wc"
>open-wc</a
>.
</p>
`;
}
}
customElements.define('vyber-client', VyberClient);

View File

@ -1,26 +0,0 @@
// import { hmrPlugin, presets } from '@open-wc/dev-server-hmr';
/** Use Hot Module replacement by adding --hmr to the start command */
const hmr = process.argv.includes('--hmr');
export default /** @type {import('@web/dev-server').DevServerConfig} */ ({
open: '/',
watch: !hmr,
/** Resolve bare module imports */
nodeResolve: {
exportConditions: ['browser', 'development'],
},
/** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
// esbuildTarget: 'auto'
/** Set appIndex to enable SPA routing */
appIndex: './index.html',
plugins: [
/** Use Hot Module Replacement by uncommenting. Requires @open-wc/dev-server-hmr plugin */
// hmr && hmrPlugin({ exclude: ['**/*/node_modules/**/*'], presets: [presets.litElement] }),
],
// See documentation for all available options
});

File diff suppressed because it is too large Load Diff