mirror of
https://github.com/styppo/hamstr.git
synced 2024-10-18 13:33:22 +00:00
Basic Profile page
This commit is contained in:
parent
533f5e662a
commit
3c66a50046
@ -83,6 +83,7 @@ export default {
|
|||||||
.create-post-dialog {
|
.create-post-dialog {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: $color-bg;
|
background-color: $color-bg;
|
||||||
|
box-shadow: $shadow-white;
|
||||||
padding: 3rem 1rem 1rem;
|
padding: 3rem 1rem 1rem;
|
||||||
min-width: 660px;
|
min-width: 660px;
|
||||||
.icon {
|
.icon {
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
v-if="!hideItemsRequiringSignIn || app.isSignedIn"
|
v-if="!hideItemsRequiringSignIn || app.isSignedIn"
|
||||||
icon="profile"
|
icon="profile"
|
||||||
:to="`/profile/${app.myPubkey}`"
|
:to="`/profile/${hexToBech32(app.myPubkey, 'npub')}`"
|
||||||
:enabled="app.isSignedIn"
|
:enabled="app.isSignedIn"
|
||||||
@click="$emit('mobile-menu-close')"
|
@click="$emit('mobile-menu-close')"
|
||||||
>
|
>
|
||||||
@ -71,6 +71,7 @@ import ProfilePopup from 'components/MainMenu/ProfilePopup'
|
|||||||
import Logo from 'components/Logo.vue'
|
import Logo from 'components/Logo.vue'
|
||||||
import {useAppStore} from 'stores/App'
|
import {useAppStore} from 'stores/App'
|
||||||
import {MENU_ITEMS} from 'components/MainMenu/constants.js'
|
import {MENU_ITEMS} from 'components/MainMenu/constants.js'
|
||||||
|
import {hexToBech32} from 'src/utils/utils'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MainMenu',
|
name: 'MainMenu',
|
||||||
@ -105,7 +106,8 @@ export default {
|
|||||||
signIn() {
|
signIn() {
|
||||||
this.$emit('mobile-menu-close')
|
this.$emit('mobile-menu-close')
|
||||||
this.app.signIn()
|
this.app.signIn()
|
||||||
}
|
},
|
||||||
|
hexToBech32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -48,7 +48,7 @@ export default defineComponent({
|
|||||||
methods: {
|
methods: {
|
||||||
titleFromRoute() {
|
titleFromRoute() {
|
||||||
const route = this.$route.name?.toLowerCase()
|
const route = this.$route.name?.toLowerCase()
|
||||||
return route.charAt(0).toUpperCase() + route.substring(1)
|
return route?.charAt(0).toUpperCase() + route?.substring(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-dialog v-model="dialogOpen">
|
<q-dialog v-model="dialogOpen">
|
||||||
<div class="logout-dialog">
|
<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>
|
<h3>Log out from <UserName :pubkey="pubkey" /></h3>
|
||||||
<p>
|
<p>
|
||||||
Do you really want to log out from <UserName :pubkey="pubkey" />?
|
Do you really want to log out from <UserName :pubkey="pubkey" />?
|
||||||
@ -77,11 +79,25 @@ export default {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
min-width: 440px;
|
min-width: 440px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
top: .5rem;
|
||||||
|
left: .5rem;
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin-top: 0;
|
margin-top: 3rem;
|
||||||
|
padding: 0 .5rem;
|
||||||
|
}
|
||||||
|
> p {
|
||||||
|
padding: 0 .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
|
color: $color-primary;
|
||||||
display: flex;
|
display: flex;
|
||||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.1);
|
background-color: rgba($color: $color-dark-gray, $alpha: 0.1);
|
||||||
border-radius: 1rem 1rem 0 0;
|
border-radius: 1rem 1rem 0 0;
|
||||||
@ -102,13 +118,9 @@ export default {
|
|||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
border-radius: 0 0 1rem 1rem;
|
border-radius: 0 0 1rem 1rem;
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1rem;
|
||||||
|
outline: none;
|
||||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
||||||
border: 1px solid rgba($color: $color-dark-gray, $alpha: 0);
|
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 {
|
.buttons {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-avatar
|
<q-avatar
|
||||||
@click="clickable && linkToProfile(pubkey)"
|
|
||||||
class="relative-position"
|
|
||||||
:class="{'cursor-pointer': clickable}"
|
:class="{'cursor-pointer': clickable}"
|
||||||
|
:size="size"
|
||||||
|
@click="clickable && linkToProfile(pubkey)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="hasAvatar && !avatarFetchFailed"
|
v-if="hasAvatar && !avatarFetchFailed"
|
||||||
@ -38,6 +38,10 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
36
src/components/User/UserCard.vue
Normal file
36
src/components/User/UserCard.vue
Normal 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>
|
@ -4,17 +4,12 @@
|
|||||||
:class="{'two-line': twoLine, clickable}"
|
:class="{'two-line': twoLine, clickable}"
|
||||||
>
|
>
|
||||||
<a @click="clickable && linkToProfile(pubkey)">
|
<a @click="clickable && linkToProfile(pubkey)">
|
||||||
<span
|
<span v-if="profile?.name" class="name">{{ profile.name }}</span>
|
||||||
v-if="profile?.name"
|
<!-- <q-icon v-if="showFollowing && isFollow" name="visibility" color="secondary">-->
|
||||||
class="name"
|
<!-- <q-tooltip>-->
|
||||||
>
|
<!-- following-->
|
||||||
{{ profile.name }}
|
<!-- </q-tooltip>-->
|
||||||
<q-icon v-if="showFollowing && isFollow" name="visibility" color="secondary">
|
<!-- </q-icon>-->
|
||||||
<q-tooltip>
|
|
||||||
following
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</span>
|
|
||||||
<Bech32Label v-if="twoLine || !profile?.name" prefix="npub" :hex="pubkey" class="pubkey" />
|
<Bech32Label v-if="twoLine || !profile?.name" prefix="npub" :hex="pubkey" class="pubkey" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@ -88,7 +83,6 @@ export default {
|
|||||||
@import "assets/theme/colors.scss";
|
@import "assets/theme/colors.scss";
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
cursor: pointer;
|
|
||||||
.name {
|
.name {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@ -109,6 +103,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.clickable {
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
.name:hover {
|
.name:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
34
src/nostr/Account.js
Normal file
34
src/nostr/Account.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,7 @@ export default class FetchQueue extends Observable {
|
|||||||
this.fnGetId = fnGetId
|
this.fnGetId = fnGetId
|
||||||
this.fnCreateFilter = fnCreateFilter
|
this.fnCreateFilter = fnCreateFilter
|
||||||
this.throttle = opts.throttle || 250
|
this.throttle = opts.throttle || 250
|
||||||
this.batchSize = opts.batchSize || 20
|
this.batchSize = opts.batchSize || 50
|
||||||
this.retryDelay = opts.retryDelay || 3000
|
this.retryDelay = opts.retryDelay || 3000
|
||||||
this.maxRetries = opts.maxRetries || 3
|
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)
|
const ids = Object.keys(this.queue).slice(0, this.batchSize)
|
||||||
if (!ids.length) return
|
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.
|
// Remove ids that we have tried too many times.
|
||||||
const filteredIds = []
|
const filteredIds = []
|
||||||
|
@ -1,24 +1,18 @@
|
|||||||
import {defineStore} from 'pinia'
|
import {defineStore} from 'pinia'
|
||||||
|
import {markRaw} from 'vue'
|
||||||
import {EventKind} from 'src/nostr/model/Event'
|
import {EventKind} from 'src/nostr/model/Event'
|
||||||
import NostrClient from 'src/nostr/NostrClient'
|
import NostrClient from 'src/nostr/NostrClient'
|
||||||
|
import FetchQueue from 'src/nostr/FetchQueue'
|
||||||
import {NoteOrder, useNoteStore} from 'src/nostr/store/NoteStore'
|
import {NoteOrder, useNoteStore} from 'src/nostr/store/NoteStore'
|
||||||
import {useProfileStore} from 'src/nostr/store/ProfileStore'
|
import {useProfileStore} from 'src/nostr/store/ProfileStore'
|
||||||
import {markRaw} from 'vue'
|
import {useContactStore} from 'src/nostr/store/ContactStore'
|
||||||
import FetchQueue from 'src/nostr/FetchQueue'
|
import {useSettingsStore} from 'stores/Settings'
|
||||||
|
|
||||||
// TODO Move to settings
|
|
||||||
const RELAYS = [
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nostr-relay.wlvs.space',
|
|
||||||
'wss://nostr-pub.wellorder.net',
|
|
||||||
'wss://nostr.oxtr.dev',
|
|
||||||
]
|
|
||||||
|
|
||||||
export const Feeds = {
|
export const Feeds = {
|
||||||
GLOBAL: {
|
GLOBAL: {
|
||||||
name: 'global',
|
name: 'global',
|
||||||
filters: {
|
filters: {
|
||||||
kinds: [EventKind.NOTE, EventKind.DELETE],
|
kinds: [EventKind.NOTE], // TODO Deletions
|
||||||
},
|
},
|
||||||
initialFetchSize: 100,
|
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', {
|
export const useNostrStore = defineStore('nostr', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
// TODO Limit size. Remove oldest.
|
// TODO Limit size. Remove oldest.
|
||||||
@ -50,7 +54,9 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
init() {
|
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.client.connect()
|
||||||
|
|
||||||
this.profileQueue = profileQueue(this.client)
|
this.profileQueue = profileQueue(this.client)
|
||||||
@ -58,6 +64,9 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
|
|
||||||
this.noteQueue = eventQueue(this.client, 'note')
|
this.noteQueue = eventQueue(this.client, 'note')
|
||||||
this.noteQueue.on('event', this.addEvent.bind(this))
|
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) {
|
addEvent(event, relay = null) {
|
||||||
// console.log(`[EVENT] from ${relay}`, event)
|
// console.log(`[EVENT] from ${relay}`, event)
|
||||||
@ -83,8 +92,10 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
}
|
}
|
||||||
case EventKind.RELAY:
|
case EventKind.RELAY:
|
||||||
break
|
break
|
||||||
case EventKind.CONTACT:
|
case EventKind.CONTACT: {
|
||||||
break
|
const contacts = useContactStore()
|
||||||
|
return contacts.addEvent(event)
|
||||||
|
}
|
||||||
case EventKind.DM:
|
case EventKind.DM:
|
||||||
break
|
break
|
||||||
case EventKind.DELETE:
|
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) {
|
streamThread(rootId, eventCallback, initialFetchCompleteCallback) {
|
||||||
return this.streamEvents(
|
return this.streamEvents(
|
||||||
{
|
{
|
||||||
@ -199,6 +235,7 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
|
|
||||||
fetchMultiple(filters, limit = 100) {
|
fetchMultiple(filters, limit = 100) {
|
||||||
const filtersWithLimit = Object.assign({}, filters, {limit})
|
const filtersWithLimit = Object.assign({}, filters, {limit})
|
||||||
|
let unsubscribeTimeout
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const objects = []
|
const objects = []
|
||||||
this.client.subscribe(
|
this.client.subscribe(
|
||||||
@ -216,8 +253,11 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
eoseCallback: (_relay, subId) => {
|
eoseCallback: (_relay, subId) => {
|
||||||
|
if (unsubscribeTimeout) clearTimeout(unsubscribeTimeout)
|
||||||
|
unsubscribeTimeout = setTimeout(() => {
|
||||||
this.client.unsubscribe(subId)
|
this.client.unsubscribe(subId)
|
||||||
resolve(objects)
|
resolve(objects)
|
||||||
|
}, 250)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -169,7 +169,7 @@ class ReconnectingWebSocket extends Observable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onError(error) {
|
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)
|
this.emit('error', error, this)
|
||||||
if (this.opts.reconnect) this.reconnect()
|
if (this.opts.reconnect) this.reconnect()
|
||||||
|
66
src/nostr/store/ContactStore.js
Normal file
66
src/nostr/store/ContactStore.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@ -10,7 +10,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-for="feed in availableFeeds"
|
v-for="feed in availableFeeds"
|
||||||
:key="feed.name"
|
:key="feed"
|
||||||
@click="switchFeed(feed)"
|
@click="switchFeed(feed)"
|
||||||
class="popup-header"
|
class="popup-header"
|
||||||
v-close-popup>
|
v-close-popup>
|
||||||
@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feed">
|
<div class="feed">
|
||||||
<div class="load-more-container" :class="{'more-available': /* FIXME */false}">
|
<div class="load-more-container" :class="{'more-available': numUnreads}">
|
||||||
<ButtonLoadMore
|
<ButtonLoadMore
|
||||||
v-if="numUnreads"
|
v-if="numUnreads"
|
||||||
:label="`Load ${numUnreads} unread`"
|
:label="`Load ${numUnreads} unread`"
|
||||||
@ -78,15 +78,15 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
availableFeeds: [Feeds.GLOBAL],
|
availableFeeds: ['global'],
|
||||||
selectedFeed: Feeds.GLOBAL,
|
selectedFeed: 'global',
|
||||||
feeds: {},
|
feeds: {},
|
||||||
initialLoadComplete: false,
|
recentlyLoaded: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
activeFeed() {
|
activeFeed() {
|
||||||
return this.feeds[this.selectedFeed.name]
|
return this.feeds[this.selectedFeed]
|
||||||
},
|
},
|
||||||
feedItems() {
|
feedItems() {
|
||||||
return this.activeFeed?.items
|
return this.activeFeed?.items
|
||||||
@ -95,15 +95,15 @@ export default defineComponent({
|
|||||||
return this.activeFeed?.unreads
|
return this.activeFeed?.unreads
|
||||||
},
|
},
|
||||||
numUnreads() {
|
numUnreads() {
|
||||||
if (!this.initialLoadComplete) return 0
|
if (this.recentlyLoaded) return 0
|
||||||
return this.activeFeed?.unreads.length
|
return this.activeFeed?.unreads.length
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initFeed(feed) {
|
initFeed(feedId) {
|
||||||
if (this.feeds[feed.name]) return
|
if (this.feeds[feedId]) return
|
||||||
|
|
||||||
this.feeds[feed.name] = {
|
this.feeds[feedId] = {
|
||||||
items: [],
|
items: [],
|
||||||
unreads: [],
|
unreads: [],
|
||||||
}
|
}
|
||||||
@ -111,34 +111,39 @@ export default defineComponent({
|
|||||||
let initialFetchComplete = false
|
let initialFetchComplete = false
|
||||||
let initialItems = []
|
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(
|
this.nostr.streamFeed(
|
||||||
feed,
|
Feeds[feedId.toUpperCase()],
|
||||||
event => {
|
event => {
|
||||||
const target = initialFetchComplete
|
const target = initialFetchComplete
|
||||||
? this.feeds[feed.name].unreads
|
? this.feeds[feedId].unreads
|
||||||
: initialItems
|
: initialItems
|
||||||
target.push([event]) // FIXME Single element thread
|
target.push([event]) // FIXME Single element thread
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
initialItems.sort(feedOrder)
|
initialItems.sort(feedOrder)
|
||||||
this.feeds[feed.name].items = initialItems
|
this.feeds[feedId].items = initialItems
|
||||||
initialFetchComplete = true
|
initialFetchComplete = true
|
||||||
|
|
||||||
// Wait a bit before showing the first unreads
|
// Wait a bit before showing the first unreads
|
||||||
setTimeout(() => this.initialLoadComplete = true, 5000)
|
setTimeout(() => this.recentlyLoaded = false, 5000)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
switchFeed(feed) {
|
switchFeed(feedId) {
|
||||||
this.initFeed(feed)
|
this.initFeed(feedId)
|
||||||
this.selectedFeed = feed
|
this.selectedFeed = feedId
|
||||||
},
|
},
|
||||||
loadUnreads() {
|
loadUnreads() {
|
||||||
const items = this.feedUnreads.concat(this.feedItems)
|
const items = this.feedUnreads.concat(this.feedItems)
|
||||||
items.sort(feedOrder)
|
items.sort(feedOrder)
|
||||||
this.activeFeed.items = items
|
this.activeFeed.items = items
|
||||||
this.activeFeed.unreads = []
|
this.activeFeed.unreads = []
|
||||||
|
|
||||||
|
// Wait a bit before showing unreads again
|
||||||
|
this.recentlyLoaded = true
|
||||||
|
setTimeout(() => this.recentlyLoaded = false, 5000)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@ -166,6 +171,9 @@ export default defineComponent({
|
|||||||
.addon-menu {
|
.addon-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
|
&-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
&-popup {
|
&-popup {
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
@ -3,9 +3,18 @@
|
|||||||
<PageHeader back-button />
|
<PageHeader back-button />
|
||||||
|
|
||||||
<div class="profile-header">
|
<div class="profile-header">
|
||||||
<UserAvatar :pubkey="pubkey" />
|
<UserAvatar :pubkey="pubkey" class="profile-header-avatar" />
|
||||||
<div class="profile-header-content">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -18,7 +27,8 @@
|
|||||||
:breakpoint="0"
|
:breakpoint="0"
|
||||||
>
|
>
|
||||||
<q-tab name="posts" label="Posts" />
|
<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="followers" label="Followers" />
|
||||||
<q-tab name="relays" label="Relays" />
|
<q-tab name="relays" label="Relays" />
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
@ -34,6 +44,31 @@
|
|||||||
actions
|
actions
|
||||||
/>
|
/>
|
||||||
</q-tab-panel>
|
</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-tab-panels>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
@ -44,6 +79,7 @@ import PageHeader from 'components/PageHeader.vue'
|
|||||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||||
import UserName from 'components/User/UserName.vue'
|
import UserName from 'components/User/UserName.vue'
|
||||||
import ListPost from 'components/Post/ListPost.vue'
|
import ListPost from 'components/Post/ListPost.vue'
|
||||||
|
import UserCard from 'components/User/UserCard.vue'
|
||||||
import {useAppStore} from 'stores/App'
|
import {useAppStore} from 'stores/App'
|
||||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||||
import {bech32ToHex} from 'src/utils/utils'
|
import {bech32ToHex} from 'src/utils/utils'
|
||||||
@ -51,6 +87,7 @@ import {bech32ToHex} from 'src/utils/utils'
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
components: {
|
components: {
|
||||||
|
UserCard,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
UserAvatar,
|
UserAvatar,
|
||||||
UserName,
|
UserName,
|
||||||
@ -71,12 +108,28 @@ export default defineComponent({
|
|||||||
pubkey() {
|
pubkey() {
|
||||||
return bech32ToHex(this.$route.params.pubkey)
|
return bech32ToHex(this.$route.params.pubkey)
|
||||||
},
|
},
|
||||||
posts() {
|
profile() {
|
||||||
|
return this.nostr.getProfile(this.pubkey)
|
||||||
|
},
|
||||||
|
notes() {
|
||||||
return this.nostr.getNotesByAuthor(this.pubkey)
|
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() {
|
mounted() {
|
||||||
this.nostr.fetchNotesByAuthor(this.pubkey)
|
this.nostr.fetchNotesByAuthor(this.pubkey)
|
||||||
|
this.nostr.fetchFollowers(this.pubkey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@ -85,9 +138,29 @@ export default defineComponent({
|
|||||||
.profile {
|
.profile {
|
||||||
&-header {
|
&-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
&-avatar {
|
||||||
|
height: 128px;
|
||||||
|
width: 128px;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
&-content {
|
||||||
|
.followers {
|
||||||
|
span + span {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&-tab-panels {
|
&-tab-panels {
|
||||||
background-color: unset;
|
background-color: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<style lang="scss">
|
||||||
|
.profile-header-content .username {
|
||||||
|
.name, .pubkey:first-child {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -1,44 +1,22 @@
|
|||||||
import {defineStore} from 'pinia'
|
import {defineStore} from 'pinia'
|
||||||
import {getEventHash, getPublicKey, signEvent} from 'nostr-tools'
|
import {Account} from 'src/nostr/Account'
|
||||||
import Nip07 from 'src/utils/Nip07'
|
|
||||||
|
|
||||||
export class Account {
|
const RELAYS = [
|
||||||
constructor(opts) {
|
'wss://nostr-pub.wellorder.net',
|
||||||
this.pubkey = opts.pubkey || null
|
'wss://nostr.onsats.org',
|
||||||
this.privkey = opts.privkey || null
|
'wss://nostr-relay.wlvs.space',
|
||||||
this.useExtension = opts.useExtension || false
|
'wss://nostr.bitcoiner.social',
|
||||||
|
'wss://relay.damus.io',
|
||||||
if (this.privkey && !this.pubkey) {
|
'wss://nostr.zebedee.cloud',
|
||||||
this.pubkey = getPublicKey(this.privkey)
|
'wss://relay.nostr.info',
|
||||||
}
|
'wss://nostr-pub.semisol.dev',
|
||||||
|
]
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', {
|
export const useSettingsStore = defineStore('settings', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
accounts: {},
|
accounts: {},
|
||||||
pubkey: null,
|
pubkey: null,
|
||||||
relays: []
|
relays: RELAYS,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
activeAccount(state) {
|
activeAccount(state) {
|
||||||
|
Loading…
Reference in New Issue
Block a user