Create posts & replies

This commit is contained in:
styppo 2022-12-30 21:31:59 +00:00
parent ca6cfa6e82
commit 6273251130
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
8 changed files with 291 additions and 94 deletions

View File

@ -1,5 +1,12 @@
<template> <template>
<textarea v-model="text" :placeholder="placeholder" @input="resize" @focus="resize" ref="textarea"></textarea> <textarea
v-model="text"
:placeholder="placeholder"
:disabled="disabled"
@input="resize"
@focus="resize"
ref="textarea"
></textarea>
</template> </template>
<script> <script>
@ -22,6 +29,10 @@ export default {
type: String, type: String,
default: 'What\'s happening?', default: 'What\'s happening?',
}, },
disabled: {
type: Boolean,
default: false,
}
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
data() { data() {
@ -42,7 +53,9 @@ export default {
resize() { resize() {
const textarea = this.$refs.textarea const textarea = this.$refs.textarea
this.$nextTick(() => { this.$nextTick(() => {
textarea.style.height = 'inherit'
let height = textarea.scrollHeight let height = textarea.scrollHeight
if (this.minHeight) { if (this.minHeight) {
height = Math.max(height, this.minHeight) height = Math.max(height, this.minHeight)
} }
@ -61,7 +74,11 @@ export default {
const caretPos = textarea.selectionStart + text.length const caretPos = textarea.selectionStart + text.length
textarea.setSelectionRange(caretPos, caretPos) textarea.setSelectionRange(caretPos, caretPos)
} }
} this.$emit('update:modelValue', textarea.value)
},
focus() {
this.$refs.textarea.focus()
},
}, },
mounted() { mounted() {
if (this.text) { if (this.text) {

View File

@ -1,20 +1,124 @@
<template> <template>
<q-dialog v-model="$store.state.postDialogOpen"> <q-dialog
<PostEditor /> v-model="$store.state.postDialogOpen"
@before-show="updateReplyToEvent"
@show="$refs.postEditor.focus()"
@hide="onClose"
>
<div class="create-post-dialog">
<q-btn v-close-popup icon="close" size="md" flat round class="icon" />
<ListPost
v-if="replyToEvent"
:event="replyToEvent"
connector-bottom
/>
<PostEditor
ref="postEditor"
class="post-editor"
:class="{standalone: !replyToEvent}"
:placeholder="placeholderText"
:connector="!!replyToEvent"
:reply-to="replyTo"
@publish="onClose"
compact
expanded
/>
</div>
</q-dialog> </q-dialog>
</template> </template>
<script> <script>
import ListPost from 'components/Post/ListPost.vue'
import PostEditor from 'components/CreatePost/PostEditor.vue' import PostEditor from 'components/CreatePost/PostEditor.vue'
import {dbStreamEvent} from 'src/query'
import helpers from 'src/utils/mixin'
export default { export default {
name: 'CreatePostDialog', name: 'CreatePostDialog',
mixins: [helpers],
components: { components: {
ListPost,
PostEditor PostEditor
},
data() {
return {
replyToEvent: null,
}
},
computed: {
paramsReplyTo() {
return this.$store.state.postDialogParams?.replyTo || []
},
replyTo() {
const replyTo = this.paramsReplyTo.map(id => ({id}))
if (replyTo.length && this.replyToEvent && this.replyToEvent.id === replyTo[replyTo.length - 1].id) {
replyTo[replyTo.length - 1].pubkey = this.replyToEvent.pubkey
}
return replyTo
},
placeholderText() {
return this.replyToEvent
? 'Post your reply'
: 'What\'s happening?'
},
},
methods: {
fetchEvent(id) {
return new Promise((resolve, reject) => {
return dbStreamEvent(id, event => {
this.interpolateEventMentions(event)
this.$store.dispatch('useProfile', {pubkey: event.pubkey})
resolve(event)
}).catch(reject)
})
},
async updateReplyToEvent() {
if (this.paramsReplyTo.length > 0) {
const ancestor = this.paramsReplyTo[this.paramsReplyTo.length - 1]
if (!this.replyToEvent || ancestor.id !== this.replyToEvent.id) {
this.replyToEvent = await this.fetchEvent(ancestor)
}
} else {
this.replyToEvent = null
}
},
onClose() {
this.$refs.postEditor.reset()
this.$store.commit('dismissPostDialog')
}
},
async mounted() {
await this.updateReplyToEvent()
} }
} }
</script> </script>
<style scoped> <style lang="scss" scoped>
@import "assets/theme/colors.scss";
.create-post-dialog {
position: relative;
background-color: $color-bg;
padding: 3rem 1rem 1rem;
min-width: 440px;
.icon {
position: absolute;
width: 16px;
height: 16px;
top: .5rem;
left: .5rem;
fill: #fff;
}
.post {
padding: 0;
border-bottom: 0;
}
.post-editor {
padding: 0;
&.standalone {
margin-top: 1rem;
}
}
}
</style> </style>

View File

@ -1,40 +1,23 @@
<template> <template>
<div <div
class="post-editor" class="post-editor"
:class="{ :class="{compact, collapsed, connector}"
'post-editor-compact': compact,
'post-editor-compact-collapsed': collapsed
}"
> >
<div class="post-editor-avatar"> <div class="post-editor-author">
<BaseUserAvatar :pubkey="$store.state.keys.pub" /> <div v-if="connector" class="connector-top">
<div class="connector-line"></div>
</div>
<BaseUserAvatar :pubkey="$store.getters.myPubkey" />
</div> </div>
<div class="post-editor-content"> <div class="post-editor-content">
<div class="input-section"> <div class="input-section">
<AutoSizeTextarea <AutoSizeTextarea
v-model="post.text" v-model="content"
ref="textarea" ref="textarea"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="publishing"
@focus.once="collapsed = false" @focus.once="collapsed = false"
/> />
<!-- <div-->
<!-- v-if="tweetContent.imageList"-->
<!-- class="tweet-section-images"-->
<!-- >-->
<!-- <div-->
<!-- v-for="(image, i) in tweetContent.imageList"-->
<!-- :key="i"-->
<!-- class="image-container"-->
<!-- >-->
<!-- <img :src="image.url">-->
<!-- <div-->
<!-- class="close-button"-->
<!-- @click="deleteImage(i)"-->
<!-- >-->
<!-- <base-icon icon="close" />-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
</div> </div>
<div class="controls"> <div class="controls">
<div class="controls-media"> <div class="controls-media">
@ -44,35 +27,21 @@
<EmojiPicker @select="onEmojiSelected"/> <EmojiPicker @select="onEmojiSelected"/>
</q-menu> </q-menu>
</div> </div>
<div class="controls-media-item disabled">
<!-- <div--> <BaseIcon icon="image" />
<!-- class="controls-media-item"--> </div>
<!-- @click="$refs.uploadImageInput.click()"-->
<!-- >-->
<!-- <BaseIcon icon="image" />-->
<!-- <input-->
<!-- ref="uploadImageInput"-->
<!-- type="file"-->
<!-- accept="image/*"-->
<!-- hidden-->
<!-- @change="showFiles"-->
<!-- >-->
<!-- </div>-->
<!-- <div class="controls-media-item">-->
<!-- <BaseIcon icon="gif" />-->
<!-- </div>-->
<!-- <div class="controls-media-item">-->
<!-- <BaseIcon icon="graph" />-->
<!-- </div>-->
</div> </div>
<div class="controls-submit"> <div class="controls-submit">
<button :disabled="!hasPostText()" @click="handleSubmit" class="btn"> <button :disabled="!hasContent() || publishing" @click="publishPost" class="btn">
Post <q-spinner v-if="publishing" />
<span v-else>Post</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<button v-if="collapsed" class="btn" disabled>Post</button> <div class="post-editor-fake-submit" v-if="collapsed">
<button class="btn" disabled>Post</button>
</div>
</div> </div>
</template> </template>
@ -91,6 +60,10 @@ export default {
EmojiPicker, EmojiPicker,
}, },
props: { props: {
replyTo: {
type: Array, // [{id: <eventId>, pubkey: <authorPubkey>},...]
default: () => [],
},
placeholder: { placeholder: {
type: String, type: String,
default: 'What\'s happening?', default: 'What\'s happening?',
@ -99,25 +72,74 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
connector: {
type: Boolean,
default: false,
},
expanded: {
type: Boolean,
default: false,
}
}, },
emits: ['publish'],
data() { data() {
return { return {
post: { content: '',
text: '', collapsed: this.compact && !this.expanded,
}, publishing: false,
collapsed: this.compact,
} }
}, },
methods: { methods: {
hasPostText() { hasContent() {
return this.post.text.trim().length > 0 return this.content.trim().length > 0
},
handleSubmit() {
}, },
onEmojiSelected(emoji) { onEmojiSelected(emoji) {
this.$refs.menuEmojiPicker.hide() this.$refs.menuEmojiPicker.hide()
this.$refs.textarea.insertText(emoji.native) this.$refs.textarea.insertText(emoji.native)
}, },
focus() {
this.$refs.textarea.focus()
},
reset() {
this.content = ''
},
async publishPost() {
this.publishing = true
const post = {
message: this.content,
tags: this.buildTags(),
}
try {
const event = await this.$store.dispatch('sendPost', post)
this.reset()
this.$emit('publish', event)
// TODO i18n
const postType = this.replyTo.length ? 'Reply' : 'Post'
this.$q.notify({
message: `${postType} published`,
color: 'positive',
})
} catch (e) {
this.$q.notify({
message: `Failed to publish post`,
color: 'negative'
})
}
this.publishing = false
},
buildTags() {
const e = []
const p = []
for (const {id, pubkey} of this.replyTo) {
e.push(['e', id])
if (pubkey) {
p.push(['p', pubkey])
}
}
return e.concat(p)
}
} }
} }
</script> </script>
@ -146,7 +168,19 @@ button.btn {
padding: 0 1rem; padding: 0 1rem;
display: flex; display: flex;
width: 100%; width: 100%;
&-avatar { &-author {
display: flex;
flex-direction: column;
.connector-top {
height: 1rem;
padding-bottom: 4px;
}
.connector-line {
width: 2px;
height: 100%;
margin: auto;
background: rgb(56, 68, 77);
}
} }
&-content { &-content {
margin-left: 12px; margin-left: 12px;
@ -196,16 +230,27 @@ button.btn {
&:hover { &:hover {
background-color: rgba($color: $color-primary, $alpha: 0.3); background-color: rgba($color: $color-primary, $alpha: 0.3);
} }
&.disabled {
cursor: default;
svg {
fill: rgba($color: $color-primary, $alpha: 0.5);
}
&:hover {
background-color: transparent;
}
}
} }
} }
} }
} }
&-compact { &-fake-submit {
height: fit-content;
margin: auto;
}
&.compact {
.input-section { .input-section {
textarea { textarea {
padding: 10px 0; padding: 10px 0;
min-height: 48px;
height: 48px;
overflow: hidden; overflow: hidden;
} }
} }
@ -215,13 +260,22 @@ button.btn {
margin-left: -4px; margin-left: -4px;
} }
&-collapsed { &.collapsed {
.input-section textarea {
min-height: 48px;
height: 48px;
}
.controls { .controls {
display: none; display: none;
} }
> button { }
margin: auto; }
} &.connector {
.post-editor-content {
margin-top: 1rem;
}
.post-editor-fake-submit {
padding-top: 1rem;
} }
} }
} }

