Basic thread component, thread styling

This commit is contained in:
styppo 2022-12-27 21:31:08 +00:00
parent 7c27ec8963
commit d4460d2769
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
12 changed files with 734 additions and 150 deletions

View File

@ -1,6 +1,6 @@
<template> <template>
<span <span
class="cursor-pointer username" :class="'username' + (wrap ? ' two-line' : '')"
@click.stop="toProfile(pubkey)" @click.stop="toProfile(pubkey)"
> >
<span <span
@ -15,6 +15,7 @@
</q-icon> </q-icon>
</span> </span>
<span v-else class="text-italic">anonymous</span> <span v-else class="text-italic">anonymous</span>
<span v-if="$store.getters.NIP05Id(pubkey)"> <span v-if="$store.getters.NIP05Id(pubkey)">
<BaseButtonNIP05 :pubkey="pubkey" /> <BaseButtonNIP05 :pubkey="pubkey" />
<span style="opacity: .9; font-size: 90%; font-weight: 300; line-height: 90%"> <span style="opacity: .9; font-size: 90%; font-weight: 300; line-height: 90%">
@ -34,10 +35,22 @@ export default {
BaseButtonNIP05, BaseButtonNIP05,
}, },
props: { props: {
pubkey: {type: String, required: true}, pubkey: {
wrap: {type: Boolean, default: false}, type: String,
showFollowing: {type: Boolean, default: false}, required: true
showVerified: {type: Boolean, default: false}, },
wrap: {
type: Boolean,
default: false
},
showFollowing: {
type: Boolean,
default: false
},
showVerified: {
type: Boolean,
default: false
},
}, },
computed: { computed: {
niceNIP05() { niceNIP05() {
@ -47,27 +60,22 @@ export default {
}, },
isFollow() { isFollow() {
return this.$store.state.follows.includes(this.pubkey) return this.$store.state.follows.includes(this.pubkey)
} },
} }
// methods: {
// openNIP05() {
// let [name, domain] = this.$store.getters
// .displayName(this.pubkey)
// .split('@')
// if (!domain) {
// domain = name
// name = '_'
// }
// window.open(`https://${domain}/.well-known/nostr.json?name=${name}`)
// }
// }
} }
</script> </script>
<style lang="scss"> <style lang="scss">
.username { .username {
cursor: pointer;
> span + span { > span + span {
margin-left: 8px; margin-left: 8px;
} }
&.two-line {
display: block;
> span {
display: block;
}
}
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<textarea v-model="text" :placeholder="placeholder" @input="resize()" ref="textarea"></textarea> <textarea v-model="text" :placeholder="placeholder" @input="resize" @focus="resize" ref="textarea"></textarea>
</template> </template>
<script> <script>
@ -12,15 +12,15 @@ export default {
}, },
minHeight: { minHeight: {
type: Number, type: Number,
'default': null, default: null,
}, },
maxHeight: { maxHeight: {
type: Number, type: Number,
'default': null, default: null,
}, },
placeholder: { placeholder: {
type: String, type: String,
default: 'What\'s happening', default: 'What\'s happening?',
}, },
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
@ -41,8 +41,6 @@ export default {
methods: { methods: {
resize() { resize() {
const textarea = this.$refs.textarea const textarea = this.$refs.textarea
textarea.style.height = 'auto'
this.$nextTick(() => { this.$nextTick(() => {
let height = textarea.scrollHeight let height = textarea.scrollHeight
if (this.minHeight) { if (this.minHeight) {
@ -66,7 +64,9 @@ export default {
} }
}, },
mounted() { mounted() {
this.resize() if (this.text) {
this.resize()
}
} }
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="post-editor"> <div :class="rootCssClass">
<div class="post-editor-avatar"> <div class="post-editor-avatar">
<BaseUserAvatar :pubkey="$store.state.keys.pub" /> <BaseUserAvatar :pubkey="$store.state.keys.pub" />
</div> </div>
@ -8,7 +8,8 @@
<AutoSizeTextarea <AutoSizeTextarea
v-model="post.text" v-model="post.text"
ref="textarea" ref="textarea"
placeholder="What's happening?" :placeholder="placeholder"
@focus.once="collapsed = false"
/> />
<!-- <div--> <!-- <div-->
<!-- v-if="tweetContent.imageList"--> <!-- v-if="tweetContent.imageList"-->
@ -59,15 +60,13 @@
<!-- </div>--> <!-- </div>-->
</div> </div>
<div class="controls-submit"> <div class="controls-submit">
<button <button :disabled="!hasPostText()" @click="handleSubmit" class="btn">
:disabled="!hasPostText()"
@click="handleSubmit"
>
Post Post
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<button v-if="collapsed" class="btn" disabled>Post</button>
</div> </div>
</template> </template>
@ -85,11 +84,30 @@ export default {
BaseUserAvatar, BaseUserAvatar,
EmojiPicker, EmojiPicker,
}, },
props: {
placeholder: {
type: String,
default: 'What\'s happening?',
},
compact: {
type: Boolean,
default: false,
},
},
data() { data() {
return { return {
post: { post: {
text: '', text: '',
}, },
collapsed: this.compact,
}
},
computed: {
rootCssClass() {
let classes = ['post-editor']
if (this.compact) classes.push('post-editor-compact')
if (this.collapsed) classes.push('post-editor-compact-collapsed')
return classes
} }
}, },
methods: { methods: {
@ -109,11 +127,27 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import "assets/theme/colors.scss"; @import "assets/theme/colors.scss";
button.btn {
cursor: pointer;
background-color: $color-primary;
color: #fff;
font-weight: bold;
padding: 8px 16px;
outline: none;
border: none;
border-radius: 9999px;
height: fit-content;
&:disabled {
cursor: no-drop;
background-color: rgba($color: $color-primary, $alpha: 0.3);
color: rgba($color: #fff, $alpha: 0.3);
}
}
.post-editor { .post-editor {
padding: 0 1rem; padding: 0 1rem;
display: flex; display: flex;
width: 100%; width: 100%;
border-bottom: $border-dark;
&-avatar { &-avatar {
} }
&-content { &-content {
@ -127,6 +161,7 @@ export default {
display: block; display: block;
padding: 12px; padding: 12px;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1.3em;
resize: vertical; resize: vertical;
background-color: transparent; background-color: transparent;
border: none; border: none;
@ -140,41 +175,6 @@ export default {
outline: none; outline: none;
} }
} }
&-images {
display: flex;
padding: 1rem;
.image-container {
& + .image-container {
margin-left: 15px;
}
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex-grow: 1;
img {
width: 100%;
}
.close-button {
position: absolute;
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
top: 0;
right: 0;
cursor: pointer;
margin-top: 10px;
margin-right: 10px;
width: 2rem;
height: 2rem;
border-radius: 999px;
padding: 7px;
svg {
width: 100%;
height: 100%;
fill: #fff;
}
}
}
}
} }
.controls { .controls {
border-top: $border-dark; border-top: $border-dark;
@ -200,22 +200,30 @@ export default {
} }
} }
} }
&-submit { }
button { }
cursor: pointer; &-compact {
background-color: $color-primary; .input-section {
color: #fff; textarea {
font-weight: bold; padding: 10px 0;
padding: 10px 16px; resize: none;
outline: none; min-height: 48px;
border: none; height: 48px;
border-radius: 9999px; overflow: hidden;
&:disabled{ }
cursor: no-drop; }
background-color: rgba($color: $color-primary, $alpha: 0.3); .controls {
color: rgba($color: #fff, $alpha: 0.3); border-top: 0;
} padding: 0;
} margin-left: -4px;
}
&-collapsed {
.controls {
display: none;
}
> button {
margin: auto;
} }
} }
} }

View File

@ -6,7 +6,7 @@
<Logo /> <Logo />
</router-link> </router-link>
</div> </div>
<menu-item <MenuItem
v-for="(route, i) in items" v-for="(route, i) in items"
:key="i" :key="i"
:icon="route.name.toLowerCase()" :icon="route.name.toLowerCase()"
@ -14,21 +14,21 @@
:required="route.req" :required="route.req"
> >
{{ route.name }} {{ route.name }}
</menu-item> </MenuItem>
<menu-item <MenuItem
icon="profile" icon="profile"
:to="`/profile/${me.id}`" :to="`/profile/${me.id}`"
required required
> >
Profile Profile
</menu-item> </MenuItem>
<menu-item <MenuItem
icon="settings" icon="settings"
to="/settings" to="/settings"
required required
> >
Settings Settings
</menu-item> </MenuItem>
<!-- <menu-item--> <!-- <menu-item-->
<!-- icon="more"--> <!-- icon="more"-->
@ -45,13 +45,13 @@
Post Post
</div> </div>
</div> </div>
<profile-popup /> <ProfilePopup />
<div <div
class="mobile-close-menu-button" class="mobile-close-menu-button"
@click="$store.commit('setMobileMenuState', false)" @click="$store.commit('setMobileMenuState', false)"
> >
<div class="icon"> <div class="icon">
<base-icon icon="left" /> <BaseIcon icon="left" />
</div> </div>
<span>Close</span> <span>Close</span>
</div> </div>

View File

@ -7,9 +7,7 @@
@click="toggleMenu" @click="toggleMenu"
> >
<div class="menu-profile-pic"> <div class="menu-profile-pic">
<img <BaseUserAvatar :pubkey="$store.state.keys.pub" />
:src="me.profile.pic"
>
</div> </div>
<div class="menu-profile-items"> <div class="menu-profile-items">
<div class="profile-info"> <div class="profile-info">
@ -65,10 +63,12 @@
<script> <script>
// import { mapGetters } from 'vuex' // import { mapGetters } from 'vuex'
import BaseIcon from '../BaseIcon/index' import BaseIcon from '../BaseIcon/index'
import BaseUserAvatar from 'components/BaseUserAvatar.vue'
export default { export default {
name: 'ProfilePopup', name: 'ProfilePopup',
components: { components: {
BaseUserAvatar,
BaseIcon BaseIcon
}, },
data: function() { data: function() {
@ -110,11 +110,12 @@ export default {
.menu-profile { .menu-profile {
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%;
padding: 4px 1rem; padding: 4px 1rem;
margin-bottom: 10px; margin-bottom: 1rem;
margin-right: 1rem;
cursor: pointer; cursor: pointer;
border-radius: 999px; border-radius: 999px;
transition: 120ms ease-in-out;
&-wrapper { &-wrapper {
position: relative; position: relative;
} }
@ -122,8 +123,6 @@ export default {
background-color: rgba($color: $color-primary, $alpha: 0.3); background-color: rgba($color: $color-primary, $alpha: 0.3);
} }
&-pic { &-pic {
width: 3rem;
height: 3rem;
margin: 6px 0; margin: 6px 0;
img { img {
border-radius: 999px; border-radius: 999px;
@ -131,12 +130,12 @@ export default {
} }
} }
&-items { &-items {
margin-left: 10px; margin-left: 12px;
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.profile-info{ .profile-info {
user-select: none; user-select: none;
p { p {
margin: 0; margin: 0;

View File

@ -0,0 +1,293 @@
<template>
<div class="post">
<div class="post-author">
<div class="post-author-avatar">
<div class="connector-top">
<div v-if="connector" class="connector-line"></div>
</div>
<BaseUserAvatar :pubkey="post.author" />
</div>
<div class="post-author-name">
<BaseUserName :pubkey="post.author" wrap />
</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="toEvent(post.inReplyTo)">{{ shorten(post.inReplyTo) }}</a>
</p>
</div>
<div class="post-content-body">
<p>
<BaseMarkdown :content="post.content" />
</p>
</div>
<div class="post-content-footer">
<p class="created-at">
<span>{{ formatPostTime(post.createdAt) }}</span>
<span>&#183;</span>
<span>{{ formatPostDate(post.createdAt) }}</span>
</p>
<div class="post-content-actions">
<div class="action-item comment">
<BaseIcon icon="comment" />
<span>{{ numComments }}</span>
</div>
<div class="action-item repost">
<BaseIcon icon="repost" />
<span>{{ post.stats.reposts }}</span>
</div>
<div class="action-item like">
<BaseIcon icon="like" />
<span>{{ post.stats.likes }}</span>
</div>
</div>
</div>
</div>
<div class="post-reply">
<PostEditor
compact
placeholder="Post your reply"
/>
</div>
</div>
</template>
<script>
import BaseIcon from 'components/BaseIcon'
import BaseUserAvatar from 'components/BaseUserAvatar.vue'
import BaseUserName from 'components/BaseUserName.vue'
import BaseMarkdown from 'components/BaseMarkdown.vue'
import helpersMixin from 'src/utils/mixin'
import PostEditor from 'components/CreatePost/PostEditor.vue'
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: '',
}
}
}
export default {
name: 'HeroPost',
mixins: [helpersMixin],
components: {
BaseMarkdown,
BaseUserName,
BaseUserAvatar,
BaseIcon,
PostEditor,
},
props: {
event: {
type: Object,
required: true
},
connector: {
type: Boolean,
default: false,
},
},
data() {
return {
post: postFromEvent(this.event),
}
},
computed: {
numComments() {
return countRepliesRecursive(this.event)
},
},
methods: {
formatPostDate(ts) {
const date = new Date(ts)
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(ts) {
const date = new Date(ts)
return `${date.getHours()}:${date.getMinutes()}`
}
}
}
</script>
<style lang="scss" scoped>
@import 'assets/theme/colors.scss';
@import 'assets/variables.scss';
.post {
border-bottom: $border-dark;
padding-bottom: 1rem;
&-author {
display: flex;
flex-direction: row;
padding: 0 1rem;
&-name {
margin-top: 1rem;
margin-left: 12px;
}
.connector-top {
height: 1rem;
padding-bottom: 4px;
}
.connector-line {
width: 2px;
height: 100%;
margin: auto;
background: rgb(56, 68, 77);
}
}
&-content {
padding: 1rem;
flex-grow: 1;
max-width: 644px;
&-header {
p.in-reply-to {
color: $color-dark-gray;
margin: 0 0 8px;
a {
color: $color-primary;
&:hover {
text-decoration: underline;
}
}
}
}
&-body {
p {
color: #fff;
font-size: 1.6em;
line-height: 1.3em;
}
p:last-child {
margin-bottom: 0;
}
}
&-footer {
color: $color-dark-gray;
border-bottom: $border-dark;
p.created-at {
margin: 0;
padding: 1rem 0;
border-bottom: $border-dark;
span + span {
margin-left: 8px;
}
}
}
&-actions {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 450px;
width: 100%;
padding: .5rem 0;
margin: auto;
.action-item {
display: flex;
align-items: center;
cursor: pointer;
svg {
padding: 8px;
border-radius: 999px;
display: block;
width: 40px;
height: 40px;
fill: $color-light-gray;
}
span {
color: $color-light-gray;
line-height: 40px;
font-weight: bold;
}
&:hover {
&.comment {
svg {
fill: $post-action-blue;
background-color: rgba($color: $post-action-blue, $alpha: 0.2);
}
span {
color: $post-action-blue;
}
}
&.repost {
svg {
fill: $post-action-green;
background-color: rgba($color: $post-action-green, $alpha: 0.2);
}
span {
color: $post-action-green;
}
}
&.like {
svg {
fill: $post-action-red;
background-color: rgba($color: $post-action-red, $alpha: 0.2);
}
span {
color: $post-action-red;
}
}
}
}
}
}
}
@media screen and (max-width: $phone) {
.post{
&-content {
&-header {
span{
display: none;
}
.created-at {
display: block;
color: rgba($color: $color-dark-gray, $alpha: 0.5);
margin: 5px 0;
}
.nip05 {
display: unset;
color: $color-dark-gray;
}
}
&-actions {
max-width: unset;
}
}
}
}
</style>

View File

@ -4,12 +4,18 @@
@click.stop="toEvent(post.id)" @click.stop="toEvent(post.id)"
> >
<div class="post-author"> <div class="post-author">
<base-user-avatar :pubkey="post.author" /> <div class="connector-top">
<div v-if="connectorTop" class="connector-line"></div>
</div>
<BaseUserAvatar :pubkey="post.author" />
<div class="connector-bottom">
<div v-if="connectorBottom" class="connector-line"></div>
</div>
</div> </div>
<div class="post-content"> <div class="post-content">
<div class="post-content-header"> <div class="post-content-header">
<p> <p>
<base-user-name :pubkey="post.author" /> <BaseUserName :pubkey="post.author" />
<span>&#183;</span> <span>&#183;</span>
<span class="created-at">{{ moment(post.createdAt).fromNow() }}</span> <span class="created-at">{{ moment(post.createdAt).fromNow() }}</span>
</p> </p>
@ -19,42 +25,22 @@
</div> </div>
<div class="post-content-body"> <div class="post-content-body">
<p> <p>
<base-markdown :content="post.content" /> <BaseMarkdown :content="post.content" />
</p> </p>
<div
v-if="post.images.length > 0"
class="post-content-body-images"
>
<div class="post-content-body-images-wrapper">
<div
v-for="(image, i) in post.images"
:key="i"
class="post-content-image-item"
>
<img
:src="image.url"
@click="$store.dispatch('setLightbox', imageUrls)"
>
</div>
</div>
</div>
</div> </div>
<div class="post-content-actions"> <div class="post-content-actions">
<div class="action-item comment"> <div class="action-item comment">
<base-icon icon="comment" /> <BaseIcon icon="comment" />
<span>{{ post.stats.comments }}</span> <span>{{ numComments }}</span>
</div> </div>
<div class="action-item repost"> <div class="action-item repost">
<base-icon icon="repost" /> <BaseIcon icon="repost" />
<span>{{ post.stats.reposts }}</span> <span>{{ post.stats.reposts }}</span>
</div> </div>
<div class="action-item like"> <div class="action-item like">
<base-icon icon="like" /> <BaseIcon icon="like" />
<span>{{ post.stats.likes }}</span> <span>{{ post.stats.likes }}</span>
</div> </div>
<div class="action-item comment">
<base-icon icon="share" />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -74,7 +60,7 @@ function countRepliesRecursive(event) {
} }
let count = 0 let count = 0
for (const thread of event.replies) { for (const thread of event.replies) {
if (!thread || !Array.isArray(thread)) { if (!thread || !thread.length) {
continue continue
} }
count += thread.length count += thread.length
@ -85,11 +71,7 @@ function countRepliesRecursive(event) {
return count return count
} }
function postFromEvents(events) { function postFromEvent(event) {
const event = events[0]
if (event.interpolated.replyEvents.length) {
console.log(event.content, event.interpolated.replyEvents)
}
return { return {
id: event.id, id: event.id,
author: event.pubkey, author: event.pubkey,
@ -98,15 +80,15 @@ function postFromEvents(events) {
inReplyTo: event.interpolated.replyEvents[event.interpolated.replyEvents.length - 1], inReplyTo: event.interpolated.replyEvents[event.interpolated.replyEvents.length - 1],
images: [], images: [],
stats: { stats: {
comments: countRepliesRecursive(event), comments: '',
reposts: 11, reposts: '',
likes: 52, likes: '',
} }
} }
} }
export default { export default {
name: 'Post', name: 'ListPost',
mixins: [helpersMixin], mixins: [helpersMixin],
components: { components: {
BaseMarkdown, BaseMarkdown,
@ -115,33 +97,41 @@ export default {
BaseIcon, BaseIcon,
}, },
props: { props: {
events: { event: {
type: Array, type: Object,
required: true required: true
}, },
connectorTop: {
type: Boolean,
default: false,
},
connectorBottom: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
post: postFromEvents(this.events), post: postFromEvent(this.event),
} }
}, },
computed: { computed: {
imageUrls() { numComments() {
return this.post.images.map(image => image.url) return countRepliesRecursive(this.event)
} },
}, },
methods: { methods: {
moment, moment,
} },
} }
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
@import 'assets/theme/colors.scss'; @import 'assets/theme/colors.scss';
@import 'assets/variables.scss'; @import 'assets/variables.scss';
.post { .post {
padding: 1rem; padding: 0 1rem;
display: flex; display: flex;
transition: 100ms ease background-color; transition: 100ms ease background-color;
cursor: pointer; cursor: pointer;
@ -151,6 +141,22 @@ export default {
background-color: rgba($color: $color-dark-gray, $alpha: 0.2); background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
} }
&-author { &-author {
display: flex;
flex-direction: column;
.connector-top {
height: 1rem;
padding-bottom: 4px;
}
.connector-bottom {
flex-grow: 1;
padding-top: 4px;
}
.connector-line {
width: 2px;
height: 100%;
margin: auto;
background: rgb(56, 68, 77);
}
img { img {
width: 100%; width: 100%;
border-radius: 999px; border-radius: 999px;
@ -158,6 +164,7 @@ export default {
} }
&-content { &-content {
margin-left: 12px; margin-left: 12px;
padding: 1rem 0;
flex-grow: 1; flex-grow: 1;
max-width: 570px; max-width: 570px;
&-header { &-header {

View File

@ -0,0 +1,46 @@
<template>
<div class="thread">
<ListPost
v-for="(event, index) in events"
:key="event.id"
:event="event"
:connector-top="events.length > 1 && index > 0"
:connector-bottom="(events.length > 1 && index < events.length - 1) || forceBottomConnector"
/>
</div>
</template>
<script>
import ListPost from 'components/Post/ListPost.vue'
export default {
name: 'Thread',
components: {
ListPost
},
props: {
events: {
type: Array,
required: true,
},
forceBottomConnector: {
type: Boolean,
default: false,
},
},
mounted() {
console.log(`Thread (${this.events.length}) ${this.events[0].content.substr(0, 100)}`, this.events)
}
}
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
.thread {
border-bottom: $border-dark;
.post {
border-bottom: 0;
}
}
</style>

View File

@ -30,7 +30,7 @@
:label="'load ' + unreadFeed[tab].length + ' unread'" :label="'load ' + unreadFeed[tab].length + ' unread'"
@click="loadUnread" @click="loadUnread"
/> />
<Post v-for="item in items" :key="item[0].id" :events="item" class="full-width" @add-event="processEvent"/> <Thread v-for="item in items" :key="item[0].id" :events="item" class="full-width" @add-event="processEvent" />
<BaseButtonLoadMore <BaseButtonLoadMore
:loading="loadingMore" :loading="loadingMore"
:label="items.length === feed[tab].length ? 'load another day' : 'load 100 more'" :label="items.length === feed[tab].length ? 'load another day' : 'load 100 more'"
@ -49,8 +49,9 @@ import {dbFeed, dbUserFollows} from '../query'
import BaseButtonLoadMore from 'components/BaseButtonLoadMore.vue' import BaseButtonLoadMore from 'components/BaseButtonLoadMore.vue'
import { createMetaMixin } from 'quasar' import { createMetaMixin } from 'quasar'
import PageHeader from 'components/PageHeader.vue' import PageHeader from 'components/PageHeader.vue'
import Post from 'components/Post/Post.vue' //import Post from 'components/Post/ListPost.vue'
import PostEditor from 'components/CreatePost/PostEditor.vue' import PostEditor from 'components/CreatePost/PostEditor.vue'
import Thread from 'components/Post/Thread.vue'
// const debouncedAddToThread = mergebounce( // const debouncedAddToThread = mergebounce(
@ -80,8 +81,9 @@ export default defineComponent({
mixins: [helpersMixin, createMetaMixin(metaData)], mixins: [helpersMixin, createMetaMixin(metaData)],
components: { components: {
Thread,
PageHeader, PageHeader,
Post, //Post,
PostEditor, PostEditor,
BaseButtonLoadMore, BaseButtonLoadMore,
}, },

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

@ -0,0 +1,216 @@
<template>
<q-page ref="page">
<PageHeader :title="$t('thread')" back-button />
<div
ref="ancestors"
v-if="ancestorsCompiled.length || rootAncestor"
>
<Thread
:events="ancestorsCompiled"
force-bottom-connector
@add-event="addEventAncestors"
class="ancestors"
/>
</div>
<q-item ref="main" class='no-padding column'>
<HeroPost
v-if="event"
:event="event"
:highlighted="true"
:position="ancestors.length ? 'last' : 'standalone'"
connector
@add-event="processChildEvent"
/>
<div v-else>
{{ $t('event') }} {{ $route.params.eventId }}
</div>
<!-- <BaseRelayList v-if="event?.seen_on?.length" :event='event' class='q-px-sm'/>-->
</q-item>
<div v-if="childrenThreadsFiltered.length">
<div v-for="(thread) in childrenThreadsFiltered" :key="thread[0].id">
<Thread :events="thread" @add-event='processChildEvent'/>
</div>
</div>
<div style='min-height: 30vh;'/>
</q-page>
</template>
<script>
import { defineComponent, nextTick } from 'vue'
import {dbStreamEvent, dbStreamTagKind} from '../query'
import helpersMixin from '../utils/mixin'
import {addToThread} from '../utils/threads'
//import BaseRelayList from 'components/BaseRelayList.vue'
import PageHeader from 'components/PageHeader.vue'
import { createMetaMixin } from 'quasar'
import Thread from 'components/Post/Thread.vue'
import HeroPost from 'components/Post/HeroPost.vue'
const metaData = {
// sets document title
title: 'hamstr - thread',
// meta tags
meta: {
description: { name: 'description', content: 'Nostr event thread' },
keywords: { name: 'keywords', content: 'nostr decentralized social media' },
equiv: { 'http-equiv': 'Content-Type', content: 'text/html; charset=UTF-8' },
},
}
export default defineComponent({
name: 'Event',
emits: ['scroll-to-rect'],
mixins: [helpersMixin, createMetaMixin(metaData)],
components: {
HeroPost,
Thread,
PageHeader,
//BaseRelayList
},
data() {
return {
replying: false,
ancestors: [],
ancestorsSeen: new Map(),
ancestorIds: [],
rootAncestor: null,
event: null,
childrenThreads: [],
childrenSet: new Set(),
sub: {},
profilesUsed: new Set(),
}
},
computed: {
childrenThreadsFiltered() {
return this.childrenThreads.filter(thread => thread[0].interpolated.replyEvents.includes(this.$route.params.eventId))
},
ancestorsCompiled() {
if (!this.rootAncestor) return this.ancestors
if (this.ancestors.length && this.rootAncestor && this.ancestors[0].id === this.rootAncestor.id) return this.ancestors
return [this.rootAncestor].concat(this.ancestors)
}
},
mounted() {
this.start()
},
beforeUnmount() {
this.stop()
},
methods: {
async start() {
this.sub.event = await dbStreamEvent(this.$route.params.eventId, event => {
let getAncestorsChildren = false
if (!this.event) getAncestorsChildren = true
this.interpolateEventMentions(event)
this.event = null
this.event = event
if (getAncestorsChildren) {
if (this.event.interpolated.replyEvents.length) this.subRootAncestor()
this.subAncestorsChildren()
}
this.useProfile(event.pubkey)
}, true)
this.subAncestorsChildren()
},
stop() {
this.replying = false
if (this.sub.event) this.sub.event.cancel()
if (this.sub.ancestorsChildren) this.sub.ancestorsChildren.cancel()
if (this.sub.rootAncestor) this.sub.rootAncestor.cancel()
this.profilesUsed.forEach(pubkey => this.$store.dispatch('cancelUseProfile', {pubkey}))
},
async subRootAncestor() {
this.sub.rootAncestor = await dbStreamEvent(this.event.interpolated.replyEvents[0], event => {
this.processAncestorEvent(event)
this.sub.rootAncestor.cancel()
})
},
async subAncestorsChildren() {
let tags = this.event?.interpolated?.replyEvents?.length ? [this.$route.params.eventId, this.event.interpolated.replyEvents[0]] : [this.$route.params.eventId]
if (this.sub.ancestorsChildren) this.sub.ancestorsChildren.update('e', tags, 1)
else this.sub.ancestorsChildren = await dbStreamTagKind('e', tags, 1, event => {
if (this.event && event.created_at < this.event.created_at) {
this.processAncestorEvent(event)
return
}
this.processChildEvent(event)
return
})
},
processAncestorEvent(event) {
let currAncestor = this.ancestors.length ? this.ancestors[this.ancestors.length - 1] : this.event
if (currAncestor.interpolated.replyEvents.length === 0) return
let existing = this.ancestorsSeen.get(event.id)
if (existing) return
this.interpolateEventMentions(event)
this.ancestorsSeen.set(event.id, event)
if (this.event?.interpolated?.replyEvents?.[0] === event.id) this.rootAncestor = event
let prevAncestorId = currAncestor.interpolated.replyEvents[currAncestor.interpolated.replyEvents.length - 1]
if (prevAncestorId === event.id) {
let prevAncestor = event
while (prevAncestor) {
this.ancestors = [prevAncestor].concat(this.ancestors)
this.scrollToMainEvent()
this.useProfile(prevAncestor.pubkey)
currAncestor = prevAncestor
prevAncestorId = currAncestor.interpolated.replyEvents[currAncestor.interpolated.replyEvents.length - 1]
prevAncestor = this.ancestorsSeen.get(prevAncestorId)
}
}
},
processChildEvent(event) {
if (event.id === this.$route.params.eventId) return
if (this.childrenSet.has(event.id)) return
this.childrenSet.add(event.id)
this.useProfile(event.pubkey)
this.interpolateEventMentions(event)
addToThread(this.childrenThreads, event, '', event.pubkey !== this.$store.state.keys.pub)
},
scrollToMainEvent() {
nextTick(() => {
let mainRect = this.$refs.main?.$el.getBoundingClientRect()
this.$emit('scroll-to-rect', mainRect)
})
},
addEventAncestors(event) {
this.interpolateEventMentions(event)
this.toEvent(event.id)
},
useProfile(pubkey) {
if (this.profilesUsed.has(pubkey)) return
this.profilesUsed.add(pubkey)
this.$store.dispatch('useProfile', {pubkey})
},
}
})
</script>
<style lang="scss" scoped>
.ancestors {
border-bottom: 0;
}
</style>

View File

@ -33,6 +33,11 @@ const routes = [
component: () => import('pages/Event.vue'), component: () => import('pages/Event.vue'),
name: 'event', name: 'event',
}, },
{
path: '/thread/:eventId([a-f0-9A-F]{64})',
component: () => import('pages/Thread.vue'),
name: 'thread',
},
{ {
path: '/notifications', path: '/notifications',
component: () => import('pages/Notifications.vue'), component: () => import('pages/Notifications.vue'),

View File

@ -53,7 +53,7 @@ export default {
}, },
toEvent(id) { toEvent(id) {
if (!window.getSelection().toString().length) this.$router.push('/event/' + id) if (!window.getSelection().toString().length) this.$router.push('/thread/' + id)
}, },
shorten, shorten,