* Add following feed

* Improve feed performance
This commit is contained in:
styppo 2023-01-17 03:10:06 +00:00
parent 7bdc8bd867
commit 171f53b5a1
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
5 changed files with 277 additions and 228 deletions

View File

@ -0,0 +1,193 @@
<template>
<div class="feed">
<div class="load-more-container" :class="{'more-available': numUnreads}">
<AsyncLoadButton
v-if="numUnreads"
:load-fn="loadNewer"
:label="`Load ${numUnreads} unread`"
/>
</div>
<Thread v-for="thread in visible" :key="thread[0].id" :thread="thread" class="full-width" />
<ListPlaceholder :count="visible.length" :loading="loading" />
<AsyncLoadButton
v-if="visible.length"
:load-fn="loadOlder"
autoload
/>
</div>
</template>
<script>
import AsyncLoadButton from 'components/AsyncLoadButton.vue'
import Thread from 'components/Post/Thread.vue'
import ListPlaceholder from 'components/ListPlaceholder.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import DateUtils from 'src/utils/DateUtils'
import Bots from 'src/utils/bots'
const feedOrder = (a, b) => b[0].createdAt - a[0].createdAt
const MAX_ITEMS_VISIBLE = 25
export default {
name: 'Feed',
components: {
ListPlaceholder,
Thread,
AsyncLoadButton
},
props: {
feed: {
type: Object,
required: true,
}
},
setup() {
return {
app: useAppStore(),
nostr: useNostrStore(),
}
},
data() {
return {
visible: [],
newer: [],
older: [],
stream: null,
loading: true,
recentlyLoaded: true,
}
},
computed: {
numUnreads() {
if (this.recentlyLoaded) return 0
return this.newer.length
},
},
methods: {
init() {
const filters = typeof this.feed.filters === 'function'
? this.feed.filters()
: this.feed.filters
this.stream = this.nostr.stream(
filters,
{subId: `feed:${this.feed.name}`}
)
this.stream.on('init', notes => {
const items = notes
.filter(note => this.filterNote(note, this.feed.hideBots))
.map(note => [note]) // TODO Single element thread
items.sort(feedOrder)
this.visible = items.slice(0, filters.limit)
this.loading = false
// Wait a bit before showing the first unreads
setTimeout(() => this.recentlyLoaded = false, 5000)
})
this.stream.on('update', note => {
if (this.filterNote(note, this.feed.hideBots)) {
this.newer.push([note]) // TODO Single element thread
}
})
},
reload() {
if (!this.stream) return
this.loading = true
this.stream.close()
this.init()
},
loadNewer() {
// TODO Deduplicate feed items
this.newer.sort(feedOrder)
const items = this.newer.concat(this.visible)
if (items.length > MAX_ITEMS_VISIBLE) {
const older = items.splice(MAX_ITEMS_VISIBLE)
this.older = older.concat(this.older)
}
//items.sort(feedOrder)
this.visible = items
this.newer = []
// Wait a bit before showing unreads again
this.recentlyLoaded = true
setTimeout(() => this.recentlyLoaded = false, 5000)
return true
},
async loadOlder() {
const feedFilters = typeof this.feed.filters === 'function'
? this.feed.filters()
: this.feed.filters
const until = this.visible[this.visible.length - 1]?.[0]?.createdAt || DateUtils.now()
const filters = Object.assign({}, feedFilters, {until})
if (this.older.length >= filters.limit) {
const chunk = this.older.splice(0, filters.limit)
this.visible = this.visible.concat(chunk)
return chunk
}
// Remove any residual older items
this.older = []
const older = await this.nostr.fetch(filters, {subId: `feed:${this.feed.name}-older`})
const items = older
.filter(note => note.createdAt <= until)
.filter(note => this.filterNote(note, this.feed.hideBots))
.map(note => [note]) // TODO Single element thread
.sort(feedOrder)
// TODO Deduplicate feed items
this.visible = this.visible.concat(items)
return items
},
filterNote(note, hideBots) {
if (note.isReaction()) return false
if (note.isRepostOrTag()) return false
if (hideBots && note.relatedPubkeys().some(Bots.isBot)) return false
return true
}
},
mounted() {
this.init()
},
unmounted() {
if (this.stream) this.stream.close()
}
}
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
@import "assets/variables.scss";
.feed {
.load-more-container {
border-top: $border-dark;
border-bottom: $border-dark;
min-height: 6px;
}
> .async-load-button:last-child {
border-bottom: 0;
}
}
@media screen and (max-width: $phone) {
.feed {
.load-more-container {
border: 0;
min-height: 0;
&.more-available {
border-bottom: $border-dark;
}
}
}
}
</style>

