for you feed etc
This commit is contained in:
parent
2ab2001014
commit
35ec58377c
@ -18,7 +18,8 @@
|
|||||||
"notificationGraph": true,
|
"notificationGraph": true,
|
||||||
"communityLeaders": true,
|
"communityLeaders": true,
|
||||||
"nostrAddress": true,
|
"nostrAddress": true,
|
||||||
"pushNotifications": true
|
"pushNotifications": true,
|
||||||
|
"forYouFeed": false
|
||||||
},
|
},
|
||||||
"signUp": {
|
"signUp": {
|
||||||
"moderation": true,
|
"moderation": true,
|
||||||
|
@ -16,7 +16,8 @@
|
|||||||
"deck": true,
|
"deck": true,
|
||||||
"zapPool": true,
|
"zapPool": true,
|
||||||
"notificationGraph": false,
|
"notificationGraph": false,
|
||||||
"communityLeaders": true
|
"communityLeaders": true,
|
||||||
|
"forYouFeed": true
|
||||||
},
|
},
|
||||||
"defaultPreferences": {
|
"defaultPreferences": {
|
||||||
"hideMutedNotes": true
|
"hideMutedNotes": true
|
||||||
@ -45,7 +46,10 @@
|
|||||||
"wss://relay.nostr.band/": { "read": true, "write": true },
|
"wss://relay.nostr.band/": { "read": true, "write": true },
|
||||||
"wss://relay.damus.io/": { "read": true, "write": true }
|
"wss://relay.damus.io/": { "read": true, "write": true }
|
||||||
},
|
},
|
||||||
"chatChannels": [{ "type": "telegram", "value": "https://t.me/irismessenger" }],
|
"chatChannels": [
|
||||||
|
{ "type": "telegram", "value": "https://t.me/irismessenger" },
|
||||||
|
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
|
||||||
|
],
|
||||||
"alby": {
|
"alby": {
|
||||||
"clientId": "5rYcHDrlDb",
|
"clientId": "5rYcHDrlDb",
|
||||||
"clientSecret": "QAI3QmgiaPH3BfTMzzFd"
|
"clientSecret": "QAI3QmgiaPH3BfTMzzFd"
|
||||||
|
1
packages/app/custom.d.ts
vendored
1
packages/app/custom.d.ts
vendored
@ -61,6 +61,7 @@ declare const CONFIG: {
|
|||||||
communityLeaders: boolean;
|
communityLeaders: boolean;
|
||||||
nostrAddress: boolean;
|
nostrAddress: boolean;
|
||||||
pushNotifications: boolean;
|
pushNotifications: boolean;
|
||||||
|
forYouFeed: boolean;
|
||||||
};
|
};
|
||||||
defaultPreferences: {
|
defaultPreferences: {
|
||||||
hideMutedNotes: boolean;
|
hideMutedNotes: boolean;
|
||||||
|
@ -5,6 +5,7 @@ import Icon from "@/Components/Icons/Icon";
|
|||||||
import { Newest } from "@/Utils/Login";
|
import { Newest } from "@/Utils/Login";
|
||||||
|
|
||||||
export type RootTab =
|
export type RootTab =
|
||||||
|
| "for-you"
|
||||||
| "following"
|
| "following"
|
||||||
| "followed-by-friends"
|
| "followed-by-friends"
|
||||||
| "conversations"
|
| "conversations"
|
||||||
@ -16,6 +17,17 @@ export type RootTab =
|
|||||||
|
|
||||||
export function rootTabItems(base: string, pubKey: string | undefined, tags: Newest<Array<string>>) {
|
export function rootTabItems(base: string, pubKey: string | undefined, tags: Newest<Array<string>>) {
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
|
{
|
||||||
|
tab: "for-you",
|
||||||
|
path: `${base}/for-you`,
|
||||||
|
show: Boolean(pubKey) && CONFIG.features.forYouFeed,
|
||||||
|
element: (
|
||||||
|
<>
|
||||||
|
<Icon name="user-v2" />
|
||||||
|
<FormattedMessage defaultMessage="For you" id="xEjBS7" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tab: "following",
|
tab: "following",
|
||||||
path: `${base}/notes`,
|
path: `${base}/notes`,
|
||||||
|
@ -25,7 +25,7 @@ export function RootTabs({ base = "/" }: { base: string }) {
|
|||||||
|
|
||||||
const defaultTab = pubKey ? preferences.defaultRootTab ?? `${base}/notes` : `${base}/trending/notes`;
|
const defaultTab = pubKey ? preferences.defaultRootTab ?? `${base}/notes` : `${base}/trending/notes`;
|
||||||
const initialPathname = location.pathname === "/" ? defaultTab : location.pathname;
|
const initialPathname = location.pathname === "/" ? defaultTab : location.pathname;
|
||||||
const initialRootType = menuItems.find(a => a.path === initialPathname)?.tab || "following";
|
const initialRootType = menuItems.find(a => a.path === initialPathname)?.tab || "for-you";
|
||||||
|
|
||||||
const [rootType, setRootType] = useState<RootTab>(initialRootType);
|
const [rootType, setRootType] = useState<RootTab>(initialRootType);
|
||||||
|
|
||||||
|
@ -1,245 +0,0 @@
|
|||||||
import LRUSet from "@snort/shared/src/LRUSet";
|
|
||||||
import { ReqFilter as Filter, TaggedNostrEvent } from "@snort/system";
|
|
||||||
import * as Comlink from "comlink";
|
|
||||||
import Dexie, { Table } from "dexie";
|
|
||||||
|
|
||||||
type Tag = {
|
|
||||||
id: string;
|
|
||||||
eventId: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SaveQueueEntry = { event: TaggedNostrEvent; tags: Tag[] };
|
|
||||||
type Task = () => Promise<void>;
|
|
||||||
|
|
||||||
class IndexedDB extends Dexie {
|
|
||||||
events!: Table<TaggedNostrEvent>;
|
|
||||||
tags!: Table<Tag>;
|
|
||||||
private saveQueue: SaveQueueEntry[] = [];
|
|
||||||
private subscribedEventIds = new Set<string>();
|
|
||||||
private subscribedAuthors = new Set<string>();
|
|
||||||
private subscribedTags = new Set<string>();
|
|
||||||
private subscribedAuthorsAndKinds = new Set<string>();
|
|
||||||
private readQueue: Map<string, Task> = new Map();
|
|
||||||
private isProcessingQueue = false;
|
|
||||||
private seenEvents = new LRUSet(2000);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super("EventDB");
|
|
||||||
|
|
||||||
this.version(6).stores({
|
|
||||||
// TODO use multientry index for *tags
|
|
||||||
events: "++id, pubkey, kind, created_at, [pubkey+kind]",
|
|
||||||
tags: "&[type+value+eventId], [type+value], eventId",
|
|
||||||
});
|
|
||||||
|
|
||||||
this.startInterval();
|
|
||||||
}
|
|
||||||
|
|
||||||
private startInterval() {
|
|
||||||
const processQueue = async () => {
|
|
||||||
if (this.saveQueue.length > 0) {
|
|
||||||
try {
|
|
||||||
const eventsToSave: TaggedNostrEvent[] = [];
|
|
||||||
const tagsToSave: Tag[] = [];
|
|
||||||
for (const item of this.saveQueue) {
|
|
||||||
eventsToSave.push(item.event);
|
|
||||||
tagsToSave.push(...item.tags);
|
|
||||||
}
|
|
||||||
await this.events.bulkPut(eventsToSave);
|
|
||||||
await this.tags.bulkPut(tagsToSave);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
this.saveQueue = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTimeout(() => processQueue(), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
setTimeout(() => processQueue(), 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEvent(event: TaggedNostrEvent) {
|
|
||||||
if (this.seenEvents.has(event.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.seenEvents.add(event.id);
|
|
||||||
|
|
||||||
// maybe we don't want event.kind 3 tags
|
|
||||||
const tags =
|
|
||||||
event.kind === 3
|
|
||||||
? []
|
|
||||||
: event.tags
|
|
||||||
?.filter(tag => {
|
|
||||||
if (tag[0] === "d") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (tag[0] === "e") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// we're only interested in p tags where we are mentioned
|
|
||||||
/*
|
|
||||||
if (tag[0] === "p") {
|
|
||||||
Key.isMine(tag[1])) { // TODO
|
|
||||||
return true;
|
|
||||||
}*/
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.map(tag => ({
|
|
||||||
eventId: event.id,
|
|
||||||
type: tag[0],
|
|
||||||
value: tag[1],
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
this.saveQueue.push({ event, tags });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async startReadQueue() {
|
|
||||||
if (!this.isProcessingQueue && this.readQueue.size > 0) {
|
|
||||||
this.isProcessingQueue = true;
|
|
||||||
for (const [key, task] of this.readQueue.entries()) {
|
|
||||||
this.readQueue.delete(key); // allow to re-queue right away
|
|
||||||
console.log("starting idb task", key);
|
|
||||||
console.time(key);
|
|
||||||
await task();
|
|
||||||
console.timeEnd(key);
|
|
||||||
}
|
|
||||||
this.isProcessingQueue = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enqueueRead(key: string, task: () => Promise<void>) {
|
|
||||||
this.readQueue.set(key, task);
|
|
||||||
this.startReadQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
getByAuthors = async (callback: (event: TaggedNostrEvent) => void, limit?: number) => {
|
|
||||||
this.enqueueRead("getByAuthors", async () => {
|
|
||||||
const authors = [...this.subscribedAuthors];
|
|
||||||
this.subscribedAuthors.clear();
|
|
||||||
|
|
||||||
await this.events
|
|
||||||
.where("pubkey")
|
|
||||||
.anyOf(authors)
|
|
||||||
.limit(limit || 1000)
|
|
||||||
.each(callback);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
getByEventIds = async (callback: (event: TaggedNostrEvent) => void) => {
|
|
||||||
this.enqueueRead("getByEventIds", async () => {
|
|
||||||
const ids = [...this.subscribedEventIds];
|
|
||||||
this.subscribedEventIds.clear();
|
|
||||||
await this.events.where("id").anyOf(ids).each(callback);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
getByTags = async (callback: (event: TaggedNostrEvent) => void) => {
|
|
||||||
this.enqueueRead("getByTags", async () => {
|
|
||||||
const tagPairs = [...this.subscribedTags].map(tag => tag.split("|"));
|
|
||||||
this.subscribedTags.clear();
|
|
||||||
|
|
||||||
await this.tags
|
|
||||||
.where("[type+value]")
|
|
||||||
.anyOf(tagPairs)
|
|
||||||
.each(tag => this.subscribedEventIds.add(tag.eventId));
|
|
||||||
|
|
||||||
await this.getByEventIds(callback);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
getByAuthorsAndKinds = async (callback: (event: TaggedNostrEvent) => void) => {
|
|
||||||
this.enqueueRead("authorsAndKinds", async () => {
|
|
||||||
const authorsAndKinds = [...this.subscribedAuthorsAndKinds];
|
|
||||||
this.subscribedAuthorsAndKinds.clear();
|
|
||||||
const pairs = authorsAndKinds.map(pair => {
|
|
||||||
const [author, kind] = pair.split("|");
|
|
||||||
return [author, parseInt(kind)];
|
|
||||||
});
|
|
||||||
await this.events.where("[pubkey+kind]").anyOf(pairs).each(callback);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
async find(filter: Filter, callback: (event: TaggedNostrEvent) => void): Promise<void> {
|
|
||||||
if (!filter) return;
|
|
||||||
|
|
||||||
const filterString = JSON.stringify(filter);
|
|
||||||
if (this.readQueue.has(filterString)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure only 1 argument is passed
|
|
||||||
const cb = e => {
|
|
||||||
this.seenEvents.add(e.id);
|
|
||||||
if (filter.not?.ids?.includes(e.id)) {
|
|
||||||
console.log("skipping", e.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
callback(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
let hasTags = false;
|
|
||||||
for (const key in filter) {
|
|
||||||
if (key.startsWith("#")) {
|
|
||||||
hasTags = true;
|
|
||||||
const tagName = key.slice(1); // Remove the hash to get the tag name
|
|
||||||
const values = filter[key];
|
|
||||||
if (Array.isArray(values)) {
|
|
||||||
for (const value of values) {
|
|
||||||
this.subscribedTags.add(tagName + "|" + value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTags) {
|
|
||||||
await this.getByTags(cb);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.ids?.length) {
|
|
||||||
filter.ids.forEach(id => this.subscribedEventIds.add(id));
|
|
||||||
await this.getByEventIds(cb);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.authors?.length && filter.kinds?.length) {
|
|
||||||
const permutations = filter.authors.flatMap(author => filter.kinds!.map(kind => author + "|" + kind));
|
|
||||||
permutations.forEach(permutation => this.subscribedAuthorsAndKinds.add(permutation));
|
|
||||||
await this.getByAuthorsAndKinds(cb);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.authors?.length) {
|
|
||||||
filter.authors.forEach(author => this.subscribedAuthors.add(author));
|
|
||||||
await this.getByAuthors(cb);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = this.events;
|
|
||||||
if (filter.kinds) {
|
|
||||||
query = query.where("kind").anyOf(filter.kinds);
|
|
||||||
}
|
|
||||||
if (filter.search) {
|
|
||||||
const term = filter.search.replace(" sort:popular", "");
|
|
||||||
if (term === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const regexp = new RegExp(term, "i");
|
|
||||||
query = query.filter((event: Event) => event.content?.match(regexp));
|
|
||||||
}
|
|
||||||
if (filter.limit) {
|
|
||||||
query = query.limit(filter.limit);
|
|
||||||
}
|
|
||||||
// TODO test that the sort is actually working
|
|
||||||
this.enqueueRead(filterString, async () => {
|
|
||||||
await query.each(cb);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new IndexedDB();
|
|
||||||
|
|
||||||
Comlink.expose(db);
|
|
107
packages/app/src/Db/getForYouFeed.ts
Normal file
107
packages/app/src/Db/getForYouFeed.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { NostrEvent, parseZap } from "@snort/system";
|
||||||
|
|
||||||
|
import { Relay } from "@/Cache";
|
||||||
|
|
||||||
|
export async function getForYouFeed(pubkey: string): Promise<NostrEvent[]> {
|
||||||
|
console.time("For You feed generation time");
|
||||||
|
|
||||||
|
console.log("pubkey", pubkey);
|
||||||
|
|
||||||
|
// Get events reacted to by me
|
||||||
|
const myReactedEvents = await getMyReactedEventIds(pubkey);
|
||||||
|
console.log("my reacted events", myReactedEvents);
|
||||||
|
|
||||||
|
// Get others who reacted to the same events as me
|
||||||
|
const othersWhoReacted = await getOthersWhoReacted(myReactedEvents, pubkey);
|
||||||
|
// this tends to be small when the user has just logged in, we should maybe subscribe for more from relays
|
||||||
|
console.log("others who reacted", othersWhoReacted);
|
||||||
|
|
||||||
|
// Get event ids reacted to by those others
|
||||||
|
const reactedByOthers = await getEventIdsReactedByOthers(othersWhoReacted);
|
||||||
|
console.log("reacted by others", reactedByOthers);
|
||||||
|
|
||||||
|
// Get events reacted to by others that I haven't reacted to
|
||||||
|
const idsToFetch = Array.from(reactedByOthers).filter(id => !myReactedEvents.has(id));
|
||||||
|
console.log("ids to fetch", idsToFetch);
|
||||||
|
|
||||||
|
// Get full events in sorted order
|
||||||
|
const feed = await getFeedEvents(idsToFetch);
|
||||||
|
console.log("feed.length", feed.length);
|
||||||
|
|
||||||
|
console.timeEnd("For You feed generation time");
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMyReactedEventIds(pubkey: string) {
|
||||||
|
const myReactedEventIds = new Set<string>();
|
||||||
|
const myEvents = await Relay.query([
|
||||||
|
"REQ",
|
||||||
|
"getMyReactedEventIds",
|
||||||
|
{
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [1, 6, 7, 9735],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
myEvents.forEach(ev => {
|
||||||
|
const targetEventId = ev.kind === 9735 ? parseZap(ev).event?.id : ev.tags.find(tag => tag[0] === "e")?.[1];
|
||||||
|
if (targetEventId) {
|
||||||
|
myReactedEventIds.add(targetEventId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return myReactedEventIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOthersWhoReacted(myReactedEventIds: Set<string>, myPubkey: string) {
|
||||||
|
const othersWhoReacted = new Set<string>();
|
||||||
|
|
||||||
|
const otherReactions = await Relay.query([
|
||||||
|
"REQ",
|
||||||
|
"getOthersWhoReacted",
|
||||||
|
{
|
||||||
|
"#e": Array.from(myReactedEventIds),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
otherReactions.forEach(reaction => {
|
||||||
|
if (reaction.pubkey !== myPubkey) {
|
||||||
|
othersWhoReacted.add(reaction.pubkey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...othersWhoReacted];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEventIdsReactedByOthers(othersWhoReacted: string[]) {
|
||||||
|
const eventIdsReactedByOthers = new Set<string>();
|
||||||
|
|
||||||
|
const events = await Relay.query([
|
||||||
|
"REQ",
|
||||||
|
"getEventIdsReactedByOthers",
|
||||||
|
{
|
||||||
|
authors: othersWhoReacted,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
events.forEach(event => {
|
||||||
|
event.tags.forEach(tag => {
|
||||||
|
if (tag[0] === "e") eventIdsReactedByOthers.add(tag[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...eventIdsReactedByOthers];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFeedEvents(ids: string[]) {
|
||||||
|
return (await Relay.query([
|
||||||
|
"REQ",
|
||||||
|
"getFeedEvents",
|
||||||
|
{
|
||||||
|
ids,
|
||||||
|
kinds: [1],
|
||||||
|
},
|
||||||
|
])).filter((ev) => {
|
||||||
|
// no replies
|
||||||
|
return !ev.tags.some((tag) => tag[0] === "e");
|
||||||
|
}).sort((a, b) => b.created_at - a.created_at);
|
||||||
|
}
|
@ -6,7 +6,7 @@ export const DefaultTab = () => {
|
|||||||
preferences: s.appData.item.preferences,
|
preferences: s.appData.item.preferences,
|
||||||
publicKey: s.publicKey,
|
publicKey: s.publicKey,
|
||||||
}));
|
}));
|
||||||
const tab = publicKey ? preferences.defaultRootTab ?? `notes` : `trending/notes`;
|
const tab = publicKey ? preferences.defaultRootTab ?? `for-you` : `trending/notes`;
|
||||||
const elm = RootTabRoutes.find(a => a.path === tab)?.element;
|
const elm = RootTabRoutes.find(a => a.path === tab)?.element;
|
||||||
return elm;
|
return elm;
|
||||||
};
|
};
|
||||||
|
98
packages/app/src/Pages/Root/ForYouTab.tsx
Normal file
98
packages/app/src/Pages/Root/ForYouTab.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { TaggedNostrEvent } from "@snort/system";
|
||||||
|
import { memo, useEffect, useMemo, useState } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
|
||||||
|
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
||||||
|
import { TaskList } from "@/Components/Tasks/TaskList";
|
||||||
|
import { getForYouFeed } from "@/Db/getForYouFeed";
|
||||||
|
import useLogin from "@/Hooks/useLogin";
|
||||||
|
import messages from "@/Pages/messages";
|
||||||
|
import { System } from "@/system";
|
||||||
|
|
||||||
|
const FollowsHint = () => {
|
||||||
|
const { publicKey: pubKey, follows } = useLogin();
|
||||||
|
if (follows.item?.length === 0 && pubKey) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
{...messages.NoFollows}
|
||||||
|
values={{
|
||||||
|
newUsersPage: (
|
||||||
|
<Link to={"/discover"}>
|
||||||
|
<FormattedMessage {...messages.NewUsers} />
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let forYouFeed = {
|
||||||
|
events: [] as TaggedNostrEvent[],
|
||||||
|
created_at: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let getForYouFeedPromise: Promise<TaggedNostrEvent[]> | null = null;
|
||||||
|
|
||||||
|
export const ForYouTab = memo(function ForYouTab() {
|
||||||
|
const [notes, setNotes] = useState<TaggedNostrEvent[]>(forYouFeed.events);
|
||||||
|
const { feedDisplayAs } = useLogin();
|
||||||
|
const displayAsInitial = feedDisplayAs ?? "list";
|
||||||
|
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||||
|
const { publicKey } = useLogin();
|
||||||
|
|
||||||
|
const getFeed = () => {
|
||||||
|
if (!publicKey) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!getForYouFeedPromise) {
|
||||||
|
getForYouFeedPromise = getForYouFeed(publicKey);
|
||||||
|
}
|
||||||
|
getForYouFeedPromise!.then(notes => {
|
||||||
|
console.log("for you feed", notes);
|
||||||
|
if (notes.length < 10) {
|
||||||
|
setTimeout(() => {
|
||||||
|
getForYouFeedPromise = null;
|
||||||
|
getForYouFeed();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
forYouFeed = {
|
||||||
|
events: notes,
|
||||||
|
created_at: Date.now(),
|
||||||
|
};
|
||||||
|
setNotes(notes);
|
||||||
|
notes.forEach(note => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
System.HandleEvent(note);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (forYouFeed.events.length < 10 || Date.now() - forYouFeed.created_at > 1000 * 60 * 1) {
|
||||||
|
getFeed();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const frags = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
events: notes,
|
||||||
|
refTime: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [notes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DisplayAsSelector activeSelection={displayAs} onSelect={a => setDisplayAs(a)} />
|
||||||
|
<FollowsHint />
|
||||||
|
<TaskList />
|
||||||
|
<TimelineRenderer frags={frags} latest={[]} displayAs={displayAs} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -4,7 +4,6 @@ import { useContext, useEffect, useMemo, useState } from "react";
|
|||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
import Timeline from "@/Components/Feed/Timeline";
|
import Timeline from "@/Components/Feed/Timeline";
|
||||||
import { TimelineSubject } from "@/Feed/TimelineFeed";
|
|
||||||
import useHistoryState from "@/Hooks/useHistoryState";
|
import useHistoryState from "@/Hooks/useHistoryState";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
import { debounce, getRelayName, sha256 } from "@/Utils";
|
import { debounce, getRelayName, sha256 } from "@/Utils";
|
||||||
@ -81,14 +80,13 @@ export const GlobalTab = () => {
|
|||||||
}, [relays, relay]);
|
}, [relays, relay]);
|
||||||
|
|
||||||
const subject = useMemo(
|
const subject = useMemo(
|
||||||
() =>
|
() => ({
|
||||||
({
|
type: "global",
|
||||||
type: "global",
|
items: [],
|
||||||
items: [],
|
relay: [relay.url],
|
||||||
relay: [relay?.url],
|
discriminator: `all-${sha256(relay.url)}`,
|
||||||
discriminator: `all-${sha256(relay?.url ?? "")}`,
|
}),
|
||||||
}) as TimelineSubject,
|
[relay.url],
|
||||||
[relay?.url],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -6,6 +6,7 @@ import HashTagsPage from "@/Pages/HashTagsPage";
|
|||||||
import { ConversationsTab } from "@/Pages/Root/ConversationsTab";
|
import { ConversationsTab } from "@/Pages/Root/ConversationsTab";
|
||||||
import { DefaultTab } from "@/Pages/Root/DefaultTab";
|
import { DefaultTab } from "@/Pages/Root/DefaultTab";
|
||||||
import { FollowedByFriendsTab } from "@/Pages/Root/FollowedByFriendsTab";
|
import { FollowedByFriendsTab } from "@/Pages/Root/FollowedByFriendsTab";
|
||||||
|
import { ForYouTab } from "@/Pages/Root/ForYouTab";
|
||||||
import { GlobalTab } from "@/Pages/Root/GlobalTab";
|
import { GlobalTab } from "@/Pages/Root/GlobalTab";
|
||||||
import { NotesTab } from "@/Pages/Root/NotesTab";
|
import { NotesTab } from "@/Pages/Root/NotesTab";
|
||||||
import { TagsTab } from "@/Pages/Root/TagsTab";
|
import { TagsTab } from "@/Pages/Root/TagsTab";
|
||||||
@ -16,6 +17,10 @@ export const RootTabRoutes = [
|
|||||||
path: "",
|
path: "",
|
||||||
element: <DefaultTab />,
|
element: <DefaultTab />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "for-you",
|
||||||
|
element: <ForYouTab />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "global",
|
path: "global",
|
||||||
element: <GlobalTab />,
|
element: <GlobalTab />,
|
||||||
|
@ -7,7 +7,6 @@ import TabSelectors, { Tab } from "@/Components/TabSelectors/TabSelectors";
|
|||||||
import TrendingNotes from "@/Components/Trending/TrendingPosts";
|
import TrendingNotes from "@/Components/Trending/TrendingPosts";
|
||||||
import TrendingUsers from "@/Components/Trending/TrendingUsers";
|
import TrendingUsers from "@/Components/Trending/TrendingUsers";
|
||||||
import FollowListBase from "@/Components/User/FollowListBase";
|
import FollowListBase from "@/Components/User/FollowListBase";
|
||||||
import { TimelineSubject } from "@/Feed/TimelineFeed";
|
|
||||||
import useProfileSearch from "@/Hooks/useProfileSearch";
|
import useProfileSearch from "@/Hooks/useProfileSearch";
|
||||||
import { debounce } from "@/Utils";
|
import { debounce } from "@/Utils";
|
||||||
|
|
||||||
@ -54,13 +53,14 @@ const SearchPage = () => {
|
|||||||
return debounce(500, () => setKeyword(search));
|
return debounce(500, () => setKeyword(search));
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
const searchTimeline = useMemo(() => {
|
const subject = useMemo(
|
||||||
return {
|
() => ({
|
||||||
type: "post_keyword",
|
type: "post_keyword",
|
||||||
|
items: [keyword + (sortPopular ? " sort:popular" : "")],
|
||||||
discriminator: keyword,
|
discriminator: keyword,
|
||||||
items: [keyword],
|
}),
|
||||||
} as TimelineSubject;
|
[keyword, sortPopular],
|
||||||
}, [keyword]);
|
);
|
||||||
|
|
||||||
function tabContent() {
|
function tabContent() {
|
||||||
if (tab.value === PROFILES) {
|
if (tab.value === PROFILES) {
|
||||||
@ -74,7 +74,7 @@ const SearchPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortOptions()}
|
{sortOptions()}
|
||||||
<Timeline subject={searchTimeline} postsOnly={false} method="LIMIT_UNTIL" />
|
<Timeline key={keyword} subject={subject} postsOnly={false} method={"LIMIT_UNTIL"} loadMore={false} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -14,5 +14,5 @@ export function TopicsPage() {
|
|||||||
[tags, pubKey],
|
[tags, pubKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <Timeline subject={subject} postsOnly={true} method="TIME_RANGE" showLoadMore={true} window={60 * 60 * 6} />;
|
return <Timeline subject={subject} postsOnly={true} method="TIME_RANGE" window={60 * 60 * 6} />;
|
||||||
}
|
}
|
||||||
|
@ -84,6 +84,11 @@ const PreferencesPage = () => {
|
|||||||
defaultRootTab: e.target.value,
|
defaultRootTab: e.target.value,
|
||||||
} as UserPreferences)
|
} as UserPreferences)
|
||||||
}>
|
}>
|
||||||
|
{CONFIG.features.forYouFeed && (
|
||||||
|
<option value="for-you">
|
||||||
|
<FormattedMessage defaultMessage="For you" id="xEjBS7" />
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
<option value="notes">
|
<option value="notes">
|
||||||
<FormattedMessage defaultMessage="Notes" id="7+Domh" />
|
<FormattedMessage defaultMessage="Notes" id="7+Domh" />
|
||||||
</option>
|
</option>
|
||||||
|
@ -55,7 +55,7 @@ export interface UserPreferences {
|
|||||||
/**
|
/**
|
||||||
* Default page to select on load
|
* Default page to select on load
|
||||||
*/
|
*/
|
||||||
defaultRootTab: "notes" | "conversations" | "global";
|
defaultRootTab: "for-you" | "notes" | "conversations" | "global";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default zap amount
|
* Default zap amount
|
||||||
@ -113,7 +113,7 @@ export const DefaultPreferences = {
|
|||||||
autoShowLatest: false,
|
autoShowLatest: false,
|
||||||
fileUploader: "void.cat",
|
fileUploader: "void.cat",
|
||||||
imgProxyConfig: DefaultImgProxy,
|
imgProxyConfig: DefaultImgProxy,
|
||||||
defaultRootTab: "notes",
|
defaultRootTab: CONFIG.features.forYouFeed ? "for-you" : "notes",
|
||||||
defaultZapAmount: 50,
|
defaultZapAmount: 50,
|
||||||
autoZap: false,
|
autoZap: false,
|
||||||
telemetry: true,
|
telemetry: true,
|
||||||
|
@ -1706,6 +1706,9 @@
|
|||||||
"x82IOl": {
|
"x82IOl": {
|
||||||
"defaultMessage": "Mute"
|
"defaultMessage": "Mute"
|
||||||
},
|
},
|
||||||
|
"xEjBS7": {
|
||||||
|
"defaultMessage": "For you"
|
||||||
|
},
|
||||||
"xIcAOU": {
|
"xIcAOU": {
|
||||||
"defaultMessage": "Votes by {type}"
|
"defaultMessage": "Votes by {type}"
|
||||||
},
|
},
|
||||||
|
@ -563,6 +563,7 @@
|
|||||||
"wtLjP6": "Copy ID",
|
"wtLjP6": "Copy ID",
|
||||||
"x/Fx2P": "Fund the services that you use by splitting a portion of all your zaps into a pool of funds!",
|
"x/Fx2P": "Fund the services that you use by splitting a portion of all your zaps into a pool of funds!",
|
||||||
"x82IOl": "Mute",
|
"x82IOl": "Mute",
|
||||||
|
"xEjBS7": "For you",
|
||||||
"xIcAOU": "Votes by {type}",
|
"xIcAOU": "Votes by {type}",
|
||||||
"xIoGG9": "Go to",
|
"xIoGG9": "Go to",
|
||||||
"xQtL3v": "Unlock",
|
"xQtL3v": "Unlock",
|
||||||
|
210
packages/system/src/InMemoryDB.ts
Normal file
210
packages/system/src/InMemoryDB.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { ID, ReqFilter as Filter, STR, TaggedNostrEvent, UID } from ".";
|
||||||
|
import loki from "lokijs";
|
||||||
|
import debug from "debug";
|
||||||
|
|
||||||
|
type PackedNostrEvent = {
|
||||||
|
id: UID;
|
||||||
|
pubkey: number;
|
||||||
|
kind: number;
|
||||||
|
tags: Array<string | UID>[];
|
||||||
|
flatTags: string[];
|
||||||
|
sig: string;
|
||||||
|
created_at: number;
|
||||||
|
content?: string;
|
||||||
|
relays: string[];
|
||||||
|
saved_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_SIZE = 5000;
|
||||||
|
|
||||||
|
class InMemoryDB {
|
||||||
|
private loki = new loki("EventDB");
|
||||||
|
private eventsCollection: Collection<PackedNostrEvent>;
|
||||||
|
private maxSize: number;
|
||||||
|
|
||||||
|
constructor(maxSize = DEFAULT_MAX_SIZE) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
this.eventsCollection = this.loki.addCollection("events", {
|
||||||
|
unique: ["id"],
|
||||||
|
indices: ["pubkey", "kind", "flatTags", "created_at", "saved_at"],
|
||||||
|
});
|
||||||
|
this.startRemoveOldestInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRemoveOldestInterval() {
|
||||||
|
const removeOldest = () => {
|
||||||
|
this.removeOldest();
|
||||||
|
setTimeout(() => removeOldest(), 3000);
|
||||||
|
};
|
||||||
|
setTimeout(() => removeOldest(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#log = debug("InMemoryDB");
|
||||||
|
|
||||||
|
get(id: string): TaggedNostrEvent | undefined {
|
||||||
|
const event = this.eventsCollection.by("id", ID(id)); // throw if db not ready yet?
|
||||||
|
if (event) {
|
||||||
|
return this.unpack(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: string): boolean {
|
||||||
|
return !!this.eventsCollection.by("id", ID(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// map to internal UIDs to save memory
|
||||||
|
private pack(event: TaggedNostrEvent): PackedNostrEvent {
|
||||||
|
return {
|
||||||
|
id: ID(event.id),
|
||||||
|
pubkey: ID(event.pubkey),
|
||||||
|
sig: event.sig,
|
||||||
|
kind: event.kind,
|
||||||
|
tags: event.tags.map(tag => {
|
||||||
|
if (["e", "p"].includes(tag[0]) && typeof tag[1] === "string") {
|
||||||
|
return [tag[0], ID(tag[1] as string), ...tag.slice(2)];
|
||||||
|
} else {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
flatTags: event.tags.filter(tag => ["e", "p", "d"].includes(tag[0])).map(tag => `${tag[0]}_${ID(tag[1])}`),
|
||||||
|
created_at: event.created_at,
|
||||||
|
content: event.content,
|
||||||
|
relays: event.relays,
|
||||||
|
saved_at: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private unpack(packedEvent: PackedNostrEvent): TaggedNostrEvent {
|
||||||
|
return <TaggedNostrEvent>{
|
||||||
|
id: STR(packedEvent.id),
|
||||||
|
pubkey: STR(packedEvent.pubkey),
|
||||||
|
sig: packedEvent.sig,
|
||||||
|
kind: packedEvent.kind,
|
||||||
|
tags: packedEvent.tags.map(tag => {
|
||||||
|
if (["e", "p"].includes(tag[0] as string) && typeof tag[1] === "number") {
|
||||||
|
return [tag[0], STR(tag[1] as number), ...tag.slice(2)];
|
||||||
|
} else {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
created_at: packedEvent.created_at,
|
||||||
|
content: packedEvent.content,
|
||||||
|
relays: packedEvent.relays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(event: TaggedNostrEvent): boolean {
|
||||||
|
if (!event || !event.id || !event.created_at) {
|
||||||
|
throw new Error("Invalid event");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ID(event.id);
|
||||||
|
if (this.eventsCollection.by("id", id)) {
|
||||||
|
return false; // this prevents updating event.relays?
|
||||||
|
}
|
||||||
|
|
||||||
|
const packed = this.pack(event);
|
||||||
|
|
||||||
|
// we might want to limit the kinds of events we save, e.g. no kind 0, 3 or only 1, 6
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.eventsCollection.insert(packed);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(eventId: string): void {
|
||||||
|
const id = ID(eventId);
|
||||||
|
this.eventsCollection.findAndRemove({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOldest(): void {
|
||||||
|
const count = this.eventsCollection.count();
|
||||||
|
this.#log("InMemoryDB: count", count, this.maxSize);
|
||||||
|
if (count > this.maxSize) {
|
||||||
|
this.#log("InMemoryDB: removing oldest events", count - this.maxSize);
|
||||||
|
this.eventsCollection
|
||||||
|
.chain()
|
||||||
|
.simplesort("saved_at")
|
||||||
|
.limit(count - this.maxSize)
|
||||||
|
.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
find(filter: Filter, callback: (event: TaggedNostrEvent) => void): void {
|
||||||
|
this.findArray(filter).forEach(event => {
|
||||||
|
callback(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findArray(filter: Filter): TaggedNostrEvent[] {
|
||||||
|
const query = this.constructQuery(filter);
|
||||||
|
|
||||||
|
const searchRegex = filter.search ? new RegExp(filter.search, "i") : undefined;
|
||||||
|
let chain = this.eventsCollection
|
||||||
|
.chain()
|
||||||
|
.find(query)
|
||||||
|
.where((e: PackedNostrEvent) => {
|
||||||
|
if (searchRegex && !e.content?.match(searchRegex)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.simplesort("created_at", true);
|
||||||
|
|
||||||
|
if (filter.limit) {
|
||||||
|
chain = chain.limit(filter.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain.data().map(e => this.unpack(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
findAndRemove(filter: Filter) {
|
||||||
|
const query = this.constructQuery(filter);
|
||||||
|
this.eventsCollection.findAndRemove(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructQuery(filter: Filter): LokiQuery<PackedNostrEvent> {
|
||||||
|
const query: LokiQuery<PackedNostrEvent> = {};
|
||||||
|
|
||||||
|
if (filter.ids) {
|
||||||
|
query.id = { $in: filter.ids.map(ID) };
|
||||||
|
} else {
|
||||||
|
if (filter.authors) {
|
||||||
|
query.pubkey = { $in: filter.authors.map(ID) };
|
||||||
|
}
|
||||||
|
if (filter.kinds) {
|
||||||
|
query.kind = { $in: filter.kinds };
|
||||||
|
}
|
||||||
|
if (filter["#e"]) {
|
||||||
|
query.flatTags = { $contains: "e_" + filter["#e"]!.map(ID) };
|
||||||
|
} else if (filter["#p"]) {
|
||||||
|
query.flatTags = { $contains: "p_" + filter["#p"]!.map(ID) };
|
||||||
|
} else if (filter["#d"]) {
|
||||||
|
query.flatTags = { $contains: "d_" + filter["#d"]!.map(ID) };
|
||||||
|
}
|
||||||
|
if (filter.since && filter.until) {
|
||||||
|
query.created_at = { $between: [filter.since, filter.until] };
|
||||||
|
}
|
||||||
|
if (filter.since) {
|
||||||
|
query.created_at = { $gte: filter.since };
|
||||||
|
}
|
||||||
|
if (filter.until) {
|
||||||
|
query.created_at = { $lte: filter.until };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
findOne(filter: Filter): TaggedNostrEvent | undefined {
|
||||||
|
return this.findArray(filter)[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InMemoryDB };
|
||||||
|
|
||||||
|
export default new InMemoryDB();
|
Loading…
Reference in New Issue
Block a user