mirror of
https://github.com/styppo/hamstr.git
synced 2024-10-18 05:23:28 +00:00
Basic thread component, thread styling
This commit is contained in:
parent
7c27ec8963
commit
d4460d2769
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<span
|
||||
class="cursor-pointer username"
|
||||
:class="'username' + (wrap ? ' two-line' : '')"
|
||||
@click.stop="toProfile(pubkey)"
|
||||
>
|
||||
<span
|
||||
@ -15,6 +15,7 @@
|
||||
</q-icon>
|
||||
</span>
|
||||
<span v-else class="text-italic">anonymous</span>
|
||||
|
||||
<span v-if="$store.getters.NIP05Id(pubkey)">
|
||||
<BaseButtonNIP05 :pubkey="pubkey" />
|
||||
<span style="opacity: .9; font-size: 90%; font-weight: 300; line-height: 90%">
|
||||
@ -34,10 +35,22 @@ export default {
|
||||
BaseButtonNIP05,
|
||||
},
|
||||
props: {
|
||||
pubkey: {type: String, required: true},
|
||||
wrap: {type: Boolean, default: false},
|
||||
showFollowing: {type: Boolean, default: false},
|
||||
showVerified: {type: Boolean, default: false},
|
||||
pubkey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
wrap: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showFollowing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showVerified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
niceNIP05() {
|
||||
@ -47,27 +60,22 @@ export default {
|
||||
},
|
||||
isFollow() {
|
||||
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>
|
||||
|
||||
<style lang="scss">
|
||||
.username {
|
||||
cursor: pointer;
|
||||
> span + span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
&.two-line {
|
||||
display: block;
|
||||
> span {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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>
|
||||
|
||||
<script>
|
||||
@ -12,15 +12,15 @@ export default {
|
||||
},
|
||||
minHeight: {
|
||||
type: Number,
|
||||
'default': null,
|
||||
default: null,
|
||||
},
|
||||
maxHeight: {
|
||||
type: Number,
|
||||
'default': null,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'What\'s happening',
|
||||
default: 'What\'s happening?',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
@ -41,8 +41,6 @@ export default {
|
||||
methods: {
|
||||
resize() {
|
||||
const textarea = this.$refs.textarea
|
||||
textarea.style.height = 'auto'
|
||||
|
||||
this.$nextTick(() => {
|
||||
let height = textarea.scrollHeight
|
||||
if (this.minHeight) {
|
||||
@ -66,7 +64,9 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.resize()
|
||||
if (this.text) {
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="post-editor">
|
||||
<div :class="rootCssClass">
|
||||
<div class="post-editor-avatar">
|
||||
<BaseUserAvatar :pubkey="$store.state.keys.pub" />
|
||||
</div>
|
||||
@ -8,7 +8,8 @@
|
||||
<AutoSizeTextarea
|
||||
v-model="post.text"
|
||||
ref="textarea"
|
||||
placeholder="What's happening?"
|
||||
:placeholder="placeholder"
|
||||
@focus.once="collapsed = false"
|
||||
/>
|
||||
<!-- <div-->
|
||||
<!-- v-if="tweetContent.imageList"-->
|
||||
@ -59,15 +60,13 @@
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
<div class="controls-submit">
|
||||
<button
|
||||
:disabled="!hasPostText()"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<button :disabled="!hasPostText()" @click="handleSubmit" class="btn">
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="collapsed" class="btn" disabled>Post</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -85,11 +84,30 @@ export default {
|
||||
BaseUserAvatar,
|
||||
EmojiPicker,
|
||||
},
|
||||
props: {
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'What\'s happening?',
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
post: {
|
||||
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: {
|
||||
@ -109,11 +127,27 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
@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 {
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-bottom: $border-dark;
|
||||
&-avatar {
|
||||
}
|
||||
&-content {
|
||||
@ -127,6 +161,7 @@ export default {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.3em;
|
||||
resize: vertical;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
@ -140,41 +175,6 @@ export default {
|
||||
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 {
|
||||
border-top: $border-dark;
|
||||
@ -200,22 +200,30 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
&-submit {
|
||||
button {
|
||||
cursor: pointer;
|
||||
background-color: $color-primary;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
padding: 10px 16px;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
&:disabled{
|
||||
cursor: no-drop;
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.3);
|
||||
color: rgba($color: #fff, $alpha: 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&-compact {
|
||||
.input-section {
|
||||
textarea {
|
||||
padding: 10px 0;
|
||||
resize: none;
|
||||
min-height: 48px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.controls {
|
||||
border-top: 0;
|
||||
padding: 0;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
&-collapsed {
|
||||
.controls {
|
||||
display: none;
|
||||
}
|
||||
> button {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@
|
||||
<Logo />
|
||||
</router-link>
|
||||
</div>
|
||||
<menu-item
|
||||
<MenuItem
|
||||
v-for="(route, i) in items"
|
||||
:key="i"
|
||||
:icon="route.name.toLowerCase()"
|
||||
@ -14,21 +14,21 @@
|
||||
:required="route.req"
|
||||
>
|
||||
{{ route.name }}
|
||||
</menu-item>
|
||||
<menu-item
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="profile"
|
||||
:to="`/profile/${me.id}`"
|
||||
required
|
||||
>
|
||||
Profile
|
||||
</menu-item>
|
||||
<menu-item
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="settings"
|
||||
to="/settings"
|
||||
required
|
||||
>
|
||||
Settings
|
||||
</menu-item>
|
||||
</MenuItem>
|
||||
|
||||
<!-- <menu-item-->
|
||||
<!-- icon="more"-->
|
||||
@ -45,13 +45,13 @@
|
||||
Post
|
||||
</div>
|
||||
</div>
|
||||
<profile-popup />
|
||||
<ProfilePopup />
|
||||
<div
|
||||
class="mobile-close-menu-button"
|
||||
@click="$store.commit('setMobileMenuState', false)"
|
||||
>
|
||||
<div class="icon">
|
||||
<base-icon icon="left" />
|
||||
<BaseIcon icon="left" />
|
||||
</div>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
|
@ -7,9 +7,7 @@
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<div class="menu-profile-pic">
|
||||
<img
|
||||
:src="me.profile.pic"
|
||||
>
|
||||
<BaseUserAvatar :pubkey="$store.state.keys.pub" />
|
||||
</div>
|
||||
<div class="menu-profile-items">
|
||||
<div class="profile-info">
|
||||
@ -65,10 +63,12 @@
|
||||
<script>
|
||||
// import { mapGetters } from 'vuex'
|
||||
import BaseIcon from '../BaseIcon/index'
|
||||
import BaseUserAvatar from 'components/BaseUserAvatar.vue'
|
||||
|
||||
export default {
|
||||
name: 'ProfilePopup',
|
||||
components: {
|
||||
BaseUserAvatar,
|
||||
BaseIcon
|
||||
},
|
||||
data: function() {
|
||||
@ -110,11 +110,12 @@ export default {
|
||||
.menu-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 4px 1rem;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
transition: 120ms ease-in-out;
|
||||
&-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
@ -122,8 +123,6 @@ export default {
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.3);
|
||||
}
|
||||
&-pic {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 6px 0;
|
||||
img {
|
||||
border-radius: 999px;
|
||||
@ -131,12 +130,12 @@ export default {
|
||||
}
|
||||
}
|
||||
&-items {
|
||||
margin-left: 10px;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.profile-info{
|
||||
.profile-info {
|
||||
user-select: none;
|
||||
p {
|
||||
margin: 0;
|
||||
|
293
src/components/Post/HeroPost.vue
Normal file
293
src/components/Post/HeroPost.vue
Normal 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>·</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>
|
@ -4,12 +4,18 @@
|
||||
@click.stop="toEvent(post.id)"
|
||||
>
|
||||
<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 class="post-content">
|
||||
<div class="post-content-header">
|
||||
<p>
|
||||
<base-user-name :pubkey="post.author" />
|
||||
<BaseUserName :pubkey="post.author" />
|
||||
<span>·</span>
|
||||
<span class="created-at">{{ moment(post.createdAt).fromNow() }}</span>
|
||||
</p>
|
||||
@ -19,42 +25,22 @@
|
||||
</div>
|
||||
<div class="post-content-body">
|
||||
<p>
|
||||
<base-markdown :content="post.content" />
|
||||
<BaseMarkdown :content="post.content" />
|
||||
</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 class="post-content-actions">
|
||||
<div class="action-item comment">
|
||||
<base-icon icon="comment" />
|
||||
<span>{{ post.stats.comments }}</span>
|
||||
<BaseIcon icon="comment" />
|
||||
<span>{{ numComments }}</span>
|
||||
</div>
|
||||
<div class="action-item repost">
|
||||
<base-icon icon="repost" />
|
||||
<BaseIcon icon="repost" />
|
||||
<span>{{ post.stats.reposts }}</span>
|
||||
</div>
|
||||
<div class="action-item like">
|
||||
<base-icon icon="like" />
|
||||
<BaseIcon icon="like" />
|
||||
<span>{{ post.stats.likes }}</span>
|
||||
</div>
|
||||
<div class="action-item comment">
|
||||
<base-icon icon="share" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -74,7 +60,7 @@ function countRepliesRecursive(event) {
|
||||
}
|
||||
let count = 0
|
||||
for (const thread of event.replies) {
|
||||
if (!thread || !Array.isArray(thread)) {
|
||||
if (!thread || !thread.length) {
|
||||
continue
|
||||
}
|
||||
count += thread.length
|
||||
@ -85,11 +71,7 @@ function countRepliesRecursive(event) {
|
||||
return count
|
||||
}
|
||||
|
||||
function postFromEvents(events) {
|
||||
const event = events[0]
|
||||
if (event.interpolated.replyEvents.length) {
|
||||
console.log(event.content, event.interpolated.replyEvents)
|
||||
}
|
||||
function postFromEvent(event) {
|
||||
return {
|
||||
id: event.id,
|
||||
author: event.pubkey,
|
||||
@ -98,15 +80,15 @@ function postFromEvents(events) {
|
||||
inReplyTo: event.interpolated.replyEvents[event.interpolated.replyEvents.length - 1],
|
||||
images: [],
|
||||
stats: {
|
||||
comments: countRepliesRecursive(event),
|
||||
reposts: 11,
|
||||
likes: 52,
|
||||
comments: '',
|
||||
reposts: '',
|
||||
likes: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Post',
|
||||
name: 'ListPost',
|
||||
mixins: [helpersMixin],
|
||||
components: {
|
||||
BaseMarkdown,
|
||||
@ -115,33 +97,41 @@ export default {
|
||||
BaseIcon,
|
||||
},
|
||||
props: {
|
||||
events: {
|
||||
type: Array,
|
||||
event: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
connectorTop: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
connectorBottom: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
post: postFromEvents(this.events),
|
||||
post: postFromEvent(this.event),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
imageUrls() {
|
||||
return this.post.images.map(image => image.url)
|
||||
}
|
||||
numComments() {
|
||||
return countRepliesRecursive(this.event)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
moment,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
@import 'assets/theme/colors.scss';
|
||||
@import 'assets/variables.scss';
|
||||
|
||||
.post {
|
||||
padding: 1rem;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
transition: 100ms ease background-color;
|
||||
cursor: pointer;
|
||||
@ -151,6 +141,22 @@ export default {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
|
||||
}
|
||||
&-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 {
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
@ -158,6 +164,7 @@ export default {
|
||||
}
|
||||
&-content {
|
||||
margin-left: 12px;
|
||||
padding: 1rem 0;
|
||||
flex-grow: 1;
|
||||
max-width: 570px;
|
||||
&-header {
|
46
src/components/Post/Thread.vue
Normal file
46
src/components/Post/Thread.vue
Normal 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>
|
@ -30,7 +30,7 @@
|
||||
:label="'load ' + unreadFeed[tab].length + ' unread'"
|
||||
@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
|
||||
:loading="loadingMore"
|
||||
: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 { createMetaMixin } from 'quasar'
|
||||
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 Thread from 'components/Post/Thread.vue'
|
||||
|
||||
|
||||
// const debouncedAddToThread = mergebounce(
|
||||
@ -80,8 +81,9 @@ export default defineComponent({
|
||||
mixins: [helpersMixin, createMetaMixin(metaData)],
|
||||
|
||||
components: {
|
||||
Thread,
|
||||
PageHeader,
|
||||
Post,
|
||||
//Post,
|
||||
PostEditor,
|
||||
BaseButtonLoadMore,
|
||||
},
|
||||
|
216
src/pages/Thread.vue
Normal file
216
src/pages/Thread.vue
Normal 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>
|
@ -33,6 +33,11 @@ const routes = [
|
||||
component: () => import('pages/Event.vue'),
|
||||
name: 'event',
|
||||
},
|
||||
{
|
||||
path: '/thread/:eventId([a-f0-9A-F]{64})',
|
||||
component: () => import('pages/Thread.vue'),
|
||||
name: 'thread',
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
component: () => import('pages/Notifications.vue'),
|
||||
|
@ -53,7 +53,7 @@ export default {
|
||||
},
|
||||
|
||||
toEvent(id) {
|
||||
if (!window.getSelection().toString().length) this.$router.push('/event/' + id)
|
||||
if (!window.getSelection().toString().length) this.$router.push('/thread/' + id)
|
||||
},
|
||||
|
||||
shorten,
|
||||
|
Loading…
Reference in New Issue
Block a user