View File

@ -1,7 +1,8 @@
<template> <template>
<div <div
class="post" class="post"
@click.stop="toEvent(post.id)" :class="{clickable}"
@click.stop="clickable && toEvent(post.id)"
> >
<div class="post-author"> <div class="post-author">
<div class="connector-top"> <div class="connector-top">
@ -19,8 +20,8 @@
<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>
<p v-if="post.inReplyTo" class="in-reply-to"> <p v-if="ancestor" class="in-reply-to">
Replying to <a @click.stop="toEvent(post.inReplyTo)">{{ shorten(post.inReplyTo) }}</a> Replying to <a @click.stop="toEvent(ancestor)">{{ shorten(ancestor) }}</a>
</p> </p>
</div> </div>
<div class="post-content-body"> <div class="post-content-body">
@ -28,16 +29,16 @@
<BaseMarkdown :content="post.content" /> <BaseMarkdown :content="post.content" />
</p> </p>
</div> </div>
<div class="post-content-actions"> <div v-if="actions" class="post-content-actions">
<div class="action-item comment"> <div class="action-item comment" @click.stop="$store.dispatch('createPost', {replyTo})">
<BaseIcon icon="comment" /> <BaseIcon icon="comment" />
<span>{{ numComments }}</span> <span>{{ numComments }}</span>
</div> </div>
<div class="action-item repost"> <div class="action-item repost" @click.stop>
<BaseIcon 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" @click.stop>
<BaseIcon icon="like" /> <BaseIcon icon="like" />
<span>{{ post.stats.likes }}</span> <span>{{ post.stats.likes }}</span>
</div> </div>
@ -77,7 +78,7 @@ function postFromEvent(event) {
author: event.pubkey, author: event.pubkey,
createdAt: event.created_at * 1000, createdAt: event.created_at * 1000,
content: event.interpolated.text, content: event.interpolated.text,
inReplyTo: event.interpolated.replyEvents[event.interpolated.replyEvents.length - 1], replyTo: event.interpolated.replyEvents,
images: [], images: [],
stats: { stats: {
comments: '', comments: '',
@ -109,13 +110,26 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}, clickable: {
data() { type: Boolean,
return { default: false,
post: postFromEvent(this.event), },
} actions: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
post() {
return postFromEvent(this.event)
},
ancestor() {
if (this.post.replyTo.length === 0) return
return this.post.replyTo[this.post.replyTo.length - 1]
},
replyTo() {
return this.post.replyTo.concat([this.post.id])
},
numComments() { numComments() {
return countRepliesRecursive(this.event) return countRepliesRecursive(this.event)
}, },
@ -134,11 +148,12 @@ export default {
padding: 0 1rem; padding: 0 1rem;
display: flex; display: flex;
transition: 100ms ease background-color; transition: 100ms ease background-color;
cursor: pointer;
border-bottom: $border-dark; border-bottom: $border-dark;
&.clickable {
&:hover { cursor: pointer;
background-color: rgba($color: $color-dark-gray, $alpha: 0.2); &:hover {
background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
}
} }
&-author { &-author {
display: flex; display: flex;
@ -157,10 +172,6 @@ export default {
margin: auto; margin: auto;
background: rgb(56, 68, 77); background: rgb(56, 68, 77);
} }
img {
width: 100%;
border-radius: 999px;
}
} }
&-content { &-content {
margin-left: 12px; margin-left: 12px;

View File

@ -6,6 +6,8 @@
:event="event" :event="event"
:connector-top="events.length > 1 && index > 0" :connector-top="events.length > 1 && index > 0"
:connector-bottom="(events.length > 1 && index < events.length - 1) || forceBottomConnector" :connector-bottom="(events.length > 1 && index < events.length - 1) || forceBottomConnector"
actions
clickable
/> />
</div> </div>
</template> </template>
@ -29,7 +31,7 @@ export default {
}, },
}, },
mounted() { mounted() {
console.log(`Thread (${this.events.length}) ${this.events[0].content.substr(0, 100)}`, this.events) //console.log(`Thread (${this.events.length}) ${this.events[0].content.substr(0, 100)}`, this.events)
} }
} }
</script> </script>

View File

@ -462,7 +462,5 @@ export async function createPost(store, options) {
} catch (e) { } catch (e) {
return return
} }
store.commit('openPostDialog', options)
// TODO options
store.state.postDialogOpen = true
} }

View File

@ -184,3 +184,13 @@ export function dismissSignInDialog(state) {
state.signInFailure = null state.signInFailure = null
state.signInDialogOpen = false state.signInDialogOpen = false
} }
export function openPostDialog(state, params) {
state.postDialogParams = params
state.postDialogOpen = true
}
export function dismissPostDialog(state) {
state.postDialogParams = null
state.postDialogOpen = false
}

View File

@ -106,5 +106,6 @@ export default function () {
signInFailure: null, signInFailure: null,
postDialogOpen: false, postDialogOpen: false,
postDialogParams: {},
} }
} }