├── src
├── boot
│ ├── .gitkeep
│ └── global-components.js
├── css
│ ├── add-tailwind.css
│ └── quasar.variables.scss
├── pool.js
├── App.vue
├── pages
│ ├── SearchFollow.vue
│ ├── Error404.vue
│ ├── Home.vue
│ ├── Chats.vue
│ ├── Notifications.vue
│ ├── Messages.vue
│ ├── Profile.vue
│ ├── Settings.vue
│ └── Event.vue
├── store
│ ├── store-flag.d.ts
│ ├── index.js
│ ├── storage.js
│ ├── eventize.js
│ ├── relayize.js
│ ├── state.js
│ ├── getters.js
│ ├── unread.js
│ ├── mutations.js
│ └── actions.js
├── utils
│ ├── helpers.js
│ └── mixin.js
├── index.template.html
├── router
│ ├── index.js
│ └── routes.js
├── components
│ ├── RawEventData.vue
│ ├── Thread.vue
│ ├── Publish.vue
│ ├── Follow.vue
│ ├── Reply.vue
│ ├── Markdown.vue
│ ├── Balloon.vue
│ └── Post.vue
├── db.js
├── layouts
│ └── MainLayout.vue
└── worker-db.js
├── public
├── _redirects
├── bird.png
├── favicon.ico
├── bird-colors.png
└── bird-outline.png
├── .eslintignore
├── .editorconfig
├── .prettierrc.yaml
├── .vscode
├── settings.json
└── extensions.json
├── .postcssrc.js
├── tailwind.config.js
├── babel.config.js
├── README.md
├── .gitignore
├── jsconfig.json
├── package.json
├── quasar.conf.js
└── .eslintrc.js
/src/boot/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/public/bird.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melvincarvalho/branle/master/public/bird.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melvincarvalho/branle/master/public/favicon.ico
--------------------------------------------------------------------------------
/src/css/add-tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/public/bird-colors.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melvincarvalho/branle/master/public/bird-colors.png
--------------------------------------------------------------------------------
/public/bird-outline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melvincarvalho/branle/master/public/bird-outline.png
--------------------------------------------------------------------------------
/src/pool.js:
--------------------------------------------------------------------------------
1 | import {relayPool} from 'nostr-tools'
2 |
3 | export const pool = relayPool()
4 |
5 | pool.setPolicy('randomChoice', 3)
6 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /dist
2 | /src-bex/www
3 | /src-capacitor
4 | /src-cordova
5 | /.quasar
6 | /node_modules
7 | .eslintrc.js
8 | babel.config.js
9 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.prettierrc.yaml:
--------------------------------------------------------------------------------
1 | arrowParens: avoid
2 | bracketSpacing: false
3 | jsxBracketSameLine: false
4 | printWidth: 80
5 | proseWrap: preserve
6 | semi: false
7 | singleQuote: true
8 | trailingComma: none
9 | useTabs: false
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "vetur.validation.template": false,
3 | "vetur.format.enable": false,
4 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
5 |
6 | "vetur.experimental.templateInterpolationService": true
7 | }
8 |
--------------------------------------------------------------------------------
/.postcssrc.js:
--------------------------------------------------------------------------------
1 | // https://github.com/michael-ciniawsky/postcss-load-config
2 |
3 | module.exports = {
4 | plugins: [
5 | require('tailwindcss'),
6 | // to edit target browsers: use "browserslist" field in package.json
7 | require('autoprefixer')
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line
2 | module.exports = {
3 | content: ['./src/**/*.{vue,html}'],
4 | theme: {
5 | extend: {
6 | colors: {
7 | primary: '#DB4655'
8 | }
9 | }
10 | },
11 | plugins: [],
12 | important: true
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/SearchFollow.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
15 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "octref.vetur"
6 | ],
7 | "unwantedRecommendations": [
8 | "hookyqr.beautify",
9 | "dbaeumer.jshint",
10 | "ms-vscode.vscode-typescript-tslint-plugin"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/src/store/store-flag.d.ts:
--------------------------------------------------------------------------------
1 | // THIS FEATURE-FLAG FILE IS AUTOGENERATED,
2 | // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
3 | import 'quasar/dist/types/feature-flag'
4 |
5 | declare module 'quasar/dist/types/feature-flag' {
6 | interface QuasarFeatureFlags {
7 | store: true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | module.exports = api => {
4 | return {
5 | presets: [
6 | [
7 | '@quasar/babel-preset-app',
8 | api.caller(caller => caller && caller.target === 'node')
9 | ? { targets: { node: 'current' } }
10 | : {}
11 | ]
12 | ]
13 | }
14 | }
15 |
16 |
--------------------------------------------------------------------------------
/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 | export function getElementFullHeight(element) {
2 | let styles = window.getComputedStyle(element)
3 | let margin =
4 | parseFloat(styles['marginTop']) + parseFloat(styles['marginBottom'])
5 |
6 | return Math.ceil(element.offsetHeight + margin)
7 | }
8 |
9 | export function isElementFullyScrolled(element) {
10 | return (
11 | element.scrollHeight - Math.abs(element.scrollTop) === element.clientHeight
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import {createStore} from 'vuex'
2 |
3 | import state from './state'
4 | import * as getters from './getters'
5 | import * as mutations from './mutations'
6 | import * as actions from './actions'
7 | import storage from './storage'
8 | import eventize from './eventize'
9 | import relayize from './relayize'
10 | import unread from './unread'
11 |
12 | export default createStore({
13 | state,
14 | getters,
15 | mutations,
16 | actions,
17 | plugins: [storage, eventize, relayize, unread]
18 | })
19 |
--------------------------------------------------------------------------------
/src/store/storage.js:
--------------------------------------------------------------------------------
1 | import {LocalStorage} from 'quasar'
2 |
3 | export default function (store) {
4 | store.subscribe(({type, payload}, state) => {
5 | switch (type) {
6 | case 'setKeys':
7 | LocalStorage.set('keys', state.keys)
8 | break
9 | case 'haveReadNotifications':
10 | LocalStorage.set('lastNotificationRead', state.lastNotificationRead)
11 | break
12 | case 'haveReadMessage':
13 | LocalStorage.set('lastMessageRead', state.lastMessageRead)
14 | break
15 | }
16 | })
17 | }
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # branle
2 |
3 | a twitter-like nostr client forked off from https://github.com/arcbtc/nostr.
4 |
5 | ## Install the dependencies
6 | ```bash
7 | yarn
8 | ```
9 |
10 | ### Start the app in development mode (hot-code reloading, error reporting, etc.)
11 | ```bash
12 | quasar dev
13 | ```
14 |
15 | ### Lint the files
16 | ```bash
17 | yarn run lint
18 | ```
19 |
20 | ### Build the app for production
21 | ```bash
22 | quasar build
23 | ```
24 |
25 | ### Customize the configuration
26 | See [Configuring quasar.conf.js](https://quasar.dev/quasar-cli/quasar-conf-js).
27 |
--------------------------------------------------------------------------------
/src/pages/Error404.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
404
7 |
8 |
Oops. Nothing here...
9 |
10 |
19 |
20 |
21 |
22 |
23 |
28 |
--------------------------------------------------------------------------------
/src/index.template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= productName %>
5 |
6 |
7 |
8 |
9 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .thumbs.db
3 | node_modules
4 |
5 | # Quasar core related directories
6 | .quasar
7 | /dist
8 |
9 | # Cordova related directories and files
10 | /src-cordova/node_modules
11 | /src-cordova/platforms
12 | /src-cordova/plugins
13 | /src-cordova/www
14 |
15 | # Capacitor related directories and files
16 | /src-capacitor/www
17 | /src-capacitor/node_modules
18 |
19 | # BEX related directories and files
20 | /src-bex/www
21 | /src-bex/js/core
22 |
23 | # Log files
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # Editor directories and files
29 | .idea
30 | *.suo
31 | *.ntvs*
32 | *.njsproj
33 | *.sln
34 |
35 | # Local Netlify folder
36 | .netlify
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "src/*": [
6 | "src/*"
7 | ],
8 | "app/*": [
9 | "*"
10 | ],
11 | "components/*": [
12 | "src/components/*"
13 | ],
14 | "layouts/*": [
15 | "src/layouts/*"
16 | ],
17 | "pages/*": [
18 | "src/pages/*"
19 | ],
20 | "assets/*": [
21 | "src/assets/*"
22 | ],
23 | "boot/*": [
24 | "src/boot/*"
25 | ],
26 | "vue$": [
27 | "node_modules/vue/dist/vue.runtime.esm-bundler.js"
28 | ]
29 | }
30 | },
31 | "exclude": [
32 | "dist",
33 | ".quasar",
34 | "node_modules"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/src/store/eventize.js:
--------------------------------------------------------------------------------
1 | import {debounce} from 'quasar'
2 |
3 | export const publishContactList = debounce(store => {
4 | store.dispatch('publishContactList')
5 | }, 10000)
6 |
7 | export default function (store) {
8 | store.subscribe(({type, payload}, state) => {
9 | switch (type) {
10 | // these mutations change the state after user manual inputs
11 | // different from 'setRelays' and 'setFollowing', which change the state
12 | // in bulk and are committed only on startup
13 | case 'addRelay':
14 | case 'removeRelay':
15 | case 'setRelayOpt':
16 | case 'follow':
17 | case 'unfollow':
18 | // make an event kind3 and publish it
19 | publishContactList(store)
20 | break
21 | }
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/mixin.js:
--------------------------------------------------------------------------------
1 | import relative from 'relative-date'
2 | import {date} from 'quasar'
3 |
4 | export default {
5 | methods: {
6 | json(o) {
7 | return JSON.stringify(o, null, 2)
8 | },
9 |
10 | log(o) {
11 | return console.log(o)
12 | },
13 |
14 | toProfile(pubkey) {
15 | this.$router.push('/' + pubkey)
16 | },
17 |
18 | toEvent(id) {
19 | this.$router.push('/event/' + id)
20 | },
21 |
22 | shorten(str) {
23 | return str.slice(0, 3) + '…' + str.slice(-4)
24 | },
25 |
26 | niceDate(value) {
27 | if (value + 60 * 60 /* an hour */ > Date.now() / 1000) {
28 | return relative(value * 1000)
29 | }
30 |
31 | return date.formatDate(value * 1000, 'YYYY MMM D h:mm A')
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/boot/global-components.js:
--------------------------------------------------------------------------------
1 | import RawEventData from '../components/RawEventData.vue'
2 | import Markdown from '../components/Markdown.vue'
3 | import Publish from '../components/Publish.vue'
4 | import Balloon from '../components/Balloon.vue'
5 | import Thread from '../components/Thread.vue'
6 | import Follow from '../components/Follow.vue'
7 | import Reply from '../components/Reply.vue'
8 | import Post from '../components/Post.vue'
9 |
10 | export default ({app}) => {
11 | app.component('RawEventData', RawEventData)
12 | app.component('Markdown', Markdown)
13 | app.component('Publish', Publish)
14 | app.component('Balloon', Balloon)
15 | app.component('Thread', Thread)
16 | app.component('Follow', Follow)
17 | app.component('Reply', Reply)
18 | app.component('Post', Post)
19 | }
20 |
--------------------------------------------------------------------------------
/src/router/index.js:
--------------------------------------------------------------------------------
1 | import {route} from 'quasar/wrappers'
2 | import {createRouter, createWebHistory, createWebHashHistory} from 'vue-router'
3 | import routes from './routes'
4 |
5 | export default route(() => {
6 | const createHistory =
7 | process.env.VUE_ROUTER_MODE === 'history'
8 | ? createWebHistory
9 | : createWebHashHistory
10 |
11 | const Router = createRouter({
12 | scrollBehavior: () => ({left: 0, top: 0}),
13 | routes,
14 |
15 | // Leave this as is and make changes in quasar.conf.js instead!
16 | // quasar.conf.js -> build -> vueRouterMode
17 | // quasar.conf.js -> build -> publicPath
18 | history: createHistory(
19 | process.env.MODE === 'ssr' ? void 0 : process.env.VUE_ROUTER_BASE
20 | )
21 | })
22 |
23 | return Router
24 | })
25 |
--------------------------------------------------------------------------------
/src/css/quasar.variables.scss:
--------------------------------------------------------------------------------
1 | // Quasar SCSS (& Sass) Variables
2 | // --------------------------------------------------
3 | // To customize the look and feel of this app, you can override
4 | // the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
5 |
6 | // Check documentation for full list of Quasar variables
7 |
8 | // Your own variables (that are declared here) and Quasar's own
9 | // ones will be available out of the box in your .vue/.scss/.sass files
10 |
11 | // It's highly recommended to change the default colors
12 | // to match your app's branding.
13 | // Tip: Use the "Theme Builder" on Quasar's documentation website.
14 |
15 | $primary: #DB4655;
16 | $secondary: darken(#8BB3EA, 30);
17 | $accent: #ccffff;
18 |
19 | $positive: #21ba45;
20 | $negative: #c10015;
21 | $info: #31ccec;
22 | $warning: #f2c037;
23 |
--------------------------------------------------------------------------------
/src/components/RawEventData.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Raw Event Data
6 |
7 | {{ json(cleaned) }}
8 |
9 |
10 |
11 |
12 |
36 |
--------------------------------------------------------------------------------
/src/store/relayize.js:
--------------------------------------------------------------------------------
1 | import {debounce} from 'quasar'
2 |
3 | import {pool} from '../pool'
4 |
5 | const addRelay = debounce((store, url) => {
6 | pool.addRelay(url)
7 | }, 10000)
8 |
9 | const removeRelay = debounce((store, url) => {
10 | pool.removeRelay(url)
11 | }, 10000)
12 |
13 | const replaceRelay = debounce((store, url, policy) => {
14 | pool.removeRelay(url)
15 | pool.addRelay(url, policy)
16 | store.dispatch('restartMainSubscription')
17 | }, 10000)
18 |
19 | export default function (store) {
20 | store.subscribe(({type, payload}, state) => {
21 | switch (type) {
22 | case 'addRelay':
23 | addRelay(store, payload)
24 | break
25 | case 'removeRelay':
26 | removeRelay(store, payload)
27 | break
28 | case 'setRelayOpt':
29 | replaceRelay(store, payload.url, state.relays[payload.url])
30 | break
31 |
32 | case 'follow':
33 | case 'unfollow':
34 | store.dispatch('restartMainSubscription')
35 | break
36 | }
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/src/store/state.js:
--------------------------------------------------------------------------------
1 | import {LocalStorage} from 'quasar'
2 |
3 | export default function () {
4 | return {
5 | keys: LocalStorage.getItem('keys') || {pub: '00'}, // { mnemonic, priv, pub }
6 | relays: {
7 | // 'wss://nostr.rocks': {read: true, write: true},
8 | 'wss://relayer.fiatjaf.com': {read: true, write: true},
9 | // 'wss://nostrrr.bublina.eu.org': {read: true, write: true},
10 | 'wss://nostr-pub.wellorder.net': {read: true, write: true}
11 | // 'wss://nostr-relay.freeberty.net': {read: true, write: true},
12 | // 'wss://freedom-relay.herokuapp.com/ws': {read: true, write: true}
13 | }, // { [url]: {} }
14 | following: [], // [ pubkeys... ]
15 |
16 | profilesCache: {}, // { [pubkey]: {name, about, picture, ...} }
17 | profilesCacheLRU: [], // [ pubkeys... ]
18 | contactListCache: {}, // { [pubkey]: {name, about, picture, ...} }
19 | contactListCacheLRU: [], // [ pubkeys... ]
20 |
21 | lastMessageRead: LocalStorage.getItem('lastMessageRead') || {},
22 | unreadMessages: {},
23 |
24 | lastNotificationRead: LocalStorage.getItem('lastNotificationRead') || 0,
25 | unreadNotifications: 0
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/store/getters.js:
--------------------------------------------------------------------------------
1 | import Identicon from 'identicon.js'
2 |
3 | export function hasName(state) {
4 | return pubkey => !!state.profilesCache[pubkey]
5 | }
6 |
7 | export function displayName(state) {
8 | return pubkey => {
9 | let pubShort = pubkey.slice(0, 3) + '...' + pubkey.slice(-4)
10 | let {name = pubShort} = state.profilesCache[pubkey] || {}
11 | return name
12 | }
13 | }
14 |
15 | export function avatar(state) {
16 | return pubkey => {
17 | let {
18 | picture = 'data:image/png;base64,' + new Identicon(pubkey, 40).toString()
19 | } = state.profilesCache[pubkey] || {}
20 | return picture
21 | }
22 | }
23 |
24 | export function profileDescription(state) {
25 | return pubkey => {
26 | let {about = ''} = state.profilesCache[pubkey] || {}
27 | return about
28 | }
29 | }
30 |
31 | export function contacts(state) {
32 | return (pubkey, short = true) =>
33 | state.contactListCache[pubkey]?.slice(0, short ? 6 : Math.inf)
34 | }
35 |
36 | export function hasMoreContacts(state) {
37 | return pubkey => state.contactListCache[pubkey]?.length > 6
38 | }
39 |
40 | export function unreadChats(state) {
41 | delete state.unreadMessages[state.keys.pub]
42 | return Object.values(state.unreadMessages).filter(v => v).length
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Thread.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
13 |
14 |
.
15 |
.
16 |
.
17 |
18 |
19 |
20 |
21 |
22 |
23 |
53 |
--------------------------------------------------------------------------------
/src/store/unread.js:
--------------------------------------------------------------------------------
1 | import {
2 | onNewMention,
3 | onNewAnyMessage,
4 | dbGetChats,
5 | dbGetUnreadMessages,
6 | dbGetUnreadNotificationsCount
7 | } from '../db'
8 |
9 | export default function (store) {
10 | const setUnreadNotifications = async () => {
11 | store.commit(
12 | 'setUnreadNotifications',
13 | await dbGetUnreadNotificationsCount(
14 | store.state.keys.pub,
15 | store.state.lastNotificationRead
16 | )
17 | )
18 | }
19 |
20 | const setUnreadMessages = async peer => {
21 | store.commit('setUnreadMessages', {
22 | peer,
23 | count: await dbGetUnreadMessages(
24 | peer,
25 | store.state.lastMessageRead[peer] || 0
26 | )
27 | })
28 | }
29 |
30 | onNewMention(store.state.keys.pub, setUnreadNotifications)
31 | onNewAnyMessage(event => {
32 | if (event.pubkey === store.state.keys.pub) return
33 | setUnreadMessages(event.pubkey)
34 | })
35 |
36 | setUnreadNotifications()
37 | dbGetChats().then(chats => {
38 | chats.forEach(chat => {
39 | setUnreadMessages(chat.peer)
40 | })
41 | })
42 |
43 | store.subscribe(({type, payload}, state) => {
44 | switch (type) {
45 | case 'haveReadNotifications':
46 | setUnreadNotifications()
47 | break
48 | case 'haveReadMessage':
49 | setUnreadMessages(payload)
50 | break
51 | }
52 | })
53 | }
54 |
--------------------------------------------------------------------------------
/src/router/routes.js:
--------------------------------------------------------------------------------
1 | const routes = [
2 | {
3 | path: '/',
4 | component: () => import('layouts/MainLayout.vue'),
5 | children: [
6 | {
7 | path: '/',
8 | component: () => import('pages/Home.vue'),
9 | name: 'home'
10 | },
11 | {
12 | path: '/follow',
13 | component: () => import('pages/SearchFollow.vue'),
14 | name: 'follow'
15 | },
16 | {
17 | path: '/settings',
18 | component: () => import('pages/Settings.vue'),
19 | name: 'settings'
20 | },
21 | {
22 | path: '/messages',
23 | component: () => import('pages/Chats.vue'),
24 | name: 'messages'
25 | },
26 | {
27 | path: '/messages/:pubkey',
28 | component: () => import('pages/Messages.vue'),
29 | name: 'chat'
30 | },
31 | {
32 | path: '/event/:eventId',
33 | component: () => import('pages/Event.vue'),
34 | name: 'event'
35 | },
36 | {
37 | path: '/notifications',
38 | component: () => import('pages/Notifications.vue'),
39 | name: 'notifications'
40 | },
41 | {
42 | path: '/:pubkey',
43 | component: () => import('pages/Profile.vue'),
44 | name: 'profile'
45 | }
46 | ]
47 | },
48 | {
49 | path: '/:catchAll(.*)*',
50 | component: () => import('pages/Error404.vue')
51 | }
52 | ]
53 |
54 | export default routes
55 |
--------------------------------------------------------------------------------
/src/components/Publish.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
13 |
19 |
20 |
21 |
22 |
23 |
24 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "branle",
3 | "version": "0.0.1",
4 | "description": "a twitter-like nostr client",
5 | "productName": "branle",
6 | "author": "fiatjaf ",
7 | "private": true,
8 | "scripts": {
9 | "lint": "eslint --ext .js,.vue ./",
10 | "dev": "quasar dev --port 3001",
11 | "publish": "rm -r dist/spa && quasar build && netlify deploy --dir=dist/spa/ --prod"
12 | },
13 | "dependencies": {
14 | "@quasar/extras": "^1.0.0",
15 | "core-js": "^3.6.5",
16 | "identicon.js": "^2.3.3",
17 | "markdown-it": "^12.3.0",
18 | "nostr-tools": "^0.16.2",
19 | "pouchdb-adapter-idb": "6",
20 | "pouchdb-core": "6",
21 | "pouchdb-mapreduce": "6",
22 | "pouchdb-upsert": "^2.2.0",
23 | "quasar": "^2.0.0",
24 | "readable-stream": "^3.6.0",
25 | "relative-date": "^1.1.3",
26 | "stream": "^0.0.2",
27 | "tailwindcss": "^3.0.1",
28 | "vuex": "^4.0.1"
29 | },
30 | "devDependencies": {
31 | "@babel/eslint-parser": "^7.13.14",
32 | "@quasar/app": "^3.0.0",
33 | "eslint": "^7.14.0",
34 | "eslint-config-prettier": "^8.1.0",
35 | "eslint-plugin-vue": "^7.0.0",
36 | "eslint-webpack-plugin": "^2.4.0"
37 | },
38 | "browserslist": [
39 | "last 10 Chrome versions",
40 | "last 10 Firefox versions",
41 | "last 4 Edge versions",
42 | "last 7 Safari versions",
43 | "last 8 Android versions",
44 | "last 8 ChromeAndroid versions",
45 | "last 8 FirefoxAndroid versions",
46 | "last 10 iOS versions",
47 | "last 5 Opera versions"
48 | ],
49 | "engines": {
50 | "node": ">= 12.22.1",
51 | "npm": ">= 6.13.4",
52 | "yarn": ">= 1.21.1"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/pages/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Home
4 |
5 |
6 |
7 |
8 |
15 |
16 |
17 |
18 |
19 |
71 |
--------------------------------------------------------------------------------
/src/pages/Chats.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Encrypted Chat
4 |
5 |
6 |
13 |
14 |
15 |
16 |
17 |
18 |
24 | {{ $store.state.unreadMessages[chat.peer] }}
25 |
26 |
27 |
28 | {{ $store.getters.displayName(chat.peer) }}
29 |
30 |
31 | {{ niceDate(chat.lastMessage) }}
32 |
33 |
34 |
35 |
36 |
37 |
38 | Start a chat by clicking at the
39 | icon on
40 | someone's profile page.
41 |
42 |
43 |
44 |
45 |
46 |
65 |
--------------------------------------------------------------------------------
/src/components/Follow.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
20 |
21 |
22 |
23 |
24 | Following
25 |
26 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {{ $store.getters.displayName(pubkey) }}
41 |
42 |
43 |
44 |
45 | When you follow someone they will show up here.
46 |
47 |
48 |
49 |
50 |
51 |
73 |
--------------------------------------------------------------------------------
/src/components/Reply.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
21 |
22 |
23 |
24 |
25 |
81 |
--------------------------------------------------------------------------------
/src/components/Markdown.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
82 |
83 |
93 |
--------------------------------------------------------------------------------
/src/components/Balloon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
18 |
19 |
20 |
88 |
--------------------------------------------------------------------------------
/src/pages/Notifications.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Notifications
4 |
5 |
11 |
19 |
20 |
21 |
22 |
23 |
100 |
--------------------------------------------------------------------------------
/src/db.js:
--------------------------------------------------------------------------------
1 | const worker = new Worker(new URL('./worker-db.js', import.meta.url))
2 |
3 | const hub = {}
4 |
5 | worker.onmessage = ev => {
6 | let {id, success, error, data, stream} = JSON.parse(ev.data)
7 |
8 | if (stream) {
9 | console.log('db ~>>', id, data)
10 | hub[id](data)
11 | return
12 | }
13 |
14 | if (!success) {
15 | hub[id].reject(new Error(error))
16 | delete hub[id]
17 | return
18 | }
19 |
20 | console.log('db ->', id, data)
21 | hub[id].resolve(data)
22 | delete hub[id]
23 | }
24 |
25 | function call(name, args) {
26 | let id = name + ' ' + Math.random().toString().slice(-4)
27 | console.log('db <-', id, name, args)
28 | worker.postMessage(JSON.stringify({id, name, args}))
29 | return new Promise((resolve, reject) => {
30 | hub[id] = {resolve, reject}
31 | })
32 | }
33 |
34 | function stream(name, args, callback) {
35 | let id = name + ' ' + Math.random().toString().slice(-4)
36 | hub[id] = callback
37 | console.log('db <-', id, args)
38 | worker.postMessage(JSON.stringify({id, name, args, stream: true}))
39 | return {
40 | cancel() {
41 | worker.postMessage(JSON.stringify({id, cancel: true}))
42 | }
43 | }
44 | }
45 |
46 | export async function eraseDatabase() {
47 | return call('eraseDatabase', [])
48 | }
49 | export async function dbSave(event) {
50 | return call('dbSave', [event])
51 | }
52 | export async function dbGetHomeFeedNotes(
53 | limit = 50,
54 | since = Math.round(Date.now() / 1000)
55 | ) {
56 | return call('dbGetHomeFeedNotes', [limit, since])
57 | }
58 | export function onNewHomeFeedNote(callback = () => {}) {
59 | return stream('onNewHomeFeedNote', [], callback)
60 | }
61 | export async function dbGetChats(ourPubKey) {
62 | return call('dbGetChats', [ourPubKey])
63 | }
64 | export async function dbGetMessages(
65 | peerPubKey,
66 | limit = 50,
67 | since = Math.round(Date.now() / 1000)
68 | ) {
69 | return call('dbGetMessages', [peerPubKey, limit, since])
70 | }
71 | export function onNewMessage(peerPubKey, callback = () => {}) {
72 | return stream('onNewMessage', [peerPubKey], callback)
73 | }
74 | export async function dbGetEvent(id) {
75 | return call('dbGetEvent', [id])
76 | }
77 | export async function dbGetMentions(ourPubKey, limit = 40, since, until) {
78 | return call('dbGetMentions', [ourPubKey, limit, since, until])
79 | }
80 | export function onNewMention(ourPubKey, callback = () => {}) {
81 | return stream('onNewMention', [ourPubKey], callback)
82 | }
83 | export function onNewAnyMessage(callback = () => {}) {
84 | return stream('onNewAnyMessage', [], callback)
85 | }
86 | export async function dbGetUnreadNotificationsCount(ourPubKey, since) {
87 | return call('dbGetUnreadNotificationsCount', [ourPubKey, since])
88 | }
89 | export async function dbGetUnreadMessages(pubkey, since) {
90 | return call('dbGetUnreadMessages', [pubkey, since])
91 | }
92 | export async function dbGetProfile(pubkey) {
93 | return call('dbGetProfile', [pubkey])
94 | }
95 | export async function dbGetContactList(pubkey) {
96 | return call('dbGetContactList', [pubkey])
97 | }
98 |
--------------------------------------------------------------------------------
/src/store/mutations.js:
--------------------------------------------------------------------------------
1 | import {getPublicKey} from 'nostr-tools'
2 | import {normalizeRelayURL} from 'nostr-tools/relay'
3 | import {
4 | seedFromWords,
5 | generateSeedWords,
6 | privateKeyFromSeed
7 | } from 'nostr-tools/nip06'
8 |
9 | export function setKeys(state, {mnemonic, priv, pub} = {}) {
10 | if (!mnemonic && !priv && !pub) {
11 | mnemonic = generateSeedWords()
12 | }
13 |
14 | if (mnemonic) {
15 | let seed = seedFromWords(mnemonic)
16 | priv = privateKeyFromSeed(seed)
17 | }
18 |
19 | if (priv) {
20 | pub = getPublicKey(priv)
21 | }
22 |
23 | state.keys = {mnemonic, priv, pub}
24 | }
25 |
26 | export function setRelays(state, relays) {
27 | state.relays = relays
28 | }
29 |
30 | export function addRelay(state, url) {
31 | try {
32 | normalizeRelayURL(url)
33 | new URL(url)
34 | } catch (err) {
35 | return
36 | }
37 |
38 | state.relays[url] = {
39 | read: true,
40 | write: true
41 | }
42 | }
43 |
44 | export function removeRelay(state, url) {
45 | delete state.relays[url]
46 | }
47 |
48 | export function setRelayOpt(state, {url, opt, value}) {
49 | if (url in state.relays) {
50 | state.relays[url][opt] = value
51 | }
52 | }
53 |
54 | export function setFollowing(state, following) {
55 | state.following = following
56 | }
57 |
58 | export function follow(state, key) {
59 | if (state.keys.pub === key) return
60 | if (state.following.includes(key)) return
61 | state.following.push(key)
62 | }
63 |
64 | export function unfollow(state, key) {
65 | let idx = state.following.indexOf(key)
66 | if (idx >= 0) state.following.splice(idx, 1)
67 | }
68 |
69 | export function addProfileToCache(state, event) {
70 | if (event.pubkey in state.profilesCache) {
71 | // was here already, remove from LRU (will readd next)
72 | state.profilesCacheLRU.splice(
73 | state.profilesCacheLRU.indexOf(event.pubkey),
74 | 0
75 | )
76 | }
77 |
78 | // replace the event in cache
79 | try {
80 | state.profilesCache[event.pubkey] = JSON.parse(event.content)
81 | } catch (err) {
82 | return
83 | }
84 |
85 | // adding to LRU
86 | if (event.pubkey === state.keys.pub) {
87 | // if it's our own profile, we'll never remove from the cache
88 | } else {
89 | state.profilesCacheLRU.push(event.pubkey)
90 | }
91 |
92 | // removing older stuff if necessary
93 | if (state.profilesCacheLRU.length > 150) {
94 | let oldest = state.profilesCacheLRU.shift()
95 | delete state.profilesCache[oldest]
96 | }
97 | }
98 |
99 | export function addContactListToCache(state, event) {
100 | if (event.pubkey in state.contactListCache) {
101 | // was here already, remove from LRU (will readd next)
102 | state.contactListCacheLRU.splice(
103 | state.contactListCacheLRU.indexOf(event.pubkey),
104 | 0
105 | )
106 | }
107 |
108 | // replace the event in cache
109 | try {
110 | let contacts = event.tags
111 | .filter(([t, v]) => t === 'p' && v)
112 | .map(([_, pubkey, relay, petname]) => ({
113 | pubkey,
114 | relay,
115 | petname
116 | }))
117 | if (contacts.length) state.contactListCache[event.pubkey] = contacts
118 | } catch (err) {
119 | return
120 | }
121 |
122 | // adding to LRU
123 | if (event.pubkey === state.keys.pub) {
124 | // if it's our own contact list, we'll never remove from the cache
125 | } else {
126 | state.contactListCacheLRU.push(event.pubkey)
127 | }
128 |
129 | // removing older stuff if necessary
130 | if (state.contactListCacheLRU.length > 150) {
131 | let oldest = state.contactListCacheLRU.shift()
132 | delete state.contactListCache[oldest]
133 | }
134 | }
135 |
136 | export function haveReadNotifications(state) {
137 | state.lastNotificationRead = Math.round(Date.now() / 1000)
138 | }
139 |
140 | export function setUnreadNotifications(state, count) {
141 | state.unreadNotifications = count
142 | }
143 |
144 | export function haveReadMessage(state, peer) {
145 | state.lastMessageRead[peer] = Math.round(Date.now() / 1000)
146 | }
147 |
148 | export function setUnreadMessages(state, {peer, count}) {
149 | state.unreadMessages[peer] = count
150 | }
151 |
--------------------------------------------------------------------------------
/quasar.conf.js:
--------------------------------------------------------------------------------
1 | /*
2 | * This file runs in a Node context (it's NOT transpiled by Babel), so use only
3 | * the ES6 features that are supported by your Node version. https://node.green/
4 | */
5 |
6 | // Configuration for your app
7 | // https://quasar.dev/quasar-cli/quasar-conf-js
8 |
9 | /* eslint-env node */
10 | const webpack = require('webpack')
11 | const ESLintPlugin = require('eslint-webpack-plugin')
12 | const {configure} = require('quasar/wrappers')
13 |
14 | module.exports = configure(function (ctx) {
15 | return {
16 | // https://quasar.dev/quasar-cli/supporting-ts
17 | supportTS: false,
18 |
19 | // https://quasar.dev/quasar-cli/prefetch-feature
20 | // preFetch: true,
21 |
22 | // app boot file (/src/boot)
23 | // --> boot files are part of "main.js"
24 | // https://quasar.dev/quasar-cli/boot-files
25 | boot: ['global-components'],
26 |
27 | // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css
28 | css: ['add-tailwind.css'],
29 |
30 | // https://github.com/quasarframework/quasar/tree/dev/extras
31 | extras: [
32 | // 'ionicons-v4',
33 | // 'mdi-v5',
34 | // 'fontawesome-v5',
35 | // 'eva-icons',
36 | // 'themify',
37 | // 'line-awesome',
38 | // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
39 |
40 | 'roboto-font', // optional, you are not bound to it
41 | 'material-icons' // optional, you are not bound to it
42 | ],
43 |
44 | // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-build
45 | build: {
46 | vueRouterMode: 'history', // available values: 'hash', 'history'
47 |
48 | // transpile: false,
49 | publicPath: '/',
50 |
51 | // Add dependencies for transpiling with Babel (Array of string/regex)
52 | // (from node_modules, which are by default not transpiled).
53 | // Applies only if "transpile" is set to true.
54 | // transpileDependencies: [],
55 |
56 | // rtl: true, // https://quasar.dev/options/rtl-support
57 | // preloadChunks: true,
58 | // showProgress: false,
59 | // gzip: true,
60 | // analyze: true,
61 |
62 | // Options below are automatically set depending on the env, set them if you want to override
63 | // extractCSS: false,
64 |
65 | // https://quasar.dev/quasar-cli/handling-webpack
66 | // "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
67 | chainWebpack(chain) {
68 | chain
69 | .plugin('eslint-webpack-plugin')
70 | .use(ESLintPlugin, [{extensions: ['js', 'vue']}])
71 | },
72 |
73 | // blergh
74 | extendWebpack(cfg) {
75 | cfg.plugins.push(
76 | new webpack.ProvidePlugin({Buffer: ['buffer', 'Buffer']})
77 | )
78 | cfg.resolve.alias = cfg.resolve.alias || {}
79 | cfg.resolve.alias.stream = 'readable-stream'
80 | cfg.resolve.fallback = cfg.resolve.fallback || {}
81 | cfg.resolve.fallback.buffer = require.resolve('buffer/')
82 | cfg.resolve.fallback.stream = require.resolve('readable-stream')
83 | cfg.resolve.fallback.crypto = false
84 | cfg.experiments = cfg.experiments || {}
85 | cfg.experiments.asyncWebAssembly = true
86 | }
87 | },
88 |
89 | // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer
90 | devServer: {
91 | server: {
92 | type: 'http'
93 | },
94 | port: 8080,
95 | open: true // opens browser window automatically
96 | },
97 |
98 | // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-framework
99 | framework: {
100 | config: {},
101 |
102 | // iconSet: 'material-icons', // Quasar icon set
103 | // lang: 'en-US', // Quasar language pack
104 |
105 | // For special cases outside of where the auto-import strategy can have an impact
106 | // (like functional components as one of the examples),
107 | // you can manually specify Quasar components/directives to be available everywhere:
108 | //
109 | // components: [],
110 | // directives: [],
111 |
112 | // Quasar plugins
113 | plugins: ['Notify', 'Dialog']
114 | }
115 | }
116 | })
117 |
--------------------------------------------------------------------------------
/src/components/Post.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
33 | {{ $store.getters.displayName(event.pubkey) }}
34 |
35 |
36 | {{ shorten(event.pubkey) }}
37 |
38 |
42 | related to
43 |
47 | {{ shorten(tagged) }}
48 |
49 |
50 |
51 |
52 |
58 |
62 | {{ niceDate(event.created_at) }}
63 |
64 |
65 |
66 |
73 |
74 | {{ trimmedContent }}
75 |
76 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
157 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 |
4 | parserOptions: {
5 | parser: '@babel/eslint-parser',
6 | ecmaVersion: 2018,
7 | sourceType: 'module',
8 | requireConfigFile: false
9 | },
10 |
11 | env: {
12 | browser: true
13 | },
14 |
15 | // Rules order is important, please avoid shuffling them
16 | extends: [
17 | 'eslint:recommended',
18 | 'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
19 | 'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
20 | 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
21 | 'prettier'
22 | ],
23 |
24 | plugins: ['vue'],
25 |
26 | globals: {
27 | cordova: 'readonly',
28 | __statics: 'readonly',
29 | __QUASAR_SSR__: 'readonly',
30 | __QUASAR_SSR_SERVER__: 'readonly',
31 | __QUASAR_SSR_CLIENT__: 'readonly',
32 | __QUASAR_SSR_PWA__: 'readonly',
33 | process: 'readonly',
34 | Capacitor: 'readonly',
35 | chrome: 'readonly'
36 | },
37 |
38 | rules: {
39 | // allow debugger during development only
40 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
41 |
42 | // custom
43 | 'vue/no-v-html': 0,
44 |
45 | 'accessor-pairs': 2,
46 | 'arrow-spacing': [2, {before: true, after: true}],
47 | 'block-spacing': [2, 'always'],
48 | 'brace-style': [2, '1tbs', {allowSingleLine: true}],
49 | 'comma-dangle': 0,
50 | 'comma-spacing': [2, {before: false, after: true}],
51 | 'comma-style': [2, 'last'],
52 | 'constructor-super': 2,
53 | curly: [0, 'multi-line'],
54 | 'dot-location': [2, 'property'],
55 | 'eol-last': 2,
56 | eqeqeq: [2, 'allow-null'],
57 | 'generator-star-spacing': [2, {before: true, after: true}],
58 | 'handle-callback-err': [2, '^(err|error)$'],
59 | indent: 0,
60 | 'jsx-quotes': [2, 'prefer-double'],
61 | 'key-spacing': [2, {beforeColon: false, afterColon: true}],
62 | 'keyword-spacing': [2, {before: true, after: true}],
63 | 'new-cap': 0,
64 | 'new-parens': 0,
65 | 'no-array-constructor': 2,
66 | 'no-caller': 2,
67 | 'no-class-assign': 2,
68 | 'no-cond-assign': 2,
69 | 'no-const-assign': 2,
70 | 'no-control-regex': 0,
71 | 'no-debugger': 0,
72 | 'no-delete-var': 2,
73 | 'no-dupe-args': 2,
74 | 'no-dupe-class-members': 2,
75 | 'no-dupe-keys': 2,
76 | 'no-duplicate-case': 2,
77 | 'no-empty-character-class': 2,
78 | 'no-empty-pattern': 2,
79 | 'no-eval': 0,
80 | 'no-ex-assign': 2,
81 | 'no-extend-native': 2,
82 | 'no-extra-bind': 2,
83 | 'no-extra-boolean-cast': 2,
84 | 'no-extra-parens': [2, 'functions'],
85 | 'no-fallthrough': 2,
86 | 'no-floating-decimal': 2,
87 | 'no-func-assign': 2,
88 | 'no-implied-eval': 2,
89 | 'no-inner-declarations': [0, 'functions'],
90 | 'no-invalid-regexp': 2,
91 | 'no-irregular-whitespace': 2,
92 | 'no-iterator': 2,
93 | 'no-label-var': 2,
94 | 'no-labels': [2, {allowLoop: false, allowSwitch: false}],
95 | 'no-lone-blocks': 2,
96 | 'no-mixed-spaces-and-tabs': 2,
97 | 'no-multi-spaces': 2,
98 | 'no-multi-str': 2,
99 | 'no-multiple-empty-lines': [2, {max: 2}],
100 | 'no-native-reassign': 2,
101 | 'no-negated-in-lhs': 2,
102 | 'no-new': 0,
103 | 'no-new-func': 2,
104 | 'no-new-object': 2,
105 | 'no-new-require': 2,
106 | 'no-new-symbol': 2,
107 | 'no-new-wrappers': 2,
108 | 'no-obj-calls': 2,
109 | 'no-octal': 2,
110 | 'no-octal-escape': 2,
111 | 'no-path-concat': 0,
112 | 'no-proto': 2,
113 | 'no-redeclare': 2,
114 | 'no-regex-spaces': 2,
115 | 'no-return-assign': 0,
116 | 'no-self-assign': 2,
117 | 'no-self-compare': 2,
118 | 'no-sequences': 2,
119 | 'no-shadow-restricted-names': 2,
120 | 'no-spaced-func': 2,
121 | 'no-sparse-arrays': 2,
122 | 'no-this-before-super': 2,
123 | 'no-throw-literal': 2,
124 | 'no-trailing-spaces': 2,
125 | 'no-undef': 2,
126 | 'no-undef-init': 2,
127 | 'no-unexpected-multiline': 2,
128 | 'no-unneeded-ternary': [2, {defaultAssignment: false}],
129 | 'no-unreachable': 2,
130 | 'no-unused-vars': [
131 | 2,
132 | {vars: 'local', args: 'none', varsIgnorePattern: '^_'}
133 | ],
134 | 'no-useless-call': 2,
135 | 'no-useless-constructor': 2,
136 | 'no-with': 2,
137 | 'one-var': [0, {initialized: 'never'}],
138 | 'operator-linebreak': [
139 | 2,
140 | 'after',
141 | {overrides: {'?': 'before', ':': 'before'}}
142 | ],
143 | 'padded-blocks': [2, 'never'],
144 | quotes: [2, 'single', {avoidEscape: true, allowTemplateLiterals: true}],
145 | semi: [2, 'never'],
146 | 'semi-spacing': [2, {before: false, after: true}],
147 | 'space-before-blocks': [2, 'always'],
148 | 'space-before-function-paren': 0,
149 | 'space-in-parens': [2, 'never'],
150 | 'space-infix-ops': 2,
151 | 'space-unary-ops': [2, {words: true, nonwords: false}],
152 | 'spaced-comment': 0,
153 | 'template-curly-spacing': [2, 'never'],
154 | 'use-isnan': 2,
155 | 'valid-typeof': 2,
156 | 'wrap-iife': [2, 'any'],
157 | 'yield-star-spacing': [2, 'both'],
158 | yoda: [0]
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/pages/Messages.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Chat with
5 |
9 | {{ $store.getters.displayName($route.params.pubkey) }}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 |
26 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
205 |
--------------------------------------------------------------------------------
/src/pages/Profile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ $store.getters.displayName($route.params.pubkey) }}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {{ $route.params.pubkey }}
14 |
15 |
16 |
17 | {{ $store.getters.profileDescription($route.params.pubkey) }}
18 |
19 |
20 |
21 |
22 |
23 |
27 |
28 |
29 | Following
30 |
31 |
38 | {{ shorten(user.pubkey) }}
46 | ({{ $store.getters.displayName(user.pubkey) }}) ,
56 |
57 |
58 |
74 |
75 |
76 |
77 |
78 |
79 |
89 |
100 |
111 |
112 |
113 |
114 |
115 |
116 |
126 |
127 |
128 |
129 |
230 |
--------------------------------------------------------------------------------
/src/pages/Settings.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Settings
4 |
5 |
6 | Profile
7 |
8 |
9 |
10 |
11 |
12 |
20 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
Relays
38 |
39 |
40 |
41 |
42 |
50 | {{ url }}
51 |
52 |
53 |
54 |
55 |
60 | read
61 |
62 |
67 | write
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | Your keys
105 |
106 |
107 | Make sure you back up your private key!
108 | Posts are published using your private key. Others can see your
110 | posts or follow you using only your public key.
112 |
113 |
114 |
115 |
116 | Seed Words:
117 |
123 | Private Key:
124 |
130 | Public Key:
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
243 |
--------------------------------------------------------------------------------
/src/pages/Event.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Event
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
29 | {{ $store.getters.displayName(event.pubkey) }}
30 |
31 |
32 | {{ shorten(event.pubkey) }}
33 |
34 |
35 |
39 | {{ event.content }}
40 |
41 |
42 |
48 |
52 | {{ niceDate(event.created_at) }}
53 |
54 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | Event {{ $route.params.eventId }}
71 |
72 |
73 |
74 |
75 |
76 |
Replies
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
283 |
--------------------------------------------------------------------------------
/src/store/actions.js:
--------------------------------------------------------------------------------
1 | import {encrypt} from 'nostr-tools/nip04'
2 | import {Notify, LocalStorage} from 'quasar'
3 |
4 | import {pool} from '../pool'
5 | import {dbSave, dbGetProfile, dbGetContactList} from '../db'
6 |
7 | export function initKeys(store, keys) {
8 | store.commit('setKeys', keys)
9 |
10 | // also initialize the lastNotificationRead value
11 | store.commit('haveReadNotifications')
12 | }
13 |
14 | export async function launch(store) {
15 | if (!store.state.keys.pub) {
16 | store.commit('setKeys') // passing no arguments will cause a new seed to be generated
17 |
18 | // also initialize the lastNotificationRead value
19 | store.commit('haveReadNotifications')
20 | }
21 |
22 | // now we already have a key
23 | if (store.state.keys.priv) {
24 | pool.setPrivateKey(store.state.keys.priv)
25 | }
26 |
27 | // translate localStorage into a kind3 event -- or load relays and following from event
28 | let contactList = await dbGetContactList(store.state.keys.pub)
29 | var {relays, following} = store.state
30 | if (contactList) {
31 | try {
32 | relays = JSON.parse(contactList.content)
33 | } catch (err) {
34 | /***/
35 | }
36 | following = contactList.tags
37 | .filter(([t, v]) => t === 'p' && v)
38 | .map(([_, v]) => v)
39 | } else {
40 | // get stuff from localstorage and save to store -- which will trigger the eventize
41 | // plugin to create and publish a contactlist event
42 | relays = LocalStorage.getItem('relays') || relays
43 | following = LocalStorage.getItem('following') || following
44 | }
45 |
46 | // update store state
47 | store.commit('setFollowing', following)
48 | store.commit('setRelays', relays)
49 |
50 | // setup pool
51 | for (let url in store.state.relays) {
52 | pool.addRelay(url, store.state.relays[url])
53 | }
54 | pool.onNotice((notice, relay) => {
55 | Notify.create({
56 | message: `Relay ${relay.url} says: ${notice}`,
57 | color: 'pink'
58 | })
59 | })
60 |
61 | // preload our own profile from the db
62 | await store.dispatch('useProfile', {pubkey: store.state.keys.pub})
63 |
64 | // start listening for nostr events
65 | store.dispatch('restartMainSubscription')
66 | }
67 |
68 | var mainSub = pool
69 |
70 | export function restartMainSubscription(store) {
71 | mainSub = mainSub.sub(
72 | {
73 | filter: [
74 | // notes, profiles and contact lists of people we follow (and ourselves)
75 | {
76 | kinds: [0, 1, 2, 3],
77 | authors: store.state.following.concat(store.state.keys.pub)
78 | },
79 |
80 | // posts mentioning us and direct messages to us
81 | {
82 | kinds: [1, 4],
83 | '#p': [store.state.keys.pub]
84 | },
85 |
86 | // our own direct messages to other people
87 | {
88 | kinds: [4],
89 | authors: [store.state.keys.pub]
90 | }
91 | ],
92 | cb: async (event, relay) => {
93 | switch (event.kind) {
94 | case 0:
95 | break
96 | case 1:
97 | break
98 | case 2:
99 | break
100 | case 3: {
101 | if (event.pubkey === store.state.keys.pub) {
102 | // we got a new contact list from ourselves
103 | // we must update our local relays and following lists
104 | // if we don't have any local lists yet
105 | let local = await dbGetContactList(store.state.keys.pub)
106 | if (!local || local.created_at < event.created_at) {
107 | var relays, following
108 | try {
109 | relays = JSON.parse(event.content)
110 | store.commit('setRelays', relays)
111 | } catch (err) {
112 | /***/
113 | }
114 |
115 | following = event.tags
116 | .filter(([t, v]) => t === 'p' && v)
117 | .map(([_, v]) => v)
118 | store.commit('setFollowing', following)
119 |
120 | following.forEach(f =>
121 | store.dispatch('useProfile', {pubkey: f})
122 | )
123 | }
124 | }
125 | break
126 | }
127 | case 4:
128 | break
129 | }
130 |
131 | store.dispatch('addEvent', event)
132 | }
133 | },
134 | 'main-channel'
135 | )
136 | }
137 |
138 | export async function sendPost(store, {message, tags = [], kind = 1}) {
139 | if (message.length === 0) return
140 |
141 | let event = await pool.publish({
142 | pubkey: store.state.keys.pub,
143 | created_at: Math.floor(Date.now() / 1000),
144 | kind,
145 | tags,
146 | content: message
147 | })
148 |
149 | store.dispatch('addEvent', event)
150 | }
151 |
152 | export async function setMetadata(store, metadata) {
153 | let event = await pool.publish({
154 | pubkey: store.state.keys.pub,
155 | created_at: Math.floor(Date.now() / 1000),
156 | kind: 0,
157 | tags: [],
158 | content: JSON.stringify(metadata)
159 | })
160 |
161 | store.dispatch('addEvent', event)
162 | }
163 |
164 | export async function sendChatMessage(store, {pubkey, text, replyTo}) {
165 | if (text.length === 0) return
166 |
167 | let [ciphertext, iv] = encrypt(store.state.keys.priv, pubkey, text)
168 |
169 | // make event
170 | let event = {
171 | pubkey: store.state.keys.pub,
172 | created_at: Math.floor(Date.now() / 1000),
173 | kind: 4,
174 | tags: [['p', pubkey]],
175 | content: ciphertext + '?iv=' + iv
176 | }
177 | if (replyTo) {
178 | event.tags.push(['e', replyTo])
179 | }
180 |
181 | event = await pool.publish(event)
182 |
183 | store.dispatch('addEvent', event)
184 | }
185 |
186 | export async function addEvent(store, event) {
187 | await dbSave(event)
188 |
189 | // do things after the event is saved
190 | switch (event.kind) {
191 | case 0:
192 | // this will reset the profile cache for this URL
193 | store.dispatch('useProfile', {pubkey: event.pubkey})
194 | break
195 | case 1:
196 | break
197 | case 2:
198 | break
199 | case 3:
200 | // this will reset the profile cache for this URL
201 | store.dispatch('useContacts', event.pubkey)
202 | break
203 | case 4:
204 | break
205 | }
206 | }
207 |
208 | export async function useProfile(store, {pubkey, request = false}) {
209 | if (pubkey in store.state.profilesCache) {
210 | // we don't fetch again, but we do commit this so the LRU gets updated
211 | store.commit('addProfileToCache', store.state.profilesCache[pubkey])
212 | } else {
213 | // fetch from db and add to cache
214 | let event = await dbGetProfile(pubkey)
215 | if (event) {
216 | store.commit('addProfileToCache', event)
217 | } else if (request) {
218 | // try to request from a relay
219 | let sub = pool.sub({
220 | filter: [{authors: [pubkey], kinds: [0]}],
221 | cb: async event => {
222 | store.commit('addProfileToCache', event)
223 | clearTimeout(timeout)
224 | if (sub) sub.unsub()
225 | }
226 | })
227 | let timeout = setTimeout(() => {
228 | sub.unsub()
229 | sub = null
230 | }, 2000)
231 | }
232 | }
233 | }
234 |
235 | export async function useContacts(store, pubkey) {
236 | if (pubkey in store.state.contactListCache) {
237 | // we don't fetch again, but we do commit this so the LRU gets updated
238 | store.commit('addContactListToCache', store.state.contactListCache[pubkey])
239 | } else {
240 | // fetch from db and add to cache
241 | let event = await dbGetContactList(pubkey)
242 | if (event) {
243 | store.commit('addContactListToCache', event)
244 | }
245 | }
246 | }
247 |
248 | export async function publishContactList(store) {
249 | // extend the existing tags
250 | let event = await dbGetContactList(store.state.keys.pub)
251 | var tags = event?.tags || []
252 |
253 | // remove contacts that we're not following anymore
254 | tags = tags.filter(
255 | ([t, v]) => t === 'p' && store.state.following.find(f => f === v)
256 | )
257 |
258 | // now we merely add to the existing event because it might contain more data in the
259 | // tags that we don't want to replace
260 | store.state.following.forEach(pubkey => {
261 | if (!tags.find(([t, v]) => t === 'p' && v === pubkey)) {
262 | tags.push(['p', pubkey])
263 | }
264 | })
265 |
266 | event = await pool.publish({
267 | pubkey: store.state.keys.pub,
268 | created_at: Math.floor(Date.now() / 1000),
269 | kind: 3,
270 | tags,
271 | content: JSON.stringify(store.state.relays)
272 | })
273 |
274 | await store.dispatch('addEvent', event)
275 |
276 | Notify.create({
277 | message: 'Updated and published list of followed keys and relays.',
278 | color: 'blue'
279 | })
280 | }
281 |
--------------------------------------------------------------------------------
/src/layouts/MainLayout.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
25 | Home
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
37 | Notifications
38 |
39 |
45 | {{ $store.state.unreadNotifications }}
46 |
47 |
48 |
49 |
50 |
57 |
58 |
59 |
60 |
61 |
64 | Messages
65 |
66 |
72 | {{ $store.getters.unreadChats }}
73 |
74 |
75 |
76 |
77 |
83 |
84 |
85 |
86 |
87 |
94 | Profile
95 |
96 |
97 |
98 |
105 |
106 |
107 |
108 |
109 |
114 | Search and Follows
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
126 | Settings
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
157 |
163 |
169 |
175 | {{ $store.state.unreadNotifications }}
176 |
177 |
178 |
185 |
191 | {{ $store.getters.unreadChats }}
192 |
193 |
194 |
204 |
210 |
216 |
217 |
218 |
219 |
220 |
221 |
222 | Initial Key Setup
223 |
224 |
225 | Type your mnemonic seed from a previous Nostr account or generate a
226 | new one.
227 |
228 |
229 | You can also type a raw private key or just a public key for a
230 | watch-only setup.
231 |
232 |
233 |
234 |
235 |
242 |
247 |
248 | Generate
249 | Proceed
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
328 |
--------------------------------------------------------------------------------
/src/worker-db.js:
--------------------------------------------------------------------------------
1 | /* global emit */
2 |
3 | import PouchDB from 'pouchdb-core'
4 | import PouchDBUpsert from 'pouchdb-upsert'
5 | import PouchDBMapReduce from 'pouchdb-mapreduce'
6 | import PouchDBAdapterIDB from 'pouchdb-adapter-idb'
7 |
8 | PouchDB.plugin(PouchDBAdapterIDB).plugin(PouchDBMapReduce).plugin(PouchDBUpsert)
9 |
10 | // instantiate db (every doc will be an event, that's it)
11 | // ~
12 | const db = new PouchDB('nostr-events', {
13 | auto_compaction: true,
14 | revs_limit: 1
15 | })
16 |
17 | // db schema (views)
18 | // ~
19 | const DESIGN_VERSION = 3
20 | db.upsert('_design/main', current => {
21 | if (current && current.version >= DESIGN_VERSION) return false
22 |
23 | return {
24 | version: DESIGN_VERSION,
25 | views: {
26 | profiles: {
27 | map: function (event) {
28 | if (event.kind === 0) {
29 | emit(event.pubkey)
30 | }
31 | }.toString()
32 | },
33 | homefeed: {
34 | map: function (event) {
35 | if (event.kind === 1) {
36 | emit(event.created_at)
37 | }
38 | }.toString()
39 | },
40 | mentions: {
41 | map: function (event) {
42 | if (event.kind === 1) {
43 | for (var i = 0; i < event.tags.length; i++) {
44 | var tag = event.tags[i]
45 | if (tag[0] === 'p') emit([tag[1], event.created_at])
46 | if (tag[0] === 'e') emit([tag[1], event.created_at])
47 | }
48 | }
49 | }.toString()
50 | },
51 | messages: {
52 | map: function (event) {
53 | if (event.kind === 4) {
54 | for (var i = 0; i < event.tags.length; i++) {
55 | var tag = event.tags[i]
56 | if (tag[0] === 'p') {
57 | emit([tag[1], event.created_at])
58 | break
59 | }
60 | }
61 | emit([event.pubkey, event.created_at])
62 | }
63 | }.toString()
64 | },
65 | contactlists: {
66 | map: function (event) {
67 | if (event.kind === 3) {
68 | emit(event.pubkey)
69 | }
70 | }.toString()
71 | },
72 | followers: {
73 | map: function (event) {
74 | if (event.kind === 3) {
75 | for (let i = 0; i < event.tags.length; i++) {
76 | var tag = event.tags[i]
77 | if (tag.length >= 2 && tag[0] === 'p') {
78 | emit(tag[1], event.pubkey)
79 | }
80 | }
81 | }
82 | }.toString()
83 | },
84 | petnames: {
85 | map: function (event) {
86 | if (event.kind === 3) {
87 | for (let i = 0; i < event.tags.length; i++) {
88 | var tag = event.tags[i]
89 | if (tag.length >= 4 && tag[0] === 'p') {
90 | emit(tag[1], [event.pubkey, tag[3]])
91 | }
92 | }
93 | }
94 | }.toString()
95 | }
96 | }
97 | }
98 | }).then(() => {
99 | // cleanup old views after a design doc change
100 | db.viewCleanup().then(r => console.log('view cleanup done', r))
101 | })
102 |
103 | // delete old events after the first 1000 (this is slow, so do it after a while)
104 | //
105 | setTimeout(async () => {
106 | let result = await db.query('main/homefeed', {
107 | descending: true,
108 | skip: 1000,
109 | include_docs: true
110 | })
111 | result.rows.forEach(row => db.remove(row.doc))
112 | }, 1000 * 60 * 15 /* 15 minutes */)
113 |
114 | const methods = {
115 | // delete everything
116 | //
117 | async eraseDatabase() {
118 | return await db.destroy()
119 | },
120 |
121 | // general function for saving an event, with granular logic for each kind
122 | //
123 | async dbSave(event) {
124 | switch (event.kind) {
125 | case 0: {
126 | // first check if we don't have a newer metadata for this user
127 | let current = await methods.dbGetProfile(event.pubkey)
128 | if (current && current.created_at >= event.created_at) {
129 | // don't save
130 | return
131 | }
132 | break
133 | }
134 | case 1:
135 | break
136 | case 2:
137 | break
138 | case 3: {
139 | // first check if we don't have a newer contact list for this user
140 | let current = await methods.dbGetContactList(event.pubkey)
141 | if (current && current.created_at >= event.created_at) {
142 | // don't save
143 | return
144 | }
145 | break
146 | }
147 | case 4: {
148 | // cleanup extra fields if somehow they manage to get in here (they shouldn't)
149 | delete event.appended
150 | delete event.plaintext
151 | break
152 | }
153 | }
154 |
155 | event._id = event.id
156 |
157 | try {
158 | await db.put(event)
159 | } catch (err) {
160 | if (err.name !== 'conflict') {
161 | console.error('unexpected error saving event', event, err)
162 | }
163 | }
164 | },
165 |
166 | // db queries
167 | // ~
168 | async dbGetHomeFeedNotes(limit = 50, since = Math.round(Date.now() / 1000)) {
169 | let result = await db.query('main/homefeed', {
170 | include_docs: true,
171 | descending: true,
172 | limit,
173 | startkey: since
174 | })
175 | return result.rows.map(r => r.doc)
176 | },
177 |
178 | onNewHomeFeedNote(callback = () => {}) {
179 | // listen for changes
180 | let changes = db.changes({
181 | live: true,
182 | since: 'now',
183 | include_docs: true,
184 | filter: '_view',
185 | view: 'main/homefeed'
186 | })
187 |
188 | changes.on('change', change => callback(change.doc))
189 |
190 | return changes
191 | },
192 |
193 | async dbGetChats(ourPubKey) {
194 | let result = await db.query('main/messages')
195 |
196 | let chats = result.rows
197 | .map(r => r.key)
198 | .reduce((acc, [peer, date]) => {
199 | acc[peer] = acc[peer] || 0
200 | if (date > acc[peer]) acc[peer] = date
201 | return acc
202 | }, {})
203 |
204 | delete chats[ourPubKey]
205 |
206 | return Object.entries(chats)
207 | .sort((a, b) => b[1] - a[1])
208 | .map(([peer, lastMessage]) => ({peer, lastMessage}))
209 | },
210 |
211 | async dbGetMessages(
212 | peerPubKey,
213 | limit = 50,
214 | since = Math.round(Date.now() / 1000)
215 | ) {
216 | let result = await db.query('main/messages', {
217 | include_docs: true,
218 | descending: true,
219 | startkey: [peerPubKey, since],
220 | endkey: [peerPubKey, 0],
221 | limit
222 | })
223 | return result.rows
224 | .map(r => r.doc)
225 | .reverse()
226 | .reduce((acc, event) => {
227 | if (!acc.length) return [event]
228 | let last = acc[acc.length - 1]
229 | if (
230 | last.pubkey === event.pubkey &&
231 | last.created_at + 120 >= event.created_at
232 | ) {
233 | last.appended = last.appended || []
234 | last.appended.push(event)
235 | } else {
236 | acc.push(event)
237 | }
238 | return acc
239 | }, [])
240 | },
241 |
242 | onNewMessage(peerPubKey, callback = () => {}) {
243 | // listen for changes
244 | let changes = db.changes({
245 | live: true,
246 | since: 'now',
247 | include_docs: true,
248 | filter: '_view',
249 | view: 'main/messages'
250 | })
251 |
252 | changes.on('change', change => {
253 | if (
254 | change.doc.pubkey === peerPubKey ||
255 | change.doc.tags.find(([t, v]) => t === 'p' && v === peerPubKey)
256 | ) {
257 | callback(change.doc)
258 | }
259 | })
260 |
261 | return changes
262 | },
263 |
264 | async dbGetEvent(id) {
265 | try {
266 | return await db.get(id)
267 | } catch (err) {
268 | if (err.name === 'not_found') return null
269 | else throw err
270 | }
271 | },
272 |
273 | async dbGetMentions(ourPubKey, limit = 40, since, until) {
274 | let result = await db.query('main/mentions', {
275 | include_docs: true,
276 | descending: true,
277 | startkey: [ourPubKey, until],
278 | endkey: [ourPubKey, since],
279 | limit
280 | })
281 | return result.rows.map(r => r.doc)
282 | },
283 |
284 | onNewMention(ourPubKey, callback = () => {}) {
285 | // listen for changes
286 | let changes = db.changes({
287 | live: true,
288 | since: 'now',
289 | include_docs: true,
290 | filter: '_view',
291 | view: 'main/mentions'
292 | })
293 |
294 | changes.on('change', change => {
295 | if (change.doc.tags.find(([t, v]) => t === 'p' && v === ourPubKey)) {
296 | callback(change.doc)
297 | }
298 | })
299 |
300 | return changes
301 | },
302 |
303 | onNewAnyMessage(callback = () => {}) {
304 | // listen for changes
305 | let changes = db.changes({
306 | live: true,
307 | since: 'now',
308 | include_docs: true,
309 | filter: '_view',
310 | view: 'main/messages'
311 | })
312 |
313 | changes.on('change', change => {
314 | callback(change.doc)
315 | })
316 |
317 | return changes
318 | },
319 |
320 | async dbGetUnreadNotificationsCount(ourPubKey, since) {
321 | let result = await db.query('main/mentions', {
322 | include_docs: false,
323 | descending: true,
324 | startkey: [ourPubKey, {}],
325 | endkey: [ourPubKey, since]
326 | })
327 | return result.rows.length
328 | },
329 |
330 | async dbGetUnreadMessages(pubkey, since) {
331 | let result = await db.query('main/messages', {
332 | include_docs: false,
333 | descending: true,
334 | startkey: [pubkey, {}],
335 | endkey: [pubkey, since]
336 | })
337 | return result.rows.length
338 | },
339 |
340 | async dbGetProfile(pubkey) {
341 | let result = await db.query('main/profiles', {
342 | include_docs: true,
343 | key: pubkey
344 | })
345 | switch (result.rows.length) {
346 | case 0:
347 | return null
348 | case 1:
349 | return result.rows[0].doc
350 | default: {
351 | let sorted = result.rows.sort(
352 | (a, b) => (b.doc?.created_at || 0) - (a.doc?.created_at || 0)
353 | )
354 | sorted
355 | .slice(1)
356 | .filter(row => row.doc)
357 | .forEach(row => db.remove(row.doc))
358 | return sorted[0].doc
359 | }
360 | }
361 | },
362 |
363 | async dbGetContactList(pubkey) {
364 | let result = await db.query('main/contactlists', {
365 | include_docs: true,
366 | key: pubkey
367 | })
368 | switch (result.rows.length) {
369 | case 0:
370 | return null
371 | case 1:
372 | return result.rows[0].doc
373 | default: {
374 | let sorted = result.rows.sort(
375 | (a, b) => (b.doc?.created_at || 0) - (a.doc?.created_at || 0)
376 | )
377 | sorted
378 | .slice(1)
379 | .filter(row => row.doc)
380 | .forEach(row => db.remove(row.doc))
381 | return sorted[0].doc
382 | }
383 | }
384 | }
385 | }
386 |
387 | var streams = {}
388 |
389 | self.onmessage = async function (ev) {
390 | let {name, args, id, stream, cancel} = JSON.parse(ev.data)
391 |
392 | if (stream) {
393 | let changes = methods[name](...args, data => {
394 | self.postMessage(
395 | JSON.stringify({
396 | id,
397 | data,
398 | stream: true
399 | })
400 | )
401 | })
402 | streams[id] = changes
403 | } else if (cancel) {
404 | streams[id].cancel()
405 | delete streams[id]
406 | } else {
407 | var reply = {id}
408 | try {
409 | let data = await methods[name](...args)
410 | reply.success = true
411 | reply.data = data
412 | } catch (err) {
413 | reply.success = false
414 | reply.error = err.message
415 | }
416 |
417 | self.postMessage(JSON.stringify(reply))
418 | }
419 | }
420 |
--------------------------------------------------------------------------------