Basic Profile page

This commit is contained in:
styppo 2023-01-09 22:24:24 +00:00
parent 533f5e662a
commit 3c66a50046
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
15 changed files with 347 additions and 98 deletions

View File

@ -83,6 +83,7 @@ export default {
.create-post-dialog {
position: relative;
background-color: $color-bg;
box-shadow: $shadow-white;
padding: 3rem 1rem 1rem;
min-width: 660px;
.icon {

View File

@ -20,7 +20,7 @@
<MenuItem
v-if="!hideItemsRequiringSignIn || app.isSignedIn"
icon="profile"
:to="`/profile/${app.myPubkey}`"
:to="`/profile/${hexToBech32(app.myPubkey, 'npub')}`"
:enabled="app.isSignedIn"
@click="$emit('mobile-menu-close')"
>
@ -71,6 +71,7 @@ import ProfilePopup from 'components/MainMenu/ProfilePopup'
import Logo from 'components/Logo.vue'
import {useAppStore} from 'stores/App'
import {MENU_ITEMS} from 'components/MainMenu/constants.js'
import {hexToBech32} from 'src/utils/utils'
export default {
name: 'MainMenu',
@ -105,7 +106,8 @@ export default {
signIn() {
this.$emit('mobile-menu-close')
this.app.signIn()
}
},
hexToBech32
}
}
</script>

View File

@ -48,7 +48,7 @@ export default defineComponent({
methods: {
titleFromRoute() {
const route = this.$route.name?.toLowerCase()
return route.charAt(0).toUpperCase() + route.substring(1)
return route?.charAt(0).toUpperCase() + route?.substring(1)
}
}
})

View File

@ -1,6 +1,8 @@
<template>
<q-dialog v-model="dialogOpen">
<div class="logout-dialog">
<q-btn icon="close" size="md" class="icon" flat round v-close-popup/>
<h3>Log out from <UserName :pubkey="pubkey" /></h3>
<p>
Do you really want to log out from <UserName :pubkey="pubkey" />?
@ -77,11 +79,25 @@ export default {
padding: 1rem;
min-width: 440px;
.icon {
position: absolute;
width: 16px;
height: 16px;
top: .5rem;
left: .5rem;
fill: #fff;
}
h3 {
margin-top: 0;
margin-top: 3rem;
padding: 0 .5rem;
}
> p {
padding: 0 .5rem;
}
.warning {
color: $color-primary;
display: flex;
background-color: rgba($color: $color-dark-gray, $alpha: 0.1);
border-radius: 1rem 1rem 0 0;
@ -102,13 +118,9 @@ export default {
padding: 12px 20px;
border-radius: 0 0 1rem 1rem;
margin: 0 auto 1rem;
outline: none;
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
border: 1px solid rgba($color: $color-dark-gray, $alpha: 0);
transition: border 150ms ease;
&:focus {
border: 1px solid rgba($color: $color-primary, $alpha: 1);
outline: none;
}
}
.buttons {

View File

@ -1,8 +1,8 @@
<template>
<q-avatar
@click="clickable && linkToProfile(pubkey)"
class="relative-position"
:class="{'cursor-pointer': clickable}"
:size="size"
@click="clickable && linkToProfile(pubkey)"
>
<img
v-if="hasAvatar && !avatarFetchFailed"
@ -38,6 +38,10 @@ export default {
type: Boolean,
default: false,
},
size: {
type: String,
default: '',
}
},
data() {
return {

View File

@ -0,0 +1,36 @@
<template>
<div class="user-card">
<UserAvatar :pubkey="pubkey" :clickable="clickable" />
<UserName :pubkey="pubkey" :clickable="clickable" two-line />
</div>
</template>
<script>
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
export default {
name: 'UserCard',
components: {UserName, UserAvatar},
props: {
pubkey: {
type: String,
required: true,
},
clickable: {
type: Boolean,
default: false,
}
}
}
</script>
<style lang="scss" scoped>
.user-card {
display: flex;
margin: 1rem;
* + * {
margin-left: 1rem;
}
}
</style>

View File

@ -4,17 +4,12 @@
:class="{'two-line': twoLine, clickable}"
>
<a @click="clickable && linkToProfile(pubkey)">
<span
v-if="profile?.name"
class="name"
>
{{ profile.name }}
<q-icon v-if="showFollowing && isFollow" name="visibility" color="secondary">
<q-tooltip>
following
</q-tooltip>
</q-icon>
</span>
<span v-if="profile?.name" class="name">{{ profile.name }}</span>
<!-- <q-icon v-if="showFollowing && isFollow" name="visibility" color="secondary">-->
<!-- <q-tooltip>-->
<!-- following-->
<!-- </q-tooltip>-->
<!-- </q-icon>-->
<Bech32Label v-if="twoLine || !profile?.name" prefix="npub" :hex="pubkey" class="pubkey" />
</a>
@ -88,7 +83,6 @@ export default {
@import "assets/theme/colors.scss";
.username {
cursor: pointer;
.name {
font-weight: bold;
}
@ -109,6 +103,7 @@ export default {
}
}
&.clickable {
cursor: pointer;
.name:hover {
text-decoration: underline;
}

34
src/nostr/Account.js Normal file
View File

@ -0,0 +1,34 @@
import {getEventHash, getPublicKey, signEvent} from 'nostr-tools'
import Nip07 from 'src/utils/Nip07'
export class Account {
constructor(opts) {
this.pubkey = opts.pubkey || null
this.privkey = opts.privkey || null
this.useExtension = opts.useExtension || false
if (this.privkey && !this.pubkey) {
this.pubkey = getPublicKey(this.privkey)
}
if (!this.pubkey) throw new Error('pubkey is required')
}
canSign() {
return !!this.privkey || (this.useExtension && Nip07.isAvailable())
}
async sign(event) {
event.id = getEventHash(event)
if (this.privkey) {
event.sig = signEvent(event, this.privkey)
} else if (this.useExtension && Nip07.isAvailable()) {
let {sig} = await Nip07.signEvent(event)
event.sig = sig
} else {
// TODO
throw new Error('cannot sign')
}
return event
}
}

View File

@ -8,7 +8,7 @@ export default class FetchQueue extends Observable {
this.fnGetId = fnGetId
this.fnCreateFilter = fnCreateFilter
this.throttle = opts.throttle || 250
this.batchSize = opts.batchSize || 20
this.batchSize = opts.batchSize || 50
this.retryDelay = opts.retryDelay || 3000
this.maxRetries = opts.maxRetries || 3
@ -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} ${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

@ -1,24 +1,18 @@
import {defineStore} from 'pinia'
import {markRaw} from 'vue'
import {EventKind} from 'src/nostr/model/Event'
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 {markRaw} from 'vue'
import FetchQueue from 'src/nostr/FetchQueue'
// TODO Move to settings
const RELAYS = [
'wss://relay.damus.io',
'wss://nostr-relay.wlvs.space',
'wss://nostr-pub.wellorder.net',
'wss://nostr.oxtr.dev',
]
import {useContactStore} from 'src/nostr/store/ContactStore'
import {useSettingsStore} from 'stores/Settings'
export const Feeds = {
GLOBAL: {
name: 'global',
filters: {
kinds: [EventKind.NOTE, EventKind.DELETE],
kinds: [EventKind.NOTE], // TODO Deletions
},
initialFetchSize: 100,
},
@ -43,6 +37,16 @@ const profileQueue = client => new FetchQueue(
})
)
const contactQueue = client => new FetchQueue(
client,
'contact',
event => event.pubkey,
pubkeys => ({
kinds: [EventKind.CONTACT],
authors: pubkeys
})
)
export const useNostrStore = defineStore('nostr', {
state: () => ({
// TODO Limit size. Remove oldest.
@ -50,7 +54,9 @@ export const useNostrStore = defineStore('nostr', {
}),
actions: {
init() {
this.client = markRaw(new NostrClient(RELAYS))
const settings = useSettingsStore()
console.log(settings.relays)
this.client = markRaw(new NostrClient(settings.relays))
this.client.connect()
this.profileQueue = profileQueue(this.client)
@ -58,6 +64,9 @@ export const useNostrStore = defineStore('nostr', {
this.noteQueue = eventQueue(this.client, 'note')
this.noteQueue.on('event', this.addEvent.bind(this))
this.contactQueue = contactQueue(this.client, 'queue')
this.contactQueue.on('event', this.addEvent.bind(this))
},
addEvent(event, relay = null) {
// console.log(`[EVENT] from ${relay}`, event)
@ -83,8 +92,10 @@ export const useNostrStore = defineStore('nostr', {
}
case EventKind.RELAY:
break
case EventKind.CONTACT:
break
case EventKind.CONTACT: {
const contacts = useContactStore()
return contacts.addEvent(event)
}
case EventKind.DM:
break
case EventKind.DELETE:
@ -145,6 +156,31 @@ export const useNostrStore = defineStore('nostr', {
)
},
getContacts(pubkey) {
const store = useContactStore()
const contacts = store.getContacts(pubkey)
if (!contacts) this.contactQueue.add(pubkey)
return contacts
},
getFollowers(pubkey) {
const store = useContactStore()
const followers = store.getFollowers(pubkey)
// TODO fetch
return followers
},
fetchFollowers(pubkey, opts = {}) {
const limit = opts.limit || 500
return this.fetchMultiple(
{
kinds: [EventKind.CONTACT],
'#p': [pubkey],
},
limit
)
},
streamThread(rootId, eventCallback, initialFetchCompleteCallback) {
return this.streamEvents(
{
@ -199,6 +235,7 @@ export const useNostrStore = defineStore('nostr', {
fetchMultiple(filters, limit = 100) {
const filtersWithLimit = Object.assign({}, filters, {limit})
let unsubscribeTimeout
return new Promise(resolve => {
const objects = []
this.client.subscribe(
@ -216,8 +253,11 @@ export const useNostrStore = defineStore('nostr', {
},
{
eoseCallback: (_relay, subId) => {
if (unsubscribeTimeout) clearTimeout(unsubscribeTimeout)
unsubscribeTimeout = setTimeout(() => {
this.client.unsubscribe(subId)
resolve(objects)
}, 250)
}
}
)

View File

@ -169,7 +169,7 @@ class ReconnectingWebSocket extends Observable {
}
onError(error) {
console.log(`Socket error from relay ${this.url}: ${error.message || error}`)
console.log(`Socket error from relay ${this.url}`, error)
this.emit('error', error, this)
if (this.opts.reconnect) this.reconnect()

View File

@ -0,0 +1,66 @@
import {defineStore} from 'pinia'
import {EventKind, Tag} from 'src/nostr/model/Event'
export const useContactStore = defineStore('contact', {
state: () => ({
contacts: {},
followers: {},
}),
getters: {
getContacts(state) {
return pubkey => state.contacts[pubkey]
},
getFollowers(state) {
return pubkey => state.followers[pubkey]
}
},
actions: {
addEvent(event) {
console.assert(event.kind === EventKind.CONTACT)
const existingContacts = this.contacts[event.pubkey]
if (existingContacts && existingContacts.lastUpdatedAt >= event.createdAt) {
return
}
const newContacts = []
newContacts.lastUpdatedAt = event.createdAt
const tags = event.tags.filter(tag => tag[0] === Tag.PUBKEY && tag[1])
for (const tag of tags) {
newContacts.push({
pubkey: tag[1],
relay: tag[2],
name: tag[3],
})
}
if (existingContacts) {
for (const contact of existingContacts) {
this.removeFollower(contact.pubkey, event.pubkey)
}
}
for (const contact of newContacts) {
this.addFollower(contact.pubkey, event.pubkey)
}
this.contacts[event.pubkey] = newContacts
return event
},
addFollower(to, follower) {
if (this.followers[to]) {
this.followers[to].push(follower)
} else {
this.followers[to] = [follower]
}
},
removeFollower(from, follower) {
const followers = this.followers[from]
if (!followers) return
const index = followers.indexOf(follower)
if (index < 0) return
followers.splice(index, 1)
}
}
})

View File

@ -10,7 +10,7 @@
<div>
<div
v-for="feed in availableFeeds"
:key="feed.name"
:key="feed"
@click="switchFeed(feed)"
class="popup-header"
v-close-popup>
@ -30,7 +30,7 @@
</div>
<div class="feed">
<div class="load-more-container" :class="{'more-available': /* FIXME */false}">
<div class="load-more-container" :class="{'more-available': numUnreads}">
<ButtonLoadMore
v-if="numUnreads"
:label="`Load ${numUnreads} unread`"
@ -78,15 +78,15 @@ export default defineComponent({
},
data() {
return {
availableFeeds: [Feeds.GLOBAL],
selectedFeed: Feeds.GLOBAL,
availableFeeds: ['global'],
selectedFeed: 'global',
feeds: {},
initialLoadComplete: false,
recentlyLoaded: true,
}
},
computed: {
activeFeed() {
return this.feeds[this.selectedFeed.name]
return this.feeds[this.selectedFeed]
},
feedItems() {
return this.activeFeed?.items
@ -95,15 +95,15 @@ export default defineComponent({
return this.activeFeed?.unreads
},
numUnreads() {
if (!this.initialLoadComplete) return 0
if (this.recentlyLoaded) return 0
return this.activeFeed?.unreads.length
},
},
methods: {
initFeed(feed) {
if (this.feeds[feed.name]) return
initFeed(feedId) {
if (this.feeds[feedId]) return
this.feeds[feed.name] = {
this.feeds[feedId] = {
items: [],
unreads: [],
}
@ -111,34 +111,39 @@ export default defineComponent({
let initialFetchComplete = false
let initialItems = []
console.log(`subscribing to feed ${feed.name}`, this.feeds[feed.name])
console.log(`subscribing to feed ${feedId}`, this.feeds[feedId])
this.nostr.streamFeed(
feed,
Feeds[feedId.toUpperCase()],
event => {
const target = initialFetchComplete
? this.feeds[feed.name].unreads
? this.feeds[feedId].unreads
: initialItems
target.push([event]) // FIXME Single element thread
},
() => {
initialItems.sort(feedOrder)
this.feeds[feed.name].items = initialItems
this.feeds[feedId].items = initialItems
initialFetchComplete = true
// Wait a bit before showing the first unreads
setTimeout(() => this.initialLoadComplete = true, 5000)
setTimeout(() => this.recentlyLoaded = false, 5000)
}
)
},
switchFeed(feed) {
this.initFeed(feed)
this.selectedFeed = feed
switchFeed(feedId) {
this.initFeed(feedId)
this.selectedFeed = feedId
},
loadUnreads() {
const items = this.feedUnreads.concat(this.feedItems)
items.sort(feedOrder)
this.activeFeed.items = items
this.activeFeed.unreads = []
// Wait a bit before showing unreads again
this.recentlyLoaded = true
setTimeout(() => this.recentlyLoaded = false, 5000)
},
},
mounted() {
@ -166,6 +171,9 @@ export default defineComponent({
.addon-menu {
display: flex;
flex-direction: row-reverse;
&-icon {
cursor: pointer;
}
&-popup {
min-width: 150px;
border-radius: 1rem;

View File

@ -3,9 +3,18 @@
<PageHeader back-button />
<div class="profile-header">
<UserAvatar :pubkey="pubkey" />
<UserAvatar :pubkey="pubkey" class="profile-header-avatar" />
<div class="profile-header-content">
<UserName :pubkey="pubkey" two-line show-verified />
<p class="username"><UserName :pubkey="pubkey" two-line show-verified /></p>
<p class="about">{{ profile?.about }}</p>
<p class="followers">
<span>
<strong>{{ contacts?.length || 0 }}</strong> Following
</span>
<span>
<strong>{{ followers?.length || 0 }}</strong> Followers
</span>
</p>
</div>
</div>
@ -18,7 +27,8 @@
:breakpoint="0"
>
<q-tab name="posts" label="Posts" />
<q-tab name="follows" label="Follows" />
<q-tab name="replies" label="Replies" />
<q-tab name="following" label="Following" />
<q-tab name="followers" label="Followers" />
<q-tab name="relays" label="Relays" />
</q-tabs>
@ -34,6 +44,31 @@
actions
/>
</q-tab-panel>
<q-tab-panel name="replies" class="no-padding">
<ListPost
v-for="note in replies"
:key="note.id"
:note="note"
clickable
actions
/>
</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>
</q-tab-panels>
</q-page>
</template>
@ -44,6 +79,7 @@ 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 {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {bech32ToHex} from 'src/utils/utils'
@ -51,6 +87,7 @@ import {bech32ToHex} from 'src/utils/utils'
export default defineComponent({
name: 'Profile',
components: {
UserCard,
PageHeader,
UserAvatar,
UserName,
@ -71,12 +108,28 @@ export default defineComponent({
pubkey() {
return bech32ToHex(this.$route.params.pubkey)
},
posts() {
profile() {
return this.nostr.getProfile(this.pubkey)
},
notes() {
return this.nostr.getNotesByAuthor(this.pubkey)
},
posts() {
return this.notes.filter(note => !note.isReply())
},
replies() {
return this.notes.filter(note => note.isReply())
},
contacts() {
return this.nostr.getContacts(this.pubkey)
},
followers() {
return this.nostr.getFollowers(this.pubkey)
},
},
mounted() {
this.nostr.fetchNotesByAuthor(this.pubkey)
this.nostr.fetchFollowers(this.pubkey)
}
})
</script>
@ -85,9 +138,29 @@ export default defineComponent({
.profile {
&-header {
display: flex;
padding: 1rem;
&-avatar {
height: 128px;
width: 128px;
margin-right: 1rem;
}
&-content {
.followers {
span + span {
margin-left: 1rem;
}
}
}
}
&-tab-panels {
background-color: unset;
}
}
</style>
<style lang="scss">
.profile-header-content .username {
.name, .pubkey:first-child {
font-size: 1.4rem;
}
}
</style>

View File

@ -1,44 +1,22 @@
import {defineStore} from 'pinia'
import {getEventHash, getPublicKey, signEvent} from 'nostr-tools'
import Nip07 from 'src/utils/Nip07'
import {Account} from 'src/nostr/Account'
export class Account {
constructor(opts) {
this.pubkey = opts.pubkey || null
this.privkey = opts.privkey || null
this.useExtension = opts.useExtension || false
if (this.privkey && !this.pubkey) {
this.pubkey = getPublicKey(this.privkey)
}
if (!this.pubkey) throw new Error('pubkey is required')
}
canSign() {
return !!this.privkey || (this.useExtension && Nip07.isAvailable())
}
async sign(event) {
event.id = getEventHash(event)
if (this.privkey) {
event.sig = signEvent(event, this.privkey)
} else if (this.useExtension && Nip07.isAvailable()) {
let {sig} = await Nip07.signEvent(event)
event.sig = sig
} else {
// TODO
throw new Error('cannot sign')
}
return event
}
}
const RELAYS = [
'wss://nostr-pub.wellorder.net',
'wss://nostr.onsats.org',
'wss://nostr-relay.wlvs.space',
'wss://nostr.bitcoiner.social',
'wss://relay.damus.io',
'wss://nostr.zebedee.cloud',
'wss://relay.nostr.info',
'wss://nostr-pub.semisol.dev',
]
export const useSettingsStore = defineStore('settings', {
state: () => ({
accounts: {},
pubkey: null,
relays: []
relays: RELAYS,
}),
getters: {
activeAccount(state) {