Basic Thread page

This commit is contained in:
styppo 2023-01-08 21:39:02 +00:00
parent 61e78904d4
commit 9222f8fb24
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
15 changed files with 991 additions and 106 deletions

View File

@ -17,6 +17,12 @@
"cross-fetch": "^3.1.5",
"emoji-mart-vue-fast": "^12.0.1",
"jdenticon": "^3.2.0",
"light-bolt11-decoder": "^2.1.0",
"markdown-it": "^13.0.1",
"markdown-it-deflist": "^2.1.0",
"markdown-it-emoji": "^2.0.2",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"moment": "^2.29.4",
"pinia": "^2.0.11",
"quasar": "^2.6.0",

View File

@ -0,0 +1,121 @@
<!-- Adapted from https://github.com/monlovesmango/astral/blob/master/src/components/BaseInvoice.vue -->
<template>
<div style='border: 3px double var(--q-accent); padding: .5rem; margin: .3rem;'>
<div class='flex row no-wrap justify-between full-width'>
<div style='font-size: .8rem;' :style='showQr ? "" : "width: 60%"' class='flex column'>
<div class='text-bold' style='font-size: 1.1rem;'>lightning invoice</div>
<div v-if='description'>desc: {{description}}</div>
<div>amount: {{amount ? `${amount} sats` : 'none specified'}}</div>
<div>created date: {{ created }}</div>
<div v-if='expires'>expires date: {{ expires }}</div>
<div class='flex row no-wrap justify-start q-pt-xs' style='gap: .5rem;'>
<!-- <BaseButtonCopy :button-text='request' button-label='copy invoice' outline class='q-pr-xs' @click.stop='showQr=false'/>-->
<q-btn label='show qr' icon='qr_code_2' outline size='sm' dense unelevated class='q-pr-xs' @click.stop='renderQr'/>
</div>
</div>
<div class='flex column' style='font-size: .7rem; padding: .4rem;' :style='showQr ? "" : "width: 40%"'>
<div v-if='!showQr' class='break-word-wrap' style='overflow-y: auto; max-height: 170px;'>{{request}}</div>
<img v-show='showQr' ref='qr' style='object-fit: cover; min-height: 200px; min-width: 200px;'/>
</div>
</div>
</div>
</template>
<script>
// import BaseButtonCopy from '../components/BaseButtonCopy'
// import qrcodegen from 'nayuki-qr-code-generator'
// import {toSvgString} from 'awesome-qr-code-generator'
export default {
name: 'BaseInvoice',
props: {
invoice: {
type: Object,
required: true
}
},
components: {
// BaseButtonCopy
},
data() {
return {
showQr: false,
// links: [],
}
},
computed: {
includesAmount() {
if (this.invoice.sections[2].name === 'separator') return false
return true
},
amount() {
if (this.includesAmount) return this.invoice.sections[2].value / 1000
return null
},
description() {
if (this.includesAmount) return this.invoice.sections[6].value
return this.invoice.sections[5].value
},
created() {
if (this.includesAmount) return parseInt(this.invoice.sections[4].value)
return parseInt(this.invoice.sections[3].value)
},
expires() {
let expiresValue = this.includesAmount ? parseInt(this.invoice.sections[8].value) : parseInt(this.invoice.sections[7].value)
let parsed = parseInt(expiresValue)
if (isNaN(parsed)) return null
return this.created + parsed
},
request() {
return this.invoice.paymentRequest
}
},
mounted() {
// console.log('invoice', this.invoice)
},
methods: {
renderQr(e) {
this.showQr = true
// FIXME
// let qr = qrcodegen.QrCode.encodeText(this.request, qrcodegen.QrCode.Ecc.MEDIUM)
// let svgSrc = this.toSvgString(qr, 4)
// this.$refs.qr.src = svgSrc
// console.log('qr', qr)
},
toSvgString(qr, border) {
let lightColor = '#FFFFFF'
let darkColor = '#000000'
if (border < 0)
throw new RangeError('Border must be non-negative')
let parts = []
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
if (qr.getModule(x, y))
parts.push(`M${x + border},${y + border}h1v1h-1z`)
}
}
let svg = `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 ${qr.size + border * 2} ${qr.size + border * 2}" stroke="none">
<rect width="100%" height="100%" fill="${lightColor}"/>
<path d="${parts.join(' ')}" fill="${darkColor}"/>
</svg>
`
let blob = new Blob([svg], {type: 'image/svg+xml'})
return URL.createObjectURL(blob)
}
},
}
</script>
<style lang='scss' scoped>
.q-btn {
opacity: .7;
transition: all .3s ease-in-out;
}
.q-btn:hover {
opacity: 1;
}
</style>

View File