View File

@ -108,6 +108,7 @@ export default defineComponent({
} }
.addon { .addon {
flex-grow: 1; flex-grow: 1;
text-align: right;
} }
} }

View File

@ -66,7 +66,7 @@ export default defineComponent({
}, },
data() { data() {
return { return {
cachedPages: ['Feed', 'Notifications', 'Messages', 'Inbox', 'Settings'], cachedPages: ['FeedPage', 'Notifications', 'Messages', 'Inbox', 'Settings'],
mobileMenuOpen: false, mobileMenuOpen: false,
} }
}, },

View File

@ -6,7 +6,7 @@ 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 {useContactStore} from 'src/nostr/store/ContactStore' import {useContactStore} from 'src/nostr/store/ContactStore'
import {useSettingsStore} from 'stores/Settings' import {useSettingsStore, RELAYS} from 'stores/Settings'
import {useStatStore} from 'src/nostr/store/StatStore' import {useStatStore} from 'src/nostr/store/StatStore'
import {Observable} from 'src/nostr/utils' import {Observable} from 'src/nostr/utils'
import {CloseAfter} from 'src/nostr/Relay' import {CloseAfter} from 'src/nostr/Relay'
@ -61,7 +61,8 @@ export const useNostrStore = defineStore('nostr', {
actions: { actions: {
init() { init() {
const settings = useSettingsStore() const settings = useSettingsStore()
this.client = markRaw(new NostrClient(settings.relays)) // FIXME Use relays from settings
this.client = markRaw(new NostrClient(RELAYS))
this.client.connect() this.client.connect()
this.profileQueue = profileQueue(this.client) this.profileQueue = profileQueue(this.client)
@ -172,8 +173,8 @@ export const useNostrStore = defineStore('nostr', {
{ {
kinds: [EventKind.NOTE], kinds: [EventKind.NOTE],
authors: [pubkey], authors: [pubkey],
}, limit,
limit }
) )
}, },
@ -191,14 +192,13 @@ export const useNostrStore = defineStore('nostr', {
return followers return followers
}, },
fetchFollowers(pubkey, opts = {}) { fetchFollowers(pubkey, limit = 500) {
const limit = opts.limit || 500
return this.fetch( return this.fetch(
{ {
kinds: [EventKind.CONTACT], kinds: [EventKind.CONTACT],
'#p': [pubkey], '#p': [pubkey],
limit,
}, },
limit
) )
}, },
@ -231,8 +231,8 @@ export const useNostrStore = defineStore('nostr', {
{ {
kinds: [EventKind.REACTION], kinds: [EventKind.REACTION],
authors: [pubkey], authors: [pubkey],
}, limit,
limit }
) )
}, },

View File

@ -2,26 +2,18 @@
<q-page> <q-page>
<PageHeader logo class="page-header"> <PageHeader logo class="page-header">
<template #addon> <template #addon>
<div class="addon-menu"> <q-btn-toggle
<div class="addon-menu-icon"> v-if="availableFeeds.length > 1"
<q-icon name="more_vert" size="sm" /> v-model="activeFeed"
</div> rounded
<q-menu target=".addon-menu-icon" anchor="top left" self="top right" class="addon-menu-popup"> toggle-color="primary"
<div> size="sm"
<div class="feed-selector"
v-for="feed in availableFeeds" :options="[
:key="feed" {value: 'global', icon: 'public'},
@click="switchFeed(feed)" {value: 'following', icon: 'group'},
class="popup-header" ]"
v-close-popup> />
<p>{{ feed }}</p>
<div v-if="feed === selectedFeed" class="more">
<BaseIcon icon="tick" />
</div>
</div>
</div>
</q-menu>
</div>
</template> </template>
</PageHeader> </PageHeader>
@ -29,27 +21,11 @@
<PostEditor /> <PostEditor />
</div> </div>
<div class="feed"> <q-tab-panels v-model="activeFeed" keep-alive animated>
<div class="load-more-container" :class="{'more-available': numUnreads}"> <q-tab-panel v-for="feed in availableFeeds" :key="feed" :name="feed">
<AsyncLoadButton <Feed :feed="feedDef(feed)" :ref="feed" />
v-if="numUnreads" </q-tab-panel>
:load-fn="loadNewer" </q-tab-panels>
:label="`Load ${numUnreads} unreads`"
/>
</div>
<template v-for="(thread, i) in feedItems">
<Thread v-if="true || defer(i)" :key="thread[0].id" :thread="thread" class="full-width" />
</template>
<ListPlaceholder :count="feedItems?.length" :loading="loading" />
<AsyncLoadButton
v-if="feedItems?.length"
:load-fn="loadOlder"
autoload
/>
</div>
</q-page> </q-page>
</template> </template>
@ -57,16 +33,12 @@
import {defineComponent} from 'vue' import {defineComponent} from 'vue'
import PageHeader from 'components/PageHeader.vue' import PageHeader from 'components/PageHeader.vue'
import PostEditor from 'components/CreatePost/PostEditor.vue' import PostEditor from 'components/CreatePost/PostEditor.vue'
import Thread from 'components/Post/Thread.vue' import Feed from 'components/Feed/Feed.vue'
import BaseIcon from 'components/BaseIcon/index.vue'
import AsyncLoadButton from 'components/AsyncLoadButton.vue'
import ListPlaceholder from 'components/ListPlaceholder.vue'
import {useAppStore} from 'stores/App' import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore' import {useNostrStore} from 'src/nostr/NostrStore'
import Defer from 'src/utils/Defer'
import {EventKind} from 'src/nostr/model/Event' import {EventKind} from 'src/nostr/model/Event'
import DateUtils from 'src/utils/DateUtils'
import Bots from 'src/utils/bots' const ZERO_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000000'
const Feeds = { const Feeds = {
global: { global: {
@ -75,24 +47,33 @@ const Feeds = {
kinds: [EventKind.NOTE], // TODO Deletions kinds: [EventKind.NOTE], // TODO Deletions
limit: 20, limit: 20,
}, },
hideBots: true,
},
following: {
name: 'following',
filters: () => {
const app = useAppStore()
const nostr = useNostrStore()
const contacts = nostr.getContacts(app.myPubkey)
let authors = contacts?.map(contact => contact.pubkey)
if (!authors || !authors.length) authors = [ZERO_PUBKEY]
return {
authors,
kinds: [EventKind.NOTE],
limit: 50,
}
},
hideBots: false,
}, },
} }
const feedOrder = (a, b) => b[0].createdAt - a[0].createdAt
const MAX_ITEMS_VISIBLE = 50
export default defineComponent({ export default defineComponent({
name: 'Feed', name: 'FeedPage',
components: { components: {
PageHeader, PageHeader,
PostEditor, PostEditor,
Thread, Feed,
BaseIcon,
AsyncLoadButton,
ListPlaceholder,
}, },
mixins: [Defer(2000)],
setup() { setup() {
return { return {
app: useAppStore(), app: useAppStore(),
@ -102,120 +83,31 @@ export default defineComponent({
data() { data() {
return { return {
feeds: {}, feeds: {},
availableFeeds: ['global'], activeFeed: 'global',
selectedFeed: 'global',
loading: true,
recentlyLoaded: true,
} }
}, },
computed: { computed: {
activeFeed() { availableFeeds() {
return this.feeds[this.selectedFeed] const feeds = ['global']
if (this.app.isSignedIn) feeds.push('following')
return feeds
}, },
feedItems() { contacts() {
return this.activeFeed?.visible if (!this.app.isSignedIn) return
}, return this.nostr.getContacts(this.app.myPubkey)
numUnreads() {
if (this.recentlyLoaded) return 0
return this.activeFeed?.newer.length
}, },
}, },
methods: { methods: {
initFeed(feedId) { feedDef(feed) {
if (this.feeds[feedId]) return return Feeds[feed]
const filters = Feeds[feedId].filters
const stream = this.nostr.stream(
filters,
{subId: `feed:${feedId}`}
)
stream.on('init', notes => {
const items = notes
.filter(this.filterNote.bind(this))
.map(note => [note]) // TODO Single element thread
items.sort(feedOrder)
this.feeds[feedId].visible = items.slice(0, filters.limit)
this.loading = false
// Wait a bit before showing the first unreads
setTimeout(() => this.recentlyLoaded = false, 5000)
})
stream.on('update', note => {
if (this.filterNote(note)) {
this.feeds[feedId].newer.push([note]) // TODO Single element thread
}
})
this.feeds[feedId] = {
visible: [],
newer: [],
older: [],
stream,
}
}, },
switchFeed(feedId) {
this.initFeed(feedId)
this.selectedFeed = feedId
}, },
loadNewer() { watch: {
// TODO Deduplicate feed items contacts() {
this.activeFeed.newer.sort(feedOrder) console.log('following', this.$refs.following)
const items = this.activeFeed.newer.concat(this.feedItems) this.$refs.following?.[0]?.reload()
if (items.length > MAX_ITEMS_VISIBLE) {
const older = items.splice(MAX_ITEMS_VISIBLE)
this.activeFeed.older = older.concat(this.activeFeed.older)
}
//items.sort(feedOrder)
this.activeFeed.visible = items
this.activeFeed.newer = []
// Wait a bit before showing unreads again
this.recentlyLoaded = true
setTimeout(() => this.recentlyLoaded = false, 5000)
return true
}, },
async loadOlder() {
const until = this.feedItems[this.feedItems.length - 1]?.[0]?.createdAt || DateUtils.now()
const filters = Object.assign({}, Feeds[this.selectedFeed].filters, {until})
if (this.activeFeed.older.length >= filters.limit) {
const chunk = this.activeFeed.older.splice(0, filters.limit)
this.activeFeed.visible = this.feedItems.concat(chunk)
return chunk
}
// Remove any residual older items
this.activeFeed.older = []
const older = await this.nostr.fetch(filters, {subId: `feed:${this.selectedFeed}-older`})
const items = older
.filter(note => note.createdAt <= until)
.filter(this.filterNote.bind(this))
.map(note => [note]) // TODO Single element thread
.sort(feedOrder)
// TODO Deduplicate feed items
this.activeFeed.visible = this.feedItems.concat(items)
return older
}, },
filterNote(note) {
if (note.isReaction()) return false
if (note.isRepostOrTag()) return false
if (note.relatedPubkeys().some(Bots.isBot)) return false
return true
}
},
mounted() {
this.initFeed(this.selectedFeed)
},
unmounted() {
for (const feed of Object.values(this.feeds)) {
feed.stream.close()
}
}
}) })
</script> </script>
@ -223,23 +115,9 @@ export default defineComponent({
@import "assets/theme/colors.scss"; @import "assets/theme/colors.scss";
@import "assets/variables.scss"; @import "assets/variables.scss";
.feed { .feed-selector {
.load-more-container { background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
border-top: $border-dark; //box-shadow: none;
border-bottom: $border-dark;
min-height: 6px;
}
> .async-load-button:last-child {
border-bottom: 0;
}
}
.addon-menu {
display: flex;
flex-direction: row-reverse;
&-icon {
cursor: pointer;
}
} }
@media screen and (max-width: $phone) { @media screen and (max-width: $phone) {
@ -249,50 +127,27 @@ export default defineComponent({
.post-editor { .post-editor {
display: none; display: none;
} }
.feed {
.load-more-container {
border: 0;
min-height: 0;
&.more-available {
border-bottom: $border-dark;
}
}
}
} }
</style> </style>
<style lang="scss"> <style lang="scss">
@import "assets/theme/colors.scss"; @import "assets/theme/colors.scss";
.addon-menu-popup { .q-tab-panels {
min-width: 150px; background: unset;
border-radius: 1rem;
padding: 10px;
background-color: $color-bg;
box-shadow: $shadow-white;
.popup-header {
display: flex;
width: 100%;
padding: 8px;
cursor: pointer;
border-radius: .5rem;
p {
margin: 0;
flex-grow: 1;
font-size: 1.1em;
font-weight: bold;
text-transform: capitalize;
} }
&:hover { .q-tab-panel {
background-color: rgba($color: $color-dark-gray, $alpha: 0.3); padding: 0;
} }
.more { .q-panel.scroll {
width: 1.5rem; overflow: inherit;
height: 1.5rem;
svg {
fill: $color-primary;
width: 100%;
} }
.feed-selector {
button:first-child {
padding: 4px 10px 4px 12px;
} }
button:last-child {
padding: 4px 12px 4px 10px;
} }
} }
</style> </style>