├── 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 | 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 | 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 | 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 | 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 | 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 | 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 | 18 | 19 | 71 | -------------------------------------------------------------------------------- /src/pages/Chats.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 65 | -------------------------------------------------------------------------------- /src/components/Follow.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 73 | -------------------------------------------------------------------------------- /src/components/Reply.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 81 | -------------------------------------------------------------------------------- /src/components/Markdown.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 82 | 83 | 93 | -------------------------------------------------------------------------------- /src/components/Balloon.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 88 | -------------------------------------------------------------------------------- /src/pages/Notifications.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 41 | 42 | 205 | -------------------------------------------------------------------------------- /src/pages/Profile.vue: -------------------------------------------------------------------------------- 1 | 128 | 129 | 230 | -------------------------------------------------------------------------------- /src/pages/Settings.vue: -------------------------------------------------------------------------------- 1 | 141 | 142 | 243 | -------------------------------------------------------------------------------- /src/pages/Event.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | --------------------------------------------------------------------------------