@ -0,0 +1,392 @@
<!-- Adapted from https://github.com/monlovesmango/astral/blob/master/src/components/BaseMarkdown.vue -->
<template>
<div ref="src" class="hidden break-word-wrap"><slot /></div>
<div ref="append" class="hidden break-word-wrap"><slot name="append" /></div>
<div v-html="html" ref="html" class="break-word-wrap dynamic-content markdown" @click='handleClicks' :class='longForm ? "long-form" : ""'/>
<q-btn
v-if='longForm'
id='long-form-button'
dense
outline
rounded
color="accent"
class='text-weight-light q-ma-sm justify-between'
style='letter-spacing: .1rem; justify-content: space-between;'
label='show full post'
@click.stop="expand"
/>
<BaseInvoice v-if='invoice' :invoice='invoice'/>
<!-- <div v-if='links.length'>
<BaseLinkPreview v-for='(link, idx) of links' :key='idx' :url='link' />
</div> -->
</template>
<script>
import MarkdownIt from 'markdown-it'
import subscript from 'markdown-it-sub'
import superscript from 'markdown-it-sup'
import deflist from 'markdown-it-deflist'
import emoji from 'markdown-it-emoji'
import * as Bolt11Decoder from 'light-bolt11-decoder'
import BaseInvoice from 'components/post/BaseInvoice.vue'
const md = MarkdownIt({
html: false,
breaks: true,
linkify: true
})
md.use(subscript)
.use(superscript)
.use(deflist)
.use(emoji)
.use(md => {
// pulled from https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
// Remember old renderer, if overridden, or proxy to default renderer
var defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options)
}
md.core.ruler.before('normalize', 'auto-imager', state => {
state.src = state.src.replace(/https?:[^ \n]+/g, m => {
if (m) {
let trimmed = m.split('?')[0]
if (
trimmed.endsWith('.gif') ||
trimmed.endsWith('.png') ||
trimmed.endsWith('.jpeg') ||
trimmed.endsWith('.jpg') ||
trimmed.endsWith('.svg') ||
trimmed.endsWith('.mp4') ||
trimmed.endsWith('.webm') ||
trimmed.endsWith('.ogg')
) {
return `![](${m})`
}
}
return m
})
})
md.renderer.rules.image = (tokens, idx) => {
let src = tokens[idx].attrs[[tokens[idx].attrIndex('src')]][1]
let trimmed = src.split('?')[0]
// let classIndex = token.attrIndex('class')
if (
trimmed.endsWith('.gif') ||
trimmed.endsWith('.png') ||
trimmed.endsWith('.jpeg') ||
trimmed.endsWith('.jpg') ||
trimmed.endsWith('.svg')
) {
return `<img src="${src}" crossorigin async loading='lazy' style="max-width: 90%; max-height: 50vh;">`
} else if (
trimmed.endsWith('.mp4') ||
trimmed.endsWith('.webm') ||
trimmed.endsWith('.ogg')
) {
return `<video src="${src}" controls crossorigin async style="max-width: 90%; max-height: 50vh;"></video>`
}
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
// If you are sure other plugins can't add `target` - drop check below
var token = tokens[idx]
var aIndexTarget = token.attrIndex('target')
var aIndexHref = token.attrIndex('href')
// // this works but errors bc youtube needs to add header Cross-Origin-Embedder-Policy "require-corp"
// // see issue https://issuetracker.google.com/issues/240387105
// var ytRegex = /^https:\/\/(www.|m.)youtu(be.com|.be)\/(watch\?v=|shorts\/)(?<v>[a-zA-Z0-9_-]{11})(&t=(?<s>[0-9]+)s)?/
// let ytMatch = token.attrs[aIndexHref][1].match(ytRegex)
// console.log('ytMatch', ytMatch, token.attrs[aIndexHref][1])
// if (ytMatch) {
// let src = `https://www.youtube.com/embed/${ytMatch.groups.v}`
// if (ytMatch.groups.s) src = src + `?start=${ytMatch.groups.s}`
// src = src + `&origin=http://localhost:8080/`
// console.log('ytMatch', src)
// return `<iframe crossorigin anonymous async style="max-width: 90%; max-height: 50vh;"" src="${src}"
// title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
// </iframe>`
// }
var httpRegex = /^https?:\/\//
if (httpRegex.test(token.attrs[aIndexHref][1])) {
if (aIndexTarget < 0) {
tokens[idx].attrPush(['target', '_blank']) // add new attribute
} else {
tokens[idx].attrs[aIndexTarget][1] = '_blank' // replace value of existing attr
}
}
// pass token to default renderer.
return defaultRender(tokens, idx, options, env, self)
}
// md.renderer.rules.code_inline = function (tokens, idx, options, env, self) {
// var token = tokens[idx]
// return `<code ${self.renderAttrs(token)}>${token.content}</code>`
// }
// md.renderer.rules.code_block = function (tokens, idx, options, env, self) {
// var token = tokens[idx]
// return `<code ${self.renderAttrs(token)}>${token.content}</code>`
// }
})
md.linkify
.tlds(['onion', 'eth'], true)
.add('bitcoin:', null)
.add('lightning:', null)
.add('http:', {
validate(text, pos, self) {
// copied from linkify defaultSchemas
var tail = text.slice(pos)
if (!self.re.http) {
self.re.http = new RegExp(
'^\\/\\/' +
self.re.src_auth +
self.re.src_host_port_strict +
self.re.src_path,
'i'
)
}
if (self.re.http.test(tail)) {
return tail.match(self.re.http)[0].length
}
return 0
},
normalize(match, self) {
if (self.__text_cache__.length < 150 || match.text < 23) {
return
}
let url = new URL(match.url)
let text = url.host
if ((url.pathname + url.search + url.hash).length > 10) {
let suffix = match.text.slice(-7)
if (suffix[0] === '/') suffix = suffix.slice(1)
text += `/…/${suffix}`
}
match.text = text
}
})
.set({fuzzyEmail: false})
export default {
name: 'BaseMarkdown',
emits: ['expand', 'resized'],
components: {
BaseInvoice,
},
data() {
return {
html: '',
invoice: null,
// links: [],
}
},
props: {
content: {
type: String,
default: 'todo needs to be updated'
},
longForm: {
type: Boolean,
default: false
},
},
computed: {
parsedContent() {
const bolt11Regex = /\b(?<i>(lnbc|LNBC)[0-9a-zA-Z]*1[0-9a-zA-Z]+)\b/g
const replacer = (match, index) => {
try {
this.invoice = Bolt11Decoder.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()
},
updated() {
this.render()
},
methods: {
render() {
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) => {
e.stopPropagation()
if (!document.fullscreenElement) {
img.requestFullscreen()
} else if (document.exitFullscreen) {
document.exitFullscreen()
}
this.$emit('resized')
})
img.addEventListener('load', (e) => {
this.$emit('resized')
})
})
// if (this.links.length === 0) {
// this.$refs.html.querySelectorAll('a').forEach(link => this.links.push(link.href))
// // if (links[0] && links[0].href) this.links.push(links[0].href)
// // links.forEach(link => this.links.push(link.href))
// console.log('links', this.links)
// }
},
handleClicks(event) {
// ensure we use the link, in case the click has been received by a subelement
let { target } = event
// while (target && target.tagName !== 'A') target = target.parentNode
// handle only links that occur inside the component and do not reference external resources
if (target && target.matches(".dynamic-content a:not([href*='://'])") && target.href) {
// some sanity checks taken from vue-router:
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } = event
// don't handle with control keys
if (metaKey || altKey || ctrlKey || shiftKey) return
// don't handle when preventDefault called
if (defaultPrevented) return
// don't handle right clicks
if (button !== undefined && button !== 0) return
// don't handle if `target="_blank"`
if (target && target.getAttribute) {
const linkTarget = target.getAttribute('target')
if (/\b_blank\b/i.test(linkTarget)) return
}
// don't handle same page links/anchors
const url = new URL(target.href)
const to = url.pathname
if (window.location.pathname !== to && event.preventDefault) {
event.preventDefault()
event.stopPropagation()
this.$router.push(to)
}
// stop propogation of external links
} else if (target && target.matches(".dynamic-content a[href*='://']") && target.href) {
event.stopPropagation()
}
},
expand(event) {
// document.querySelector('#long-form-button').style.display = 'none'
// document.querySelector('#post-text').classList.remove('long-form')
this.$emit('expand')
}
}
}
</script>
<style lang='scss'>
.markdown {
a {
text-decoration: underline;
color: #448195;
}
p {
margin-block-end: .5rem;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
ul,
ol {
list-style-position: outside;
padding-inline-start: 1.5rem;
margin-block-start: .5rem;
margin-block-end: .5rem;
text-align: left;
}
.post-highlighted ul,
.post-highlighted ol {
padding-inline-start: 2rem;
}
ul ul,
ol ul {
list-style-type: circle;
list-style-position: outside;
margin-left: .5rem;
}
ol ol,
ul ol {
list-style-type: lower-latin;
list-style-position: outside;
margin-left: .5rem;
}
}
.break-word-wrap p:last-of-type {
margin: 0;
}
.break-word-wrap {
word-wrap: break-word;
word-break: break-word;
}
.break-word-wrap p:has(img),
.break-word-wrap p:has(video) {
display: inline-block;
}
.break-word-wrap img,
.break-word-wrap video {
border-radius: 1rem;
border: 1px solid var(--q-accent);
display: block;
}
.break-word-wrap pre {
overflow: auto;
}
.long-form {
max-height: 10rem;
overflow-y: hidden;
/* Permalink - use to edit and share this gradient: https://colorzilla.com/gradient-editor/#000000+0,000000+100&1+0,1+51,0.7+58,0+100 */
background: -moz-linear-gradient(top, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 51%, rgba(0,0,0,0.7) 58%, rgba(0,0,0,0) 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(top, rgba(0,0,0,1) 0%,rgba(0,0,0,1) 51%,rgba(0,0,0,0.7) 58%,rgba(0,0,0,0) 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to bottom, rgba(0,0,0,1) 0%,rgba(0,0,0,1) 51%,rgba(0,0,0,0.7) 58%,rgba(0,0,0,0) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#000000', endColorstr='#00000000',GradientType=0 ); /* IE6-9 */
margin: 0;
padding: 0;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.body--dark .long-form {
/* Permalink - use to edit and share this gradient: https://colorzilla.com/gradient-editor/#000000+0,000000+100&1+0,1+51,0.7+58,0+100 */
background: -moz-linear-gradient(top, rgba(255,255,255,1) 0%, rgba(255,255,255,1) 51%, rgba(255,255,255,0.7) 58%, rgba(255,255,255,0) 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(top, rgba(255,255,255,1) 0%,rgba(255,255,255,1) 51%,rgba(255,255,255,0.7) 58%,rgba(255,255,255,0) 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to bottom, rgba(255,255,255,1) 0%,rgba(255,255,255,1) 51%,rgba(255,255,255,0.7) 58%,rgba(255,255,255,0) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#000000', endColorstr='#00000000',GradientType=0 ); /* IE6-9 */
background-clip: text;
-webkit-background-clip: text;
}
</style>

View File

@ -5,46 +5,49 @@
<div class="connector-top">
<div v-if="connector" class="connector-line"></div>
</div>
<UserAvatar :pubkey="post.author" />
<UserAvatar :pubkey="note.author" clickable />
</div>
<div class="post-author-name">
<UserName :pubkey="post.author" two-line />
<UserName :pubkey="note.author" clickable two-line />
</div>
</div>
<div class="post-content">
<div class="post-content-header">
<p v-if="post.inReplyTo" class="in-reply-to">
Replying to <a @click.stop="linkToEvent(post.inReplyTo)">{{ shorten(post.inReplyTo) }}</a>
<p v-if="note.isReply()" class="in-reply-to">
Replying to
<a @click.stop="linkToProfile(ancestor?.author)">
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
</a>
</p>
</div>
<div class="post-content-body">
<p>
<!-- <BaseMarkdown :content="post.content" />-->
<BaseMarkdown :content="note.content" />
</p>
</div>
<div class="post-content-footer">
<p class="created-at">
<span>{{ formatPostTime(post.createdAt) }}</span>
<span>{{ formatTime(note.createdAt) }}</span>
<span>&#183;</span>
<span>{{ formatPostDate(post.createdAt) }}</span>
<span>{{ formatDate(note.createdAt) }}</span>
</p>
<div class="post-content-actions">
<div class="action-item comment">
<BaseIcon icon="comment" />
<span>{{ numComments }}</span>
<span>{{ stats.comments }}</span>
</div>
<div class="action-item repost">
<BaseIcon icon="repost" />
<span>{{ post.stats.reposts }}</span>
<span>{{ stats.shares }}</span>
</div>
<div class="action-item like">
<BaseIcon icon="like" />
<span>{{ post.stats.likes }}</span>
<span>{{ stats.reactions }}</span>
</div>
</div>
</div>
</div>
<div class="post-reply">
<div v-if="app.isSignedIn" class="post-reply">
<PostEditor
compact
placeholder="Post your reply"
@ -57,57 +60,25 @@
import BaseIcon from 'components/BaseIcon'
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
// import BaseMarkdown from 'app/tmp/BaseMarkdown.vue'
import BaseMarkdown from 'components/Post/BaseMarkdown.vue'
import PostEditor from 'components/CreatePost/PostEditor.vue'
import {useNostrStore} from 'src/nostr/NostrStore'
import {useAppStore} from 'stores/App'
import routerMixin from 'src/router/mixin'
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
function countRepliesRecursive(event) {
if (!event.replies) {
return 0
}
let count = 0
for (const thread of event.replies) {
if (!thread || !thread.length) {
continue
}
count += thread.length
for (const reply of thread) {
count += countRepliesRecursive(reply)
}
}
return count
}
function postFromEvent(event) {
return {
id: event.id,
author: event.pubkey,
createdAt: event.created_at * 1000,
content: event.interpolated.text,
inReplyTo: event.interpolated.replyEvents[event.interpolated.replyEvents.length - 1],
images: [],
stats: {
comments: '',
reposts: '',
likes: '',
}
}
}
import DateUtils from 'src/utils/DateUtils'
export default {
name: 'HeroPost',
mixins: [routerMixin],
components: {
// BaseMarkdown,
BaseMarkdown,
UserName,
UserAvatar,
BaseIcon,
PostEditor,
},
props: {
event: {
note: {
type: Object,
required: true
},
@ -116,30 +87,29 @@ export default {
default: false,
},
},
data() {
setup() {
return {
post: postFromEvent(this.event),
app: useAppStore(),
nostr: useNostrStore()
}
},
computed: {
numComments() {
return countRepliesRecursive(this.event)
ancestor() {
return this.note.isReply()
? this.nostr.getNote(this.note.ancestor())
: null
},
stats() {
return {
comments: 69,
reactions: 420,
shares: 4711,
}
},
},
methods: {
formatPostDate(timestamp) {
const date = new Date(timestamp)
const month = this.$t(MONTHS[date.getMonth()])
const sameYear = date.getFullYear() === (new Date().getFullYear())
const year = !sameYear ? ' ' + date.getFullYear() : ''
return `${date.getDate()} ${month}${year}`
},
formatPostTime(timestamp) {
const date = new Date(timestamp)
return `${date.getHours()}:${date.getMinutes()}`
}
formatDate: DateUtils.formatDate,
formatTime: DateUtils.formatTime,
}
}
</script>

View File

@ -2,7 +2,7 @@
<div
class="post"
:class="{clickable}"
@click.stop="clickable && linkToEvent(note.id)"
@click.stop="clickable && linkToThread(note.id)"
>
<div class="post-author">
<div class="connector-top">
@ -28,8 +28,8 @@
</p>
</div>
<div class="post-content-body">
<!-- <BaseMarkdown :content="note.content" />-->
{{ note.content }}
<BaseMarkdown :content="note.content" />
<!-- {{ note.content }}-->
</div>
<div v-if="actions" class="post-content-actions">
<div class="action-item comment" @click.stop="app.createPost({ancestor: note.ancestor()})">
@ -53,12 +53,11 @@
import BaseIcon from 'components/BaseIcon'
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
// import BaseMarkdown from 'app/tmp/BaseMarkdown.vue'
// import Note from 'src/nostr/model/Note'
import BaseMarkdown from 'components/Post/BaseMarkdown.vue'
import routerMixin from 'src/router/mixin'
import moment from 'moment'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import DateUtils from 'src/utils/DateUtils'
export default {
name: 'ListPost',
@ -66,7 +65,7 @@ export default {
components: {
UserAvatar,
UserName,
// BaseMarkdown,
BaseMarkdown,
BaseIcon,
},
props: {
@ -113,22 +112,9 @@ export default {
},
methods: {
formatPostDate(timestamp) {
return this.$q.screen.lt.md
? this.shortDateFromNow(timestamp)
: moment(timestamp * 1000).fromNow()
const format = this.$q.screen.lt.md ? 'short' : 'long'
return DateUtils.formatFromNow(timestamp, format)
},
shortDateFromNow(timestamp) {
const now = Date.now()
const diff = Math.round(Math.max(now - (timestamp * 1000), 0) / 1000)
const formatDiff = (div, offset) => Math.max(Math.floor((diff + offset) / div), 1)
if (diff < 45) return `${formatDiff(1, 0)}s`
if (diff < 60 * 45) return `${formatDiff(60, 15)}m`
if (diff < 60 * 60 * 22) return `${formatDiff(60 * 60, 60 * 15)}h`
if (diff < 60 * 60 * 24 * 26) return `${formatDiff(60 * 60 * 24, 60 * 60 * 2)}d`
if (diff < 60 * 60 * 24 * 30 * 320) return `${formatDiff(60 * 60 * 24 * 30, 60 * 60 * 24 * 4)}mo`
return `${formatDiff(60 * 60 * 24 * 30 * 365, 60 * 60 * 24 * 45)}y`
}
},
}
</script>

View File

@ -2,6 +2,5 @@
// so you can safely delete all default props below
export default {
failed: 'Action failed',
success: 'Action was successful'
'thread': 'Thread'
}

View File

@ -13,13 +13,17 @@ export default class FetchQueue extends Observable {
this.maxRetries = opts.maxRetries || 3
this.queue = {}
this.failed = {}
this.fetching = false
this.fetchQueued = false
this.retryInterval = null
}
add(id) {
if (this.queue[id] === undefined) return
if (!id) throw new Error(`invalid id ${id}`)
if (this.queue[id] !== undefined) return
if (this.failed[id]) return // TODO improve this
this.queue[id] = 0
if (!this.fetching) {
@ -45,6 +49,7 @@ export default class FetchQueue extends Observable {
this.queue[id]++
if (this.queue[id] > this.maxRetries) {
console.warn(`Failed to fetch ${this.subId} ${id}`)
this.failed[id] = true
delete this.queue[id]
} else {
filteredIds.push(id)
@ -59,23 +64,23 @@ export default class FetchQueue extends Observable {
this.client.unsubscribe(this.subId)
this.client.subscribe(
this.fnCreateFilter(ids),
this.fnCreateFilter(filteredIds),
(event, relay, subId) => {
const id = this.fnGetId(event)
delete this.queue[id]
ids.splice(ids.indexOf(id), 1)
filteredIds.splice(filteredIds.indexOf(id), 1)
console.log(`Fetched ${this.subId} ${id}, ${ids.length} remaining`)
// console.log(`Fetched ${this.subId} ${id}, ${filteredIds.length} remaining`)
this.emit('event', event, relay)
if (Object.keys(this.queue).length === 0) {
console.log(`Fetched all ${this.subId}s`)
// console.log(`Fetched all ${this.subId}s`)
if (this.retryInterval) clearInterval(this.retryInterval)
this.client.unsubscribe(subId)
this.fetching = false
} else if (ids.length === 0) {
console.log(`Batch ${this.subId} fetched, requesting more (${Object.keys(this.queue).length} remain)`)
} else if (filteredIds.length === 0) {
// console.log(`Batch ${this.subId} fetched, requesting more (${Object.keys(this.queue).length} remain)`)
this.fetch()
}
},

View File

@ -114,6 +114,13 @@ export const useNostrStore = defineStore('nostr', {
return note
},
getRepliesTo(id, order = NoteOrder.CREATION_DATE_DESC) {
const notes = useNoteStore()
const replies = notes.repliesTo(id, order)
// TODO fetch
return replies
},
getNotesByAuthor(pubkey, opts = {}) {
const order = opts.order || NoteOrder.CREATION_DATE_DESC
const notes = useNoteStore()
@ -131,6 +138,21 @@ export const useNostrStore = defineStore('nostr', {
)
},
streamThread(rootId, eventCallback, initialFetchCompleteCallback) {
return this.streamEvents(
{
kinds: [EventKind.NOTE],
'#e': [rootId],
},
500,
eventCallback,
initialFetchCompleteCallback,
{
subId: `thread:${rootId}`,
}
)
},
streamFeed(feed, eventCallback, initialFetchCompleteCallback) {
return this.streamEvents(
feed.filters,
@ -143,8 +165,8 @@ export const useNostrStore = defineStore('nostr', {
)
},
cancelFeed(feed) {
this.client.unsubscribe(feed.name)
cancelStream(subId) {
this.client.unsubscribe(subId)
},
fetchEvent(id) {
@ -215,7 +237,7 @@ export const useNostrStore = defineStore('nostr', {
},
{
subId: opts.subId || null,
cancelAfter: 'neven',
cancelAfter: 'never',
eoseCallback: () => {
if (!initialFetchComplete) {
initialFetchComplete = true

View File

@ -42,7 +42,7 @@ export default class extends Observable {
}
subscribe(subId, filters) {
// console.log(`Subscribing ${subId}`, filters)
console.log(`Subscribing ${subId}`, filters)
this.subs[subId] = filters
for (const relay of this.connectedRelays()) {
relay.subscribe(subId, filters)
@ -75,7 +75,7 @@ export default class extends Observable {
onOpen(relay) {
console.log(`Connected to ${relay}`, relay)
for (const subId of Object.keys(this.subs)) {
console.log(`Subscribing ${subId} with ${relay}`, this.subs[subId])
// console.log(`Subscribing ${subId} with ${relay}`, this.subs[subId])
relay.subscribe(subId, this.subs[subId])
}
this.emit('open', relay)

14
src/nostr/model/Thread.js Normal file
View File

@ -0,0 +1,14 @@
export default class Thread {
constructor(note) {
this.note = note
this.replies = []
}
id() {
return this.note.id
}
addReply(note) {
this.replies.push(note)
}
}

220
src/pages/Thread.vue Normal file
View File

@ -0,0 +1,220 @@
<template>
<q-page ref="page">
<PageHeader :title="$t('thread')" back-button />
<div ref="ancestors">
<Thread
:thread="ancestors"
force-bottom-connector
class="ancestors"
/>
</div>
<q-item ref="main" class="no-padding column">
<HeroPost
v-if="note?.id"
:note="note"
:connector="ancestors.length > 0"
/>
<div v-else style="padding-left: 1.5rem">
<q-spinner size="sm" style="margin-right: .5rem"/> Loading...
</div>
</q-item>
<div v-if="children.length">
<div v-for="thread in children" :key="thread[0].id">
<Thread :thread="thread" />
</div>
</div>
<div style="min-height: 80vh;" />
</q-page>
</template>
<script>
import {defineComponent} from 'vue'
import PageHeader from 'components/PageHeader.vue'
import Thread from 'components/Post/Thread.vue'
import HeroPost from 'components/Post/HeroPost.vue'
import {useNostrStore} from 'src/nostr/NostrStore'
import {NoteOrder} from 'src/nostr/store/NoteStore'
import {bech32ToHex} from 'src/utils/utils'
export default defineComponent({
name: 'ThreadPage',
components: {
HeroPost,
Thread,
PageHeader,
},
setup() {
return {
nostr: useNostrStore()
}
},
data() {
return {
predecessors: [],
children: [],
subId: null,
resizeObserver: null,
scrollTimeout: null,
}
},
computed: {
noteId() {
return bech32ToHex(this.$route.params.id)
},
note() {
if (!this.noteId) return
return this.nostr.getNote(this.noteId)
},
noteLoaded() {
return this.note?.id === this.noteId
},
rootId() {
if (!this.noteLoaded) return
return this.note.isReply()
? this.note.root()
: this.note.id
},
root() {
if (!this.rootId) return
return this.nostr.getNote(this.rootId)
},
rootLoaded() {
if (!this.noteLoaded) return
return this.root?.id === this.note.root()
},
ancestors() {
if (!this.rootLoaded) return []
return [this.root].concat(this.predecessors)
}
},
methods: {
startStream() {
if (!this.rootId) return
this.subId = this.nostr.streamThread(
this.rootId,
() => {}, // TODO
this.buildThread.bind(this)
)
},
cancelStream() {
if (!this.subId) return
this.nostr.cancelStream(this.subId)
this.subId = null
},
buildThread() {
if (!this.noteLoaded) return
const ancestors = this.allAncestors(this.note)
const ancestor = ancestors.length
? ancestors[ancestors.length - 1]
: null
// Sanity check
if (ancestors.length > 0 && ancestors[0].id !== this.rootId) {
console.error(`Invalid thread structure: expected root ${this.rootId} but found ${ancestors[0].id}`)
return
}
this.predecessors = this
.collectPredecessors(ancestors, this.note)
.slice(1)
this.scrollToMain()
this.children = this.collectChildren(this.note, ancestor)
},
collectPredecessors(ancestors, target) {
if (!ancestors || !ancestors.length) return []
const ancestor = ancestors.pop()
const replies = this.nostr.getRepliesTo(ancestor.id, NoteOrder.CREATION_DATE_ASC)
const targetIdx = replies.findIndex(reply => reply.id === target.id)
const predecessors = [ancestor].concat(replies.slice(0, targetIdx))
return this
.collectPredecessors(ancestors, ancestor)
.concat(predecessors)
},
collectChildren(target, ancestor) {
const children = []
// Get same-level successors
if (ancestor) {
const ancestorReplies = this.nostr.getRepliesTo(ancestor.id, NoteOrder.CREATION_DATE_ASC)
const targetIdx = ancestorReplies.findIndex(reply => reply.id === target.id)
const successors = ancestorReplies.slice(targetIdx + 1)
if (successors.length) {
children.push(successors)
}
}
// Get children of target
const targetReplies = this.nostr.getRepliesTo(target.id, NoteOrder.CREATION_DATE_ASC)
// FIXME Single element threads
for (const reply of targetReplies) {
children.push([reply])
}
return children
},
allAncestors(note) {
if (!note.isReply()) return []
const ancestor = this.nostr.getNote(note.ancestor())
if (!ancestor) {
console.error(`Couldn't fetch ancestor ${note.ancestor()}`)
return []
}
return this.allAncestors(ancestor).concat([ancestor])
},
scrollToMain() {
const el = this.$refs.main?.$el
if (!el) return
// TODO Clean up
const offset = this.$q.screen.xs ? 61 : 78
const position = Math.max(el.offsetTop - offset, 0)
if (this.scrollTimeout) {
clearTimeout(this.scrollTimeout)
}
this.scrollTimeout = setTimeout(() => window.scrollTo(0, position), 100)
},
},
watch: {
root() {
if (this.rootLoaded) {
this.startStream()
}
}
},
mounted() {
console.log('mounted', this.noteId)
this.startStream()
this.buildThread()
this.resizeObserver = new ResizeObserver(this.scrollToMain.bind(this))
this.resizeObserver.observe(this.$refs.ancestors)
setTimeout(() => this.resizeObserver.disconnect(), 2000)
},
unmounted() {
console.log('unmounted', this.subId)
this.cancelStream()
this.resizeObserver.disconnect()
}
})
</script>
<style lang="scss" scoped>
.ancestors {
border-bottom: 0;
}
</style>

View File

@ -10,8 +10,13 @@ export default {
}
})
},
linkToEvent(id) {
this.$router.push({name: 'event', params: {id}})
linkToThread(id) {
this.$router.push({
name: 'thread',
params: {
id: hexToBech32(id, 'note')
}
})
}
}
}

View File

@ -18,7 +18,11 @@ const routes = [
component: () => import('pages/Profile.vue'),
name: 'profile',
},
{
path: '/thread/:id(note[a-z0-9A-Z]{59})',
component: () => import('pages/Thread.vue'),
name: 'thread',
},
// {
// path: '/follow',
// component: () => import('pages/SearchFollow.vue'),

61
src/utils/DateUtils.js Normal file
View File

@ -0,0 +1,61 @@
import moment from 'moment/moment'
// TODO i18n
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
]
export default class DateUtils {
static formatDate(timestamp) {
const date = new Date(timestamp * 1000)
const month = MONTHS[date.getMonth()] // TODO i18n
const sameYear = date.getFullYear() === (new Date().getFullYear())
const year = !sameYear ? ' ' + date.getFullYear() : ''
return `${date.getDate()} ${month}${year}`
}
static formatTime(timestamp) {
const date = new Date(timestamp * 1000)
return `${date.getHours()}:${date.getMinutes()}`
}
static formatDateTime(timestamp) {
return `${DateUtils.formatDate(timestamp)}, ${DateUtils.formatTime(timestamp)}`
}
static formatFromNow(timestamp, format = 'long') {
return format === 'short'
? DateUtils.formatFromNowShort(timestamp)
: DateUtils.formatFromNowLong(timestamp)
}
static formatFromNowLong(timestamp) {
return moment(timestamp * 1000).fromNow()
}
static formatFromNowShort(timestamp) {
const now = Date.now()
const diff = Math.round(Math.max(now - (timestamp * 1000), 0) / 1000)
const formatDiff = (unit, factor, offset) => Math.max(Math.floor((diff + (unit * offset)) / (unit * factor)), 1)
if (diff < 45) return `${formatDiff(1, 1, 0)}s`
if (diff < 60 * 45) return `${formatDiff(1, 60, 15)}m`
if (diff < 60 * 60 * 22) return `${formatDiff(60, 60, 15)}h`
if (diff < 60 * 60 * 24 * 26) return `${formatDiff(60 * 60, 24, 2)}d`
if (diff < 60 * 60 * 24 * 30 * 320) return `${formatDiff(60 * 60 * 24, 30, 4)}mo`
return `${formatDiff(60 * 60 * 24, 30 * 365, 45)}y`
}
}

View File

@ -1949,6 +1949,11 @@ bech32-buffer@^0.2.1:
resolved "https://registry.yarnpkg.com/bech32-buffer/-/bech32-buffer-0.2.1.tgz#8106f2f51bcb2ba1d9fb7718905c3042c5be2fcd"
integrity sha512-fCG1TyZuCN48Sdw97p/IR39fvqpFlWDVpG7qnuU1Uc3+Xtc/0uqAp8U7bMW/bGuVF5CcNVIXwxQsWwUr6un6FQ==
bech32@^1.1.2:
version "1.1.4"
resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.4.tgz#e38c9f37bf179b8eb16ae3a772b40c356d4832e9"
integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==
big.js@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@ -1968,6 +1973,11 @@ bl@^4.0.3, bl@^4.1.0:
inherits "^2.0.4"
readable-stream "^3.4.0"
bn.js@^4.11.8:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
body-parser@1.20.0:
version "1.20.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5"
@ -2069,6 +2079,14 @@ buffer@^5.5.0:
base64-js "^1.3.1"
ieee754 "^1.1.13"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.2.1"
bytes@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
@ -2789,6 +2807,11 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
entities@~3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4"
integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==
error-ex@^1.3.1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@ -3597,7 +3620,7 @@ icss-utils@^5.0.0, icss-utils@^5.1.0:
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae"
integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==
ieee754@^1.1.13:
ieee754@^1.1.13, ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
@ -3931,6 +3954,15 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
light-bolt11-decoder@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/light-bolt11-decoder/-/light-bolt11-decoder-2.1.0.tgz#46b122790ae0eb415841227eba770eae7303ecf5"
integrity sha512-/AaSWTldx3aaFD7DgMVbX77MVEgLEPI0Zyx4Fjg23u3WpEoc536vz5LTXBU8oXAcrEcyDyn5GpBi2pEYuL351Q==
dependencies:
bech32 "^1.1.2"
bn.js "^4.11.8"
buffer "^6.0.3"
lilconfig@^2.0.3:
version "2.0.6"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"
@ -3941,6 +3973,13 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
linkify-it@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec"
integrity sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==
dependencies:
uc.micro "^1.0.1"
loader-runner@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1"
@ -4099,11 +4138,47 @@ make-dir@^3.0.2, make-dir@^3.1.0:
dependencies:
semver "^6.0.0"
markdown-it-deflist@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/markdown-it-deflist/-/markdown-it-deflist-2.1.0.tgz#50d7a56b9544cd81252f7623bd785e28a8dcef5c"
integrity sha512-3OuqoRUlSxJiuQYu0cWTLHNhhq2xtoSFqsZK8plANg91+RJQU1ziQ6lA2LzmFAEes18uPBsHZpcX6We5l76Nzg==
markdown-it-emoji@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-2.0.2.tgz#cd42421c2fda1537d9cc12b9923f5c8aeb9029c8"
integrity sha512-zLftSaNrKuYl0kR5zm4gxXjHaOI3FAOEaloKmRA5hijmJZvSjmxcokOLlzycb/HXlUFWzXqpIEoyEMCE4i9MvQ==
markdown-it-sub@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/markdown-it-sub/-/markdown-it-sub-1.0.0.tgz#375fd6026eae7ddcb012497f6411195ea1e3afe8"
integrity sha512-z2Rm/LzEE1wzwTSDrI+FlPEveAAbgdAdPhdWarq/ZGJrGW/uCQbKAnhoCsE4hAbc3SEym26+W2z/VQB0cQiA9Q==
markdown-it-sup@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/markdown-it-sup/-/markdown-it-sup-1.0.0.tgz#cb9c9ff91a5255ac08f3fd3d63286e15df0a1fc3"
integrity sha512-E32m0nV9iyhRR7CrhnzL5msqic7rL1juWre6TQNxsnApg7Uf+F97JOKxUijg5YwXz86lZ0mqfOnutoryyNdntQ==
markdown-it@^13.0.1:
version "13.0.1"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.1.tgz#c6ecc431cacf1a5da531423fc6a42807814af430"
integrity sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==
dependencies:
argparse "^2.0.1"
entities "~3.0.1"
linkify-it "^4.0.1"
mdurl "^1.0.1"
uc.micro "^1.0.5"
mdn-data@2.0.14:
version "2.0.14"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
mdurl@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@ -5698,6 +5773,11 @@ typescript@4.5.5:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3"
integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
uc.micro@^1.0.1, uc.micro@^1.0.5:
version "1.0.6"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
uglify-js@^3.5.1:
version "3.17.4"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c"