mirror of
https://github.com/styppo/hamstr.git
synced 2024-09-19 08:23:30 +00:00
Basic Thread page
This commit is contained in:
parent
61e78904d4
commit
9222f8fb24
@ -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",
|
||||
|
121
src/components/Post/BaseInvoice.vue
Normal file
121
src/components/Post/BaseInvoice.vue
Normal 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>
|
||||
|
392
src/components/Post/BaseMarkdown.vue
Normal file
392
src/components/Post/BaseMarkdown.vue
Normal 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>
|
@ -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>·</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>
|
||||
|
@ -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>
|
||||
|
@ -2,6 +2,5 @@
|
||||
// so you can safely delete all default props below
|
||||
|
||||
export default {
|
||||
failed: 'Action failed',
|
||||
success: 'Action was successful'
|
||||
'thread': 'Thread'
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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
14
src/nostr/model/Thread.js
Normal 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
220
src/pages/Thread.vue
Normal 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>
|
@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
61
src/utils/DateUtils.js
Normal 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`
|
||||
}
|
||||
}
|
82
yarn.lock
82
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user