* Profile page refinements

* Add basic reaction support
This commit is contained in:
styppo 2023-01-10 01:43:15 +00:00
parent 3c66a50046
commit 371c068254
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
18 changed files with 445 additions and 58 deletions

View File

@ -0,0 +1,16 @@
<template>
<p>Nothing here 🤷</p>
</template>
<script>
export default {
name: 'EmptyPlaceholder'
}
</script>
<style lang="scss" scoped>
p {
padding: 1rem;
text-align: center;
}
</style>

View File

@ -15,7 +15,7 @@
<div class="post-content-header">
<p v-if="note.isReply()" class="in-reply-to">
Replying to
<a @click.stop="linkToProfile(ancestor?.author)">
<a @click.stop="goToProfile(ancestor?.author)">
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
</a>
</p>

View File

@ -2,7 +2,7 @@
<div
class="post"
:class="{clickable}"
@click.stop="clickable && linkToThread(note.id)"
@click.stop="clickable && goToThread(note.id)"
>
<div class="post-author">
<div class="connector-top">
@ -22,7 +22,7 @@
</p>
<p v-if="note.isReply()" class="in-reply-to">
Replying to
<a @click.stop="linkToProfile(ancestor?.author)">
<a @click.stop="goToProfile(ancestor?.author)">
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
</a>
</p>
@ -31,7 +31,7 @@
<BaseMarkdown :content="note.content" />
<!-- {{ note.content }}-->
</div>
<div v-if="actions" class="post-content-actions">
<div v-if="showActions" class="post-content-actions">
<div class="action-item comment" @click.stop="app.createPost({ancestor: note.id})">
<BaseIcon icon="comment" />
<span>{{ stats.comments || '' }}</span>
@ -109,6 +109,9 @@ export default {
shares: 0,
}
},
showActions() {
return this.actions && this.note.canReply()
},
},
methods: {
formatPostDate(timestamp) {

View File

@ -1,11 +1,11 @@
<template>
<div class="thread">
<ListPost
v-for="(note, index) in thread"
v-for="(note, index) in filteredThread"
:key="note.id"
:note="note"
:connector-top="thread.length > 1 && index > 0"
:connector-bottom="(thread.length > 1 && index < thread.length - 1) || forceBottomConnector"
:connector-top="filteredThread.length > 1 && index > 0"
:connector-bottom="(filteredThread.length > 1 && index < filteredThread.length - 1) || forceBottomConnector"
actions
clickable
/>
@ -30,6 +30,11 @@ export default {
default: false,
},
},
computed: {
filteredThread() {
return this.thread.filter(note => !!note)
}
}
}
</script>

View File

@ -2,7 +2,7 @@
<q-avatar
:class="{'cursor-pointer': clickable}"
:size="size"
@click="clickable && linkToProfile(pubkey)"
@click="clickable && goToProfile(pubkey)"
>
<img
v-if="hasAvatar && !avatarFetchFailed"

View File

@ -3,7 +3,7 @@
class="username"
:class="{'two-line': twoLine, clickable}"
>
<a @click="clickable && linkToProfile(pubkey)">
<a @click="clickable && goToProfile(pubkey)">
<span v-if="profile?.name" class="name">{{ profile.name }}</span>
<!-- <q-icon v-if="showFollowing && isFollow" name="visibility" color="secondary">-->
<!-- <q-tooltip>-->

View File

@ -12,7 +12,7 @@
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #1976D2;
$primary : #ee517d;
$secondary : #26A69A;
$accent : #9C27B0;

View File

@ -41,7 +41,7 @@ export default class FetchQueue extends Observable {
const ids = Object.keys(this.queue).slice(0, this.batchSize)
if (!ids.length) return
console.log(`Fetching ${ids.length}/${Object.keys(this.queue).length} ${this.subId}s`, ids)
//console.log(`Fetching ${ids.length}/${Object.keys(this.queue).length} ${this.subId}s`, ids)
// Remove ids that we have tried too many times.
const filteredIds = []

View File

@ -31,7 +31,7 @@ export default class NostrClient {
return this.pool.connectedRelays()
}
subscribe(filters, callback, opts) {
subscribe(filters, callback, opts = {}) {
let subId
if (opts?.subId) {
//if (this.subs[opts.subId]) throw new Error(`SubId '${opts.subId}' already exists`)
@ -69,7 +69,7 @@ export default class NostrClient {
const sub = this.subs[subId]
if (!sub) {
console.warn(`Event for invalid subId ${subId} from ${relay}`)
//console.warn(`Event for invalid subId ${subId} from ${relay}`)
return
}
@ -83,7 +83,7 @@ export default class NostrClient {
}
onEose(relay, subId) {
console.log(`[EOSE] from ${relay} for ${subId}`)
//console.log(`[EOSE] from ${relay} for ${subId}`)
const sub = this.subs[subId]
if (!sub) return

View File

@ -1,12 +1,14 @@
import {defineStore} from 'pinia'
import {markRaw} from 'vue'
import {EventKind} from 'src/nostr/model/Event'
import Note from 'src/nostr/model/Note'
import NostrClient from 'src/nostr/NostrClient'
import FetchQueue from 'src/nostr/FetchQueue'
import {NoteOrder, useNoteStore} from 'src/nostr/store/NoteStore'
import {useProfileStore} from 'src/nostr/store/ProfileStore'
import {useContactStore} from 'src/nostr/store/ContactStore'
import {useSettingsStore} from 'stores/Settings'
import {ReactionOrder, useReactionStore} from 'src/nostr/store/ReactionStore'
export const Feeds = {
GLOBAL: {
@ -87,9 +89,14 @@ export const useNostrStore = defineStore('nostr', {
return profiles.addEvent(event)
}
case EventKind.NOTE: {
if (Note.isReaction(event)) {
const reactions = useReactionStore()
return reactions.addEvent(event)
} else {
const notes = useNoteStore()
return notes.addEvent(event)
}
}
case EventKind.RELAY:
break
case EventKind.CONTACT: {
@ -102,8 +109,10 @@ export const useNostrStore = defineStore('nostr', {
break
case EventKind.SHARE:
break
case EventKind.REACTION:
break
case EventKind.REACTION: {
const reactions = useReactionStore()
return reactions.addEvent(event)
}
case EventKind.CHATROOM:
break
}
@ -181,6 +190,40 @@ export const useNostrStore = defineStore('nostr', {
)
},
getReactionsTo(id, order = ReactionOrder.CREATION_DATE_DESC) {
const store = useReactionStore()
const reactions = store.allByEvent(id, order)
// TODO fetch?
return reactions
},
fetchReactionsTo(id, limit = 500) {
return this.fetchMultiple(
{
kinds: [EventKind.REACTION],
'#e': [id],
},
limit
)
},
getReactionsByAuthor(pubkey, order = ReactionOrder.CREATION_DATE_DESC) {
const store = useReactionStore()
const reactions = store.allByAuthor(pubkey, order)
// TODO fetch?
return reactions
},
fetchReactionsByAuthor(pubkey, limit = 500) {
return this.fetchMultiple(
{
kinds: [EventKind.REACTION],
authors: [pubkey],
},
limit
)
},
streamThread(rootId, eventCallback, initialFetchCompleteCallback) {
return this.streamEvents(
{
@ -208,8 +251,40 @@ export const useNostrStore = defineStore('nostr', {
)
},
cancelStream(subId) {
streamFullProfile(pubkey) {
const handles = []
// Everything authored by pubkey
handles.push(this.client.subscribe({
kinds: [EventKind.NOTE],
authors: [pubkey],
limit: 200,
}, () => {}, { subId: 'foo' }))
handles.push(this.client.subscribe({
kinds: [EventKind.REACTION],
authors: [pubkey],
limit: 100,
}))
handles.push(this.client.subscribe({
kinds: [EventKind.METADATA, EventKind.CONTACT],
authors: [pubkey],
}))
// handles.push(this.client.subscribe({
// kinds: [EventKind.METADATA, EventKind.NOTE, EventKind.RELAY, EventKind.CONTACT, EventKind.REACTION],
// authors: [pubkey],
// }))
// Followers
handles.push(this.client.subscribe({
kinds: [EventKind.CONTACT],
'#p': [pubkey]
}))
return handles
},
cancelStream(subIds) {
if (!Array.isArray(subIds)) subIds = [subIds]
for (const subId of subIds) {
this.client.unsubscribe(subId)
}
},
fetchEvent(id) {

View File

@ -29,6 +29,7 @@ export class Relay extends Observable {
}
subscribe(subId, filters) {
console.log(`${this} subscribing to ${subId}`, filters)
this.socket.send(['REQ', subId, filters])
}

View File

@ -1,8 +1,10 @@
import {EventKind} from 'src/nostr/model/Event'
import {isEmoji} from 'src/utils/utils'
export default class Note {
constructor(id, args) {
this.id = id
this.kind = args.kind || EventKind.NOTE
this.author = args.author || args.pubkey
this.createdAt = args.createdAt
this.content = args.content
@ -13,11 +15,15 @@ export default class Note {
}
static from(event) {
console.assert(event.kind === EventKind.NOTE)
const content = Note.isReaction(event)
? Note.normalizeReactionContent(event.content)
: event.content
return new Note(event.id, {
kind: event.kind,
author: event.pubkey,
createdAt: event.createdAt,
content: event.content,
content,
refs: {
events: event.eventRefs(),
pubkeys: event.pubkeyRefs(),
@ -29,6 +35,10 @@ export default class Note {
return !this.refs.events.isEmpty()
}
canReply() {
return this.kind === EventKind.NOTE
}
root() {
return this.refs.events.root()
}
@ -36,4 +46,26 @@ export default class Note {
ancestor() {
return this.refs.events.ancestor()
}
isReaction() {
return this.kind === EventKind.REACTION
|| (this.isReply() && Note.isReactionContent(this.content))
}
static isReaction(event) {
return event.kind === EventKind.REACTION
|| (!event.eventRefs().isEmpty() && Note.isReactionContent(event.content))
}
static isReactionContent(content) {
return content === '+'
|| content === ''
|| isEmoji(content)
}
static normalizeReactionContent(content) {
return isEmoji(content)
? content
: '❤️'
}
}

View File

@ -0,0 +1,75 @@
import {defineStore} from 'pinia'
import {NoteOrder} from 'src/nostr/store/NoteStore'
import Note from 'src/nostr/model/Note'
// class Reaction {
// constructor(id, ancestor, author, createdAt, emoji) {
// this.id = id
// this.ancestor = ancestor
// this.author = author
// this.createdAt = createdAt
// this.emoji = emoji
// }
//
// static from(event) {
// console.assert([EventKind.REACTION, EventKind.NOTE].includes(event.kind))
// if (event.eventRefs().isEmpty()) return
//
// // TODO Normalize content better
// const emoji = isEmoji(event.content)
// ? event.content
// : '❤️'
//
// return new Reaction(
// event.id,
// event.eventRefs().ancestor(),
// event.pubkey,
// event.createdAt,
// emoji
// )
// }
// }
export const ReactionOrder = NoteOrder
export const useReactionStore = defineStore('reaction', {
state: () => ({
reactions: {},
byEvent: {},
byAuthor: {},
}),
getters: {
get(state) {
return id => state.reactions[id]
},
allByEvent(state) {
return (id, order) => (state.byEvent[id] || []).sort(order || ReactionOrder.CREATION_DATE_DESC)
},
allByAuthor(state) {
return (pubkey, order) => (state.byAuthor[pubkey] || []).sort(order || ReactionOrder.CREATION_DATE_DESC)
}
},
actions: {
addEvent(event) {
const note = Note.from(event)
if (!note || !note.isReply()) return false
// Skip if reaction already exists
if (this.reactions[note.id]) return this.reactions[note.id]
this.reactions[note.id] = note
if (!this.byEvent[note.ancestor()]) {
this.byEvent[note.ancestor()] = []
}
this.byEvent[note.ancestor()].push(note)
if (!this.byAuthor[note.author]) {
this.byAuthor[note.author] = []
}
this.byAuthor[note.author].push(note)
return this.reactions[note.id]
}
}
})

View File

@ -0,0 +1,134 @@
<template>
<q-page class="followers">
<PageHeader :title="profile?.name || hexToBech32(pubkey) || 'Followers'" back-button />
<div class="profile-tabs">
<q-tabs
v-model="activeTab"
outline
align="justify"
indicator-color="primary"
:breakpoint="0"
>
<q-tab name="following" label="Following" />
<q-tab name="followers" label="Followers" />
</q-tabs>
</div>
<q-tab-panels v-model="activeTab" class="profile-tab-panels" animated>
<q-tab-panel name="following" class="no-padding">
<UserCard
v-for="contact in contacts"
:key="contact.pubkey"
:pubkey="contact.pubkey"
clickable
/>
</q-tab-panel>
<q-tab-panel name="followers" class="no-padding">
<UserCard
v-for="follower in followers"
:key="follower"
:pubkey="follower"
clickable
/>
</q-tab-panel>
</q-tab-panels>
</q-page>
</template>
<script>
import {defineComponent} from 'vue'
import PageHeader from 'components/PageHeader.vue'
import UserCard from 'components/User/UserCard.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {bech32ToHex, hexToBech32} from 'src/utils/utils'
export default defineComponent({
name: 'Profile',
components: {
UserCard,
PageHeader,
},
setup() {
return {
app: useAppStore(),
nostr: useNostrStore(),
}
},
data() {
return {
activeTab: this.$route.params.tab || 'following',
}
},
computed: {
pubkey() {
return bech32ToHex(this.$route.params.pubkey)
},
profile() {
return this.nostr.getProfile(this.pubkey)
},
contacts() {
return this.nostr.getContacts(this.pubkey)
},
followers() {
return this.nostr.getFollowers(this.pubkey)
},
},
methods: {
hexToBech32,
},
mounted() {
this.nostr.fetchFollowers(this.pubkey, 1000)
},
watch: {
activeTab() {
this.$router.replace({
params: {
tab: this.activeTab
}
})
}
}
})
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
.profile {
&-header {
display: flex;
padding: 1rem;
&-avatar {
height: 128px;
width: 128px;
margin-right: 1rem;
}
&-content {
.followers {
a + a {
margin-left: 1rem;
}
}
}
}
&-tabs {
border-bottom: $border-dark;
}
&-tab-panels {
background-color: unset;
}
}
</style>
<style lang="scss">
.profile-tabs {
.q-tab {
}
}
.profile-header-content .username {
.name, .pubkey:first-child {
font-size: 1.4rem;
}
}
</style>

View File

@ -8,12 +8,12 @@
<p class="username"><UserName :pubkey="pubkey" two-line show-verified /></p>
<p class="about">{{ profile?.about }}</p>
<p class="followers">
<span>
<a @click="goToFollowers('following')">
<strong>{{ contacts?.length || 0 }}</strong> Following
</span>
<span>
</a>
<a @click="goToFollowers('followers')">
<strong>{{ followers?.length || 0 }}</strong> Followers
</span>
</a>
</p>
</div>
</div>
@ -21,15 +21,14 @@
<div class="profile-tabs">
<q-tabs
v-model="activeTab"
dense
outline
align="left"
align="justify"
indicator-color="primary"
:breakpoint="0"
>
<q-tab name="posts" label="Posts" />
<q-tab name="replies" label="Replies" />
<q-tab name="following" label="Following" />
<q-tab name="followers" label="Followers" />
<q-tab name="reactions" label="Reactions" />
<q-tab name="relays" label="Relays" />
</q-tabs>
</div>
@ -43,31 +42,23 @@
clickable
actions
/>
<EmptyPlaceholder v-if="!posts?.length" />
</q-tab-panel>
<q-tab-panel name="replies" class="no-padding">
<ListPost
v-for="note in replies"
:key="note.id"
:note="note"
clickable
actions
<Thread
v-for="thread in replies"
:key="thread[1].id"
:thread="thread"
/>
<EmptyPlaceholder v-if="!replies?.length" />
</q-tab-panel>
<q-tab-panel name="following" class="no-padding">
<UserCard
v-for="contact in contacts"
:key="contact.pubkey"
:pubkey="contact.pubkey"
clickable
/>
</q-tab-panel>
<q-tab-panel name="followers" class="no-padding">
<UserCard
v-for="follower in followers"
:key="follower"
:pubkey="follower"
clickable
<q-tab-panel name="reactions" class="no-padding">
<Thread
v-for="thread in reactions"
:key="thread[1].id"
:thread="thread"
/>
<EmptyPlaceholder v-if="!reactions?.length" />
</q-tab-panel>
</q-tab-panels>
</q-page>
@ -79,15 +70,17 @@ import PageHeader from 'components/PageHeader.vue'
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
import ListPost from 'components/Post/ListPost.vue'
import UserCard from 'components/User/UserCard.vue'
import Thread from 'components/Post/Thread.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {bech32ToHex} from 'src/utils/utils'
import {bech32ToHex, hexToBech32} from 'src/utils/utils'
import EmptyPlaceholder from 'components/EmptyPlaceholder.vue'
export default defineComponent({
name: 'Profile',
components: {
UserCard,
EmptyPlaceholder,
Thread,
PageHeader,
UserAvatar,
UserName,
@ -101,6 +94,7 @@ export default defineComponent({
},
data() {
return {
activeScreen: 'posts',
activeTab: 'posts',
}
},
@ -119,6 +113,11 @@ export default defineComponent({
},
replies() {
return this.notes.filter(note => note.isReply())
.map(note => [this.nostr.getNote(note.ancestor()), note])
},
reactions() {
return this.nostr.getReactionsByAuthor(this.pubkey)
.map(note => [this.nostr.getNote(note.ancestor()), note])
},
contacts() {
return this.nostr.getContacts(this.pubkey)
@ -127,14 +126,33 @@ export default defineComponent({
return this.nostr.getFollowers(this.pubkey)
},
},
methods: {
goToFollowers(tab = 'following') {
this.$router.push({
name: 'followers',
params: {
pubkey: hexToBech32(this.pubkey),
tab,
}
})
}
},
mounted() {
// FIXME
// this.stream = this.nostr.streamFullProfile(this.pubkey)
this.nostr.fetchNotesByAuthor(this.pubkey)
this.nostr.fetchFollowers(this.pubkey)
this.nostr.fetchReactionsByAuthor(this.pubkey, 100)
this.nostr.fetchFollowers(this.pubkey, 1000)
},
unmounted() {
// if (this.stream) this.nostr.cancelStream(this.stream)
}
})
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
.profile {
&-header {
display: flex;
@ -146,18 +164,35 @@ export default defineComponent({
}
&-content {
.followers {
span + span {
a {
cursor: pointer;
color: $color-light-gray;
&:hover, &:active {
text-decoration: underline;
}
strong {
color: #fff;
}
}
a + a {
margin-left: 1rem;
}
}
}
}
&-tabs {
border-bottom: $border-dark;
}
&-tab-panels {
background-color: unset;
}
}
</style>
<style lang="scss">
.profile-tabs {
.q-tab {
}
}
.profile-header-content .username {
.name, .pubkey:first-child {
font-size: 1.4rem;

View File

@ -2,7 +2,7 @@ import {hexToBech32} from 'src/utils/utils'
export default {
methods: {
linkToProfile(pubkey) {
goToProfile(pubkey) {
this.$router.push({
name: 'profile',
params: {
@ -10,7 +10,7 @@ export default {
}
})
},
linkToThread(id) {
goToThread(id) {
this.$router.push({
name: 'thread',
params: {

View File

@ -15,9 +15,14 @@ const routes = [
},
{
path: '/profile/:pubkey(npub[a-z0-9A-Z]{59})',
component: () => import('pages/Profile.vue'),
component: () => import('pages/profile/Profile.vue'),
name: 'profile',
},
{
path: '/profile/:pubkey(npub[a-z0-9A-Z]{59})/:tab(following|followers)',
component: () => import('pages/profile/Followers.vue'),
name: 'followers',
},
{
path: '/thread/:id(note[a-z0-9A-Z]{59})',
component: () => import('pages/Thread.vue'),

File diff suppressed because one or more lines are too long