diff --git a/package.json b/package.json index e65413d..a441d24 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,10 @@ "codemirror": "5", "core-js": "^3.6.5", "cross-fetch": "^3.1.5", + "dompurify": "^2.4.1", "emoji-mart-vue-fast": "^10.2.1", "identicon.js": "2.3", + "light-bolt11-decoder": "^2.1.0", "markdown-it": "13.0", "markdown-it-deflist": "2.1", "markdown-it-emoji": "^2.0.2", @@ -28,6 +30,7 @@ "markdown-it-sup": "1.0", "markdown-it-task-lists": "2.1", "mergebounce": "^0.1.1", + "nayuki-qr-code-generator": "^1.8.0", "nostr-tools": "^0.24.1", "quasar": "2.5.5", "readable-stream": "3.6.0", diff --git a/src/components/BaseButtonCopy.vue b/src/components/BaseButtonCopy.vue index d8c2dab..ddcd682 100644 --- a/src/components/BaseButtonCopy.vue +++ b/src/components/BaseButtonCopy.vue @@ -9,7 +9,7 @@ :size='buttonSize' class='button-copy' dense - :label='verbose ? "copy" : ""' + :label='(verbose || buttonLabel) ? (buttonLabel || "copy") : ""' align="left" > @@ -20,14 +20,21 @@ diff --git a/src/components/BaseButtonFollow.vue b/src/components/BaseButtonFollow.vue index f12976d..c03882d 100644 --- a/src/components/BaseButtonFollow.vue +++ b/src/components/BaseButtonFollow.vue @@ -2,7 +2,6 @@ +
+ + + +

+ the Nostr protocol is + a decentralized and censorship resistant distributed information network that relies on clients and relays. + relays store user data. clients communicate with the relays to save and fetch said user data. +

+
    +
  • users choose which relays to store their data on, meaning no one centralized entity has the power + to remove your data from the network (so it is recommended to use multiple relays)
  • +
  • users choose which clients to use, meaning no one centralized website can stop you from accessing the network
  • +
  • any client can be used with any relay, meaning users can choose their relays and client independently
  • +
+

+ astral is a client for Nostr. while astral is implementing a social media usecase of Nostr, the possibilities of Nostr are endless. + Jester + is a beta peer to peer chess client implemented over Nostr. +

+
+
+
+ + + +

+ if you would just like to look around you do not need a key pair, simply close this + dialog popup. however, if you want to post or save your profile and follows you will + need to create a key pair if you don't have one already. +

+

+ if you decide to just look around and want to login at a later time hit the set user + button in the user menu. +

+
+
+
+ + + + + in order to participate in the Nostr network you will need to a public key and private key pair. + this key pair can be used in any Nostr client to login. + + + + public key + + publicly known unique ID associated with your user on the Nostr + network. can be shared freely. others can see your posts or + follow you using only your public key. + + + + + + + private key + + KEEP THIS SECRET! secret key used to sign for + (or unlock) your public key. all content from your user public + key will need a signature derived from your private key before + being relayed. if a bad actor discovers your private key they + can impersonate you on Nostr network and see your encrypted dms. + + + + +

+ your public key is created from your private key via a one way hash + function, meaning: +

    +
  • your public key can be calculated from your private + key - which is why you only need to enter your private key for astral to + know your public key
  • +
  • your private key cannot be calculated from your public key - which + is why you can freely share your public key without compromising your + private key
  • +
+

+

+ through the magic of cryptograpic functions, this private and public key pair + allows you to sign for Nostr events, which could represent a post, profile + settings, or follows list, in a cryptographically verifiable manner (similar to + how you sign for a bitcoin transaction) +

+

+ you may see your keys displayed in a couple different key formats depending on + the Nostr client you use, please don't be alarmed. they both represent the same + byte data, they just use different encoding methods to be human readable. the + 'npub' (for Nostr + public key) and 'nsec' + (for Nostr + secret key) format that Damus + uses is preferable over the hex format that astral uses because there is a visual + indicator preventing the user from mixing up their public and private key. astral + will adopt this format in the future. +

+
+
+
+ + + +

+ anytime you enter your private key into any Nostr client, you are trusting that client to: +

    +
  1. not store your private key
  2. +
  3. not have any vulnerabilities that a bad actor can exploit to steal your private key
  4. +
+ while I can promise you that astral does not store your private key (it is stored locally in your + browser and is NEVER sent to back to astral) and that I am doing my best to prevent vulnerabilities, + it is still recommeneded that you DO NOT TRUST ME. +

+

+ fortunately, on desktop devices Nostr provides an easy way for you to sign into Nostr clients without + ever providing the client with your private key via browser extensions like + getAlby or nos2x. + these browser extensions will store your private key locally + in your browser. when the client needs to send an event or decrypt your messages (ie. use your + private key), it will employ your browser extension to do the necessary cryptographic functions. + see how to get a key pair? section below for instructions on how to use these + browser extensions. +

+

+ unfortunately, on mobile devices you cannot use browser extensions so it will be + necessary to enter your private key for now. this is a known and important issue + that is being worked on, and there should be some better solutions for private key + management on mobile devices coming soon. +

+
+
+
+ + + +

+ if you are on mobile you will not be able to use the directions below because browser extensions are + not supported on mobile devices. if you are on a desktop device please continue. +

+
    +
  1. hit the VIEW YOUR KEYS at the bottom of the settings page and note down your private key
  2. +
  3. follow the instructions in how to get a key pair? section below, making sure to enter + your private key rather than generating a new one
  4. +
  5. hit the LOGOUT at the bottom of the settings page (LOGOUT will wipe your + user data but preserve the existing browser database, DELETE LOCAL DATA will wipe your user + data and the browser database
  6. +
  7. hit the USE PUBLIC KEY FROM EXTENSION option that should appear in the key input (if + astral doesn't refresh automatically please refresh the page
  8. +
  9. proceed to how to use astral? section below
  10. +
+
+
+
+ + + +

+ you can use any method you want to generate your keys, as long as they + conform to the Nostr NIP-01 + specification. +

    +
  • if you are on a desktop device the recommeneded option will be using a browser extension like + getAlby or nos2x. + (see why shouldn't I enter my private key? section above)
  • +
  • if you are on a mobile device the easiest option will be using astral
  • +
  • if you are a technical user who is concerned about key generation security you can try the local option
  • +
+

+

+ **if you already have a key pair and just want to enter it into a browser extension, follow the + instructions in the applicable sub-section below but enter your private key instead of hitting + 'generate' (with nos2x make sure to hit save afterword). if your existing private key begins with + 'nsec' you will need to convert it to hex format before saving it in the browser extensions, which + can be done here.** +

+

+ choose your key generation method: +

+
+ + + +
    +
  1. hit GENERATE KEYS button below in the key input
  2. +
  3. proceed to how to use astral? section below
  4. +
+
+
+
+ + + +
    +
  1. install getAlby browser extension
  2. +
  3. complete alby setup (when it asks Do you have a lightning wallet? just hit Alby Wallet + if you do not have one)
  4. +
  5. open the getAlby extension options page (usually in dropdown when clicking on extension's icon at the top of your broswer)
  6. +
  7. hit the Settings tab
  8. +
  9. scroll down to the Nostr section and hit Generate (or if you already have a private key enter it)
  10. +
  11. refresh astral.ninja page and hit the USE PUBLIC KEY FROM EXTENSION option that should appear in the key input
  12. +
  13. proceed to how to use astral? section below
  14. +
+
+
+
+ + + +
    +
  1. install nos2x browser extension
  2. +
  3. open the nos2x extension options page (usually in dropdown when clicking on extension's icon at the top of your broswer)
  4. +
  5. hit generate button (or if you already have a private key enter it and hit save)
  6. +
  7. refresh astral.ninja page and hit the USE PUBLIC KEY FROM EXTENSION option that should appear in the key input
  8. +
  9. proceed to how to use astral? section below
  10. +
+
+
+
+ + + +
    +
  1. open terminal
  2. +
  3. enter the command
    openssl rand -hex 32
  4. +
  5. proceed to how to use astral? section below
  6. +
+
+
+
+ + +
+ + + +
    +
  1. + enter your key if you don't have a key pair, or you have a key pair that you want to enter into + a Nostr browser extension, see how to get a key pair? section above. +
  2. +
  3. + choose bootstrap relays (optional) this section will only appear once a valid key has + been entered. if you are using a brand new key you don't need + to worry about this. if you are using an existing key and don't typically use any of the selected + relays, make sure to include a relay you typically use or astral may not be able to find your user. + the bootstrap relays are NOT used as your user's relay list, astral only uses these to find your + user's information and settings when loading your Nostr account. +
  4. +
  5. + hit PROCEED astral will login and attempt to find your user information. please + be patient as it can take a few minutes to completely sync with Nostr relays. +
  6. +
  7. + BACK UP YOUR KEYS!!! once you are taken to the settings page you should get a + popup displaying your Nostr keys. make sure to make a backup of your Nostr keys and keep it + somewhere safe. if you lose your Nostr keys you lose access to your Nostr user identity. there is no + astral customer service to reset your password. +
  8. +
  9. + edit settings hit the EDIT button for which ever section you would + like to edit. +
      +
    • profile saving this section will broadcast your updated user profile to the + Nostr relays (if you have never set your relays on Nostr, astral will use astral's default relay + list to set and broadcast your set Nostr relays at the same time). all fields in the profile are + completely optional, you don't need to set a profile at all to use Nostr. +
        +
      • name non-unique username that will be displayed accross Nostr
      • +
      • about share a little about yourself
      • +
      • picture image url for the picture you would like displayed as your Nostr + profile picture
      • +
      • NIP-05 Identifier meant to be a unique, human readable identifier for + Nostr (read more here) +
      • +
      +
    • +
    • preferences saving this section will update the look of astral for this browser. + this section is not synced to Nostr.
    • +
    • relays saving this section will broadcast your updated relay list to the + Nostr relays.
    • +
    +
  10. +
+
+
+
+ + + diff --git a/src/components/BaseInvoice.vue b/src/components/BaseInvoice.vue new file mode 100644 index 0000000..3367ae5 --- /dev/null +++ b/src/components/BaseInvoice.vue @@ -0,0 +1,119 @@ + + + + + + diff --git a/src/components/BaseIssues.vue b/src/components/BaseIssues.vue new file mode 100644 index 0000000..71b8763 --- /dev/null +++ b/src/components/BaseIssues.vue @@ -0,0 +1,358 @@ + + + diff --git a/src/components/BaseMarkdown.vue b/src/components/BaseMarkdown.vue index 1738c78..e112b80 100644 --- a/src/components/BaseMarkdown.vue +++ b/src/components/BaseMarkdown.vue @@ -14,6 +14,7 @@ label='show full post' @click.stop="expand" /> + @@ -27,7 +28,8 @@ import deflist from 'markdown-it-deflist' import taskLists from 'markdown-it-task-lists' import emoji from 'markdown-it-emoji' import helpersMixin from '../utils/mixin' -// import BaseLinkPreview from 'components/BaseLinkPreview.vue' +import * as bolt11Parser from 'light-bolt11-decoder' +import BaseInvoice from 'components/BaseInvoice.vue' const md = MarkdownIt({ html: false, @@ -56,6 +58,7 @@ md.use(subscript) trimmed.endsWith('.png') || trimmed.endsWith('.jpeg') || trimmed.endsWith('.jpg') || + trimmed.endsWith('.svg') || trimmed.endsWith('.mp4') || trimmed.endsWith('.webm') || trimmed.endsWith('.ogg') @@ -76,9 +79,10 @@ md.use(subscript) trimmed.endsWith('.gif') || trimmed.endsWith('.png') || trimmed.endsWith('.jpeg') || - trimmed.endsWith('.jpg') + trimmed.endsWith('.jpg') || + trimmed.endsWith('.svg') ) { - return `` + return `` } else if ( trimmed.endsWith('.mp4') || trimmed.endsWith('.webm') || @@ -178,14 +182,15 @@ md.linkify export default { name: 'BaseMarkdown', mixins: [helpersMixin], - emits: ['expand'], - // components: { - // BaseLinkPreview, - // }, + emits: ['expand', 'resized'], + components: { + BaseInvoice, + }, data() { return { html: '', + invoice: null, // links: [], } }, @@ -201,6 +206,23 @@ export default { }, }, + computed: { + parsedContent() { + const bolt11Regex = /\b(?(lnbc|LNBC)[0-9a-zA-Z]*1[0-9a-zA-Z]+)\b/g + const replacer = (match, index) => { + try { + this.invoice = bolt11Parser.decode(match) + return '' + } catch (e) { + console.log('invoice parsing error', e) + return match + } + } + let replacedContent = this.content.replace(bolt11Regex, replacer) + return replacedContent + } + }, + mounted() { this.render() }, @@ -211,7 +233,7 @@ export default { methods: { render() { - this.html = md.render(this.content) + this.$refs.append.innerHTML + this.html = md.render(this.parsedContent) + this.$refs.append.innerHTML // md.render(this.$refs.src.innerHTML) + this.$refs.append.innerHTML this.$refs.html.querySelectorAll('img').forEach(img => { img.addEventListener('click', (e) => { @@ -221,6 +243,10 @@ export default { } else if (document.exitFullscreen) { document.exitFullscreen() } + this.$emit('resized') + }) + img.addEventListener('load', (e) => { + this.$emit('resized') }) }) // if (this.links.length === 0) { diff --git a/src/components/BasePost.vue b/src/components/BasePost.vue index 72f3114..5f416f8 100644 --- a/src/components/BasePost.vue +++ b/src/components/BasePost.vue @@ -85,9 +85,15 @@
- - - {{ cleanEvent }} + + +
 {{ cleanEvent }} 
500 + this.isLongForm = this.event.content.length > 600 }, activated() { @@ -406,6 +412,10 @@ export default defineComponent({ console.log('post reply threads add-event', event) this.$emit('add-event', event) }, + + sanitize(text) { + return DOMPurify.sanitize(text) + } } }) diff --git a/src/components/BaseRawEvent.vue b/src/components/BaseRawEvent.vue index 131ada5..91f2ff9 100644 --- a/src/components/BaseRawEvent.vue +++ b/src/components/BaseRawEvent.vue @@ -14,6 +14,7 @@ import helpersMixin from '../utils/mixin' import {cleanEvent} from '../utils/event' import BaseButtonCopy from 'components/BaseButtonCopy.vue' +import * as DOMPurify from 'dompurify' export default { name: 'BaseRawEvent', @@ -25,20 +26,16 @@ export default { computed: { cleaned() { - if (Array.isArray(this.event)) return this.event.map(cleanEvent) - return cleanEvent(this.event) + console.log('cleaned', JSON.parse(DOMPurify.sanitize(JSON.stringify(this.event)))) + if (Array.isArray(this.event)) return this.event.map(event => cleanEvent(this.sanitize(event))) + return cleanEvent(this.sanitize(this.event)) } }, - // methods: { - // copyText(defaultText) { - // console.log('defaultText: ', defaultText) - // let selection = window.getSelection().toString() - // console.log('selection: ', selection) - // if (selection) { - // return selection - // } else return defaultText - // }, - // } + methods: { + sanitize(event) { + return JSON.parse(DOMPurify.sanitize(JSON.stringify(this.event))) + }, + } } diff --git a/src/components/BaseUserAvatar.vue b/src/components/BaseUserAvatar.vue index ddd3d5d..9cb0748 100644 --- a/src/components/BaseUserAvatar.vue +++ b/src/components/BaseUserAvatar.vue @@ -1,7 +1,7 @@ -
+ diff --git a/src/i18n/en/index.js b/src/i18n/en/index.js index 0c3b204..717166e 100644 --- a/src/i18n/en/index.js +++ b/src/i18n/en/index.js @@ -85,6 +85,7 @@ export default { replies: 'replies', profile: 'profile', relays: 'relays', + faq: 'faq', users: 'users', nip05Maintainer: 'NIP05 maintainer', inactiveRelays: 'inactive relays', diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index e7d0362..50c9034 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -2,7 +2,7 @@ - +
@@ -20,8 +20,8 @@ - - + + @@ -161,12 +161,14 @@ export default defineComponent({ setup () { const $q = useQuasar() + // const cachedPages = ref(['feed', 'notifications', 'messages']) return $q }, data() { return { + cachedPages: ['Feed', 'Notifications', 'Messages'], middlePagePos: {}, fabPos: [0, 10], draggingFab: false, @@ -243,7 +245,7 @@ export default defineComponent({ }, preserveScrollPos(to, from) { - this.middlePagePos[from.fullPath] = getVerticalScrollPosition(this.scrollingContainer) + if (this.cachedPages.map(page => page.toLowerCase()).includes(from.name)) this.middlePagePos[from.fullPath] = getVerticalScrollPosition(this.scrollingContainer) }, restoreScrollPos(to, from) { @@ -283,11 +285,8 @@ export default defineComponent({ if (this.hasLaunched) { activateSub() } - if (this.$store.state.keys.pub) { - this.$store.dispatch('launch') - } else { - this.$store.dispatch('launchWithoutKey') - } + if (this.$store.state.keys.pub) this.$store.dispatch('launch') + else this.$store.dispatch('launchWithoutKey') this.hasLaunched = true }, @@ -409,6 +408,9 @@ export default defineComponent({ // } } // console.log('font', getCssVar('font'), this.googleFontsName) + }, + setLookingAroundMode() { + this.lookingAround = true } }, }) diff --git a/src/pages/Event.vue b/src/pages/Event.vue index a00a67a..ad51be3 100644 --- a/src/pages/Event.vue +++ b/src/pages/Event.vue @@ -2,7 +2,7 @@ {{ $t('thread') }} -
+
@@ -17,7 +17,7 @@
{{ $t('event') }} {{ $route.params.eventId }}
- + @@ -28,7 +28,7 @@
-
+
@@ -86,11 +86,11 @@ export default defineComponent({ } }, - activated() { + mounted() { this.start() }, - deactivated() { + beforeUnmount() { this.stop() }, diff --git a/src/pages/Feed.vue b/src/pages/Feed.vue index 71d8557..2a11a55 100644 --- a/src/pages/Feed.vue +++ b/src/pages/Feed.vue @@ -29,10 +29,10 @@ :label='"load " + unreadFeed[tab].length + " unread"' @click='loadUnread' /> - + @@ -43,7 +43,7 @@ import { defineComponent } from 'vue' import helpersMixin from '../utils/mixin' import {addToThread} from '../utils/threads' import {isValidEvent} from '../utils/event' -import {streamFeed, dbFeed, dbUserFollows} from '../query' +import {dbFeed, dbUserFollows} from '../query' import BaseButtonLoadMore from 'components/BaseButtonLoadMore.vue' import { createMetaMixin } from 'quasar' @@ -78,9 +78,23 @@ export default defineComponent({ BaseButtonLoadMore, }, + watch: { + lookingAround(curr, prev) { + if (curr) { + this.loadMore() + } + } + }, + + props: { + lookingAround: { + type: Boolean, + default: false, + } + }, + data() { return { - listener: null, reachedEnd: false, feed: { follows: [], @@ -88,6 +102,18 @@ export default defineComponent({ AI: [], bots: [] }, + feedCounts: { + follows: 100, + global: 100, + AI: 100, + bots: 100 + }, + unreadCounts: { + follows: 100, + global: 100, + AI: 100, + bots: 100 + }, unreadFeed: { follows: [], global: [], @@ -101,20 +127,21 @@ export default defineComponent({ loadingMore: true, loadingUnread: false, tab: 'follows', - sub: null, - since: Math.round(Date.now() / 1000) - (1 * 24 * 60 * 60), + since: Math.round(Date.now() / 1000), profilesUsed: new Set(), // index: 0, - active: false, + lastLoaded: Math.round(Date.now() / 1000), + refreshInterval: null, + unsubscribe: null, } }, computed: { items() { - if (this.tab === 'follows') return this.feed.follows - if (this.tab === 'global') return this.feed.global - if (this.tab === 'AI') return this.feed.AI - if (this.tab === 'bots') return this.feed.bots + if (this.tab === 'follows') return this.feed.follows.slice(0, this.feedCounts['follows']) + if (this.tab === 'global') return this.feed.global.slice(0, this.feedCounts['global']) + if (this.tab === 'AI') return this.feed.AI.slice(0, this.feedCounts['AI']) + if (this.tab === 'bots') return this.feed.bots.slice(0, this.feedCounts['bots']) return [] } }, @@ -123,76 +150,68 @@ export default defineComponent({ this.bots = await this.getFollows(this.botTracker) this.follows = await this.getFollows(this.$store.state.keys.pub) - this.loadMore() + if (this.$store.state.keys.pub) this.loadMore() + else { + this.unsubscribe = this.$store.subscribe((mutation, state) => { + switch (mutation.type) { + case 'setKeys': { + this.loadingMore = true + setTimeout(this.loadMore(), 6) + break + } + } + }) + } if (this.follows.length === 0) { this.tab = 'global' } }, - activated() { - // console.log('feed activated', this.index) - // this.$refs.virtualScroll.refresh(this.index) - this.active = true - }, - async beforeUnmount() { if (this.listener) this.listener.cancel() - if (this.sub) this.sub.cancel() - this.sub = null this.profilesUsed.forEach(pubkey => this.$store.dispatch('cancelUseProfile', {pubkey})) - }, - - deactivated() { - // console.log('feed deactivated', this.index) - this.active = false + if (this.unsubscribe) this.unsubscribe() }, methods: { async loadMore() { this.loadingMore = true + if (this.items.length < this.feed[this.tab].length) { + this.feedCounts[this.tab] += 100 + this.loadingMore = false + return + } + let loadedFeed = {} for (let feed of Object.keys(this.feed)) { loadedFeed[feed] = [] } - // let timer = setTimeout(() => { this.loadingMore = false }, 1000) - if (this.sub) { - this.since = this.since - (24 * 60 * 60) - this.sub.update(this.since - (24 * 60 * 60)) - } else this.sub = await streamFeed(this.since - (24 * 60 * 60), (event) => { - this.processEvent(event, this.unreadFeed) - }) + this.since = this.since - (6 * 60 * 60) let results = await dbFeed(this.since) if (results) for (let event of results) this.processEvent(event, loadedFeed) for (let feed of Object.keys(this.feed)) { this.feed[feed] = this.feed[feed].concat(loadedFeed[feed]) } + this.refreshInterval = setInterval(async () => { + let results = await dbFeed(this.lastLoaded) + if (results) for (let event of results) this.processEvent(event, this.unreadFeed) + for (let feed of Object.keys(this.feed)) { + this.feed[feed] = this.feed[feed].concat(this.unreadFeed[feed]) + } + }, 10000) + this.loadingMore = false - // this.sub = await dbStreamFeed(this.since, event => { - // if (!timer) { - // this.processEvent(event, this.feed) - // return - // } - // clearTimeout(timer) - // timer = setTimeout(() => { - // for (let feed of Object.keys(this.feed)) { - // this.feed[feed] = this.feed[feed].concat(loadedFeed[feed]) - // } - // timer = null - // this.loadingMore = false - // }, 300) - // this.loadingMore = false - // this.processEvent(event, loadedFeed) - // }) }, loadUnread() { this.loadingUnread = true this.feed[this.tab] = this.unreadFeed[this.tab].concat(this.feed[this.tab]) this.unreadFeed[this.tab] = [] + this.lastLoaded = Math.round(Date.now() / 1000) this.loadingUnread = false }, diff --git a/src/pages/Hashtag.vue b/src/pages/Hashtag.vue index ee9b9c1..1c5c8c5 100644 --- a/src/pages/Hashtag.vue +++ b/src/pages/Hashtag.vue @@ -46,11 +46,11 @@ export default defineComponent({ } }, - activated() { + mounted() { this.start() }, - deactivated() { + beforeUnmount() { this.stop() }, @@ -64,9 +64,9 @@ export default defineComponent({ this.processEvent(event) }) - this.sub.hashtagOld = await dbStreamTagKind('hashtag', this.$route.params.hashtagId.toLowerCase(), 1, event => { - this.processEvent(event) - }) + // this.sub.hashtagOld = await dbStreamTagKind('hashtag', this.$route.params.hashtagId.toLowerCase(), 1, event => { + // this.processEvent(event) + // }) }, stop() { diff --git a/src/pages/Inbox.vue b/src/pages/Inbox.vue index 77cbd62..79cefbf 100644 --- a/src/pages/Inbox.vue +++ b/src/pages/Inbox.vue @@ -43,7 +43,7 @@