Compare commits

...

10 Commits

11 changed files with 123 additions and 44 deletions

View File

@ -65,6 +65,19 @@ To build the application and system packages, use
$ yarn build
```
Tauri desktop application:
```
# install dependencies
yarn
# develop
yarn tauri dev
# build
yarn tauri build
```
### Translations
[![Crowdin](https://badges.crowdin.net/snort/localized.svg)](https://crowdin.com/project/snort)

View File

@ -22,7 +22,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const [showReactions, setShowReactions] = useState(false);
const related = useReactions("reactions", ids, undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related);
const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions;
const { preferences: prefs, readonly } = useLogin(s => ({
@ -33,7 +33,7 @@ export default function NoteFooter(props: NoteFooterProps) {
return (
<div className="flex flex-row gap-4 overflow-hidden max-w-full h-6 items-center">
<ReplyButton ev={ev} replyCount={props.replyCount} readonly={readonly} />
<ReplyButton ev={ev} replyCount={props.replyCount ?? replies.length} readonly={readonly} />
<RepostButton ev={ev} reposts={reposts} />
{prefs.enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
{CONFIG.showPowIcon && <PowIcon ev={ev} />}

View File

@ -27,6 +27,7 @@
height: 100%;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.4);
position: absolute;
}
.avatar .icons {

View File

@ -1,35 +1,62 @@
import { NostrEvent, parseZap } from "@snort/system";
import debug from "debug";
import { Relay } from "@/Cache";
const log = debug("getForYouFeed");
export async function getForYouFeed(pubkey: string): Promise<NostrEvent[]> {
console.time("For You feed generation time");
console.log("pubkey", pubkey);
log("pubkey", pubkey);
// Get events reacted to by me
const myReactedEvents = await getMyReactedEventIds(pubkey);
console.log("my reacted events", myReactedEvents);
const myReactedEventIds = await getMyReactedEvents(pubkey);
log("my reacted events", myReactedEventIds);
const myReactedAuthors = await getMyReactedAuthors(myReactedEventIds, pubkey);
log("my reacted authors", myReactedAuthors);
// Get others who reacted to the same events as me
const othersWhoReacted = await getOthersWhoReacted(myReactedEvents, pubkey);
const othersWhoReacted = await getOthersWhoReacted(myReactedEventIds, 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);
log("others who reacted", othersWhoReacted);
// Get event ids reacted to by those others
const reactedByOthers = await getEventIdsReactedByOthers(othersWhoReacted, myReactedEvents, pubkey);
console.log("reacted by others", reactedByOthers);
const reactedByOthers = await getEventIdsReactedByOthers(othersWhoReacted, myReactedEventIds, pubkey);
log("reacted by others", reactedByOthers);
// Get full events in sorted order
const feed = await getFeedEvents(reactedByOthers);
console.log("feed.length", feed.length);
const feed = await getFeedEvents(reactedByOthers, myReactedAuthors);
log("feed.length", feed.length);
console.timeEnd("For You feed generation time");
return feed;
}
async function getMyReactedEventIds(pubkey: string) {
async function getMyReactedAuthors(myReactedEventIds: Set<string>, myPubkey: string) {
const myReactedAuthors = new Map<string, number>();
const myReactions = await Relay.query([
"REQ",
"getMyReactedAuthors",
{
"#e": Array.from(myReactedEventIds),
},
]);
myReactions.forEach(reaction => {
if (reaction.pubkey !== myPubkey) {
myReactedAuthors.set(reaction.pubkey, (myReactedAuthors.get(reaction.pubkey) || 0) + 1);
}
});
return myReactedAuthors;
}
async function getMyReactedEvents(pubkey: string) {
const myReactedEventIds = new Set<string>();
const myEvents = await Relay.query([
"REQ",
"getMyReactedEventIds",
@ -100,17 +127,40 @@ async function getEventIdsReactedByOthers(
return eventIdsReactedByOthers;
}
async function getFeedEvents(reactedToIds: Map<string, number>) {
async function getFeedEvents(reactedToIds: Map<string, number>, reactedToAuthors: Map<string, number>) {
const events = await Relay.query([
"REQ",
"getFeedEvents",
{
ids: Array.from(reactedToIds.keys()),
kinds: [1],
// max 24h old
since: Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 7,
},
]);
const seen = new Set<string>(events.map(ev => ev.id));
log("reactedToAuthors", reactedToAuthors);
const favoriteAuthors = Array.from(reactedToAuthors.keys())
.sort((a, b) => reactedToAuthors.get(b)! - reactedToAuthors.get(a)!)
.slice(20);
const eventsByFavoriteAuthors = await Relay.query([
"REQ",
"getFeedEvents",
{
authors: favoriteAuthors,
kinds: [1],
since: Math.floor(Date.now() / 1000) - 60 * 60 * 24,
limit: 100,
},
]);
eventsByFavoriteAuthors.forEach(ev => {
if (!seen.has(ev.id)) {
events.push(ev);
}
});
// Filter out replies
const filteredEvents = events.filter(ev => !ev.tags.some(tag => tag[0] === "e"));
@ -137,6 +187,9 @@ async function getFeedEvents(reactedToIds: Map<string, number>) {
const normalize = (value: number, min: number, max: number) => (value - min) / (max - min);
const maxFavoriteness = Math.max(...Array.from(reactedToAuthors.values()));
const favoritenessWeight = 0.5;
// Normalize and sort events by calculated score
filteredEvents.sort((a, b) => {
const aReactions = normalize(reactedToIds.get(a.id) || 0, minReactions, maxReactions);
@ -145,10 +198,13 @@ async function getFeedEvents(reactedToIds: Map<string, number>) {
const aAge = normalize(currentTime - new Date(a.created_at).getTime(), minAge, maxAge);
const bAge = normalize(currentTime - new Date(b.created_at).getTime(), minAge, maxAge);
const aFavoriteness = normalize(reactedToAuthors.get(a.pubkey) || 0, 0, maxFavoriteness);
const bFavoriteness = normalize(reactedToAuthors.get(b.pubkey) || 0, 0, maxFavoriteness);
// randomly big or small weight for recentness
const recentnessWeight = Math.random() > 0.5 ? -0.1 : -10;
const aScore = aReactions + recentnessWeight * aAge;
const bScore = bReactions + recentnessWeight * bAge;
const recentnessWeight = Math.random() > 0.75 ? -0.1 : -10;
const aScore = aReactions + recentnessWeight * aAge + aFavoriteness * favoritenessWeight;
const bScore = bReactions + recentnessWeight * bAge + bFavoriteness * favoritenessWeight;
// Sort by descending score
return bScore - aScore;

View File

@ -86,7 +86,7 @@ export default function NavSidebar({ narrow = false }: { narrow: boolean }) {
const className = classNames(
{ "xl:w-56 xl:gap-2 xl:items-start": !narrow },
"overflow-y-auto hide-scrollbar sticky items-center border-r border-border-color top-0 z-20 h-screen max-h-screen hidden md:flex flex-col px-2 py-4 flex-shrink-0 gap-1",
"select-none overflow-y-auto hide-scrollbar sticky items-center border-r border-border-color top-0 z-20 h-screen max-h-screen hidden md:flex flex-col px-2 py-4 flex-shrink-0 gap-1",
);
const readOnlyIcon = readonly && (

View File

@ -1,4 +1,4 @@
import {EventKind, TaggedNostrEvent} from "@snort/system";
import { EventKind, TaggedNostrEvent } from "@snort/system";
import { memo, useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
@ -7,10 +7,10 @@ import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelecto
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import { TaskList } from "@/Components/Tasks/TaskList";
import { getForYouFeed } from "@/Db/getForYouFeed";
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
import useLogin from "@/Hooks/useLogin";
import messages from "@/Pages/messages";
import { System } from "@/system";
import useTimelineFeed, {TimelineFeedOptions, TimelineSubject} from "@/Feed/TimelineFeed";
const FollowsHint = () => {
const { publicKey: pubKey, follows } = useLogin();
@ -60,8 +60,12 @@ export const ForYouTab = memo(function ForYouTab() {
}) as TimelineSubject,
[login.follows.item, login.tags.item],
);
// also get "follows" feed so data is loaded
// also get "follows" feed so data is loaded from relays and there's a fallback if "for you" feed is empty
const latestFeed = useTimelineFeed(subject, { method: "TIME_RANGE" } as TimelineFeedOptions);
const filteredLatestFeed = useMemo(() => {
// no replies
return latestFeed.main?.filter((ev: TaggedNostrEvent) => !ev.tags.some((tag: string[]) => tag[0] === "e")) ?? [];
}, [latestFeed.main]);
const getFeed = () => {
if (!publicKey) {
@ -71,11 +75,10 @@ export const ForYouTab = memo(function ForYouTab() {
getForYouFeedPromise = getForYouFeed(publicKey);
}
getForYouFeedPromise!.then(notes => {
console.log("for you feed", notes);
getForYouFeedPromise = null;
if (notes.length < 10) {
setTimeout(() => {
getForYouFeedPromise = null;
getForYouFeed();
getForYouFeed(publicKey);
}, 1000);
}
forYouFeed = {
@ -92,19 +95,29 @@ export const ForYouTab = memo(function ForYouTab() {
};
useEffect(() => {
if (forYouFeed.events.length < 10 || Date.now() - forYouFeed.created_at > 1000 * 60 * 1) {
if (forYouFeed.events.length < 10 || Date.now() - forYouFeed.created_at > 1000 * 60 * 2) {
getFeed();
}
}, []);
const combinedFeed = useMemo(() => {
// combine feeds: intermittently pick from both feeds
const seen = new Set<string>();
const combined = [];
let i = 0;
let j = 0;
while (i < notes.length || j < latestFeed.main?.length) {
if (i < notes.length) {
let i = 0; // Index for `notes`
let j = 0; // Index for `latestFeed.main`
let count = 0; // Combined feed count to decide when to insert from `latestFeed`
while (i < notes.length || j < (filteredLatestFeed.length ?? 0)) {
// Insert approximately 1 event from `latestFeed` for every 4 events from `notes`
if (count % 5 === 0 && j < (filteredLatestFeed.length ?? 0)) {
const ev = filteredLatestFeed[j];
if (!seen.has(ev.id) && !ev.tags.some((a: string[]) => a[0] === "e")) {
seen.add(ev.id);
combined.push(ev);
}
j++;
} else if (i < notes.length) {
// Add from `notes` otherwise
const ev = notes[i];
if (!seen.has(ev.id)) {
seen.add(ev.id);
@ -112,17 +125,10 @@ export const ForYouTab = memo(function ForYouTab() {
}
i++;
}
if (j < latestFeed.main?.length) {
const ev = latestFeed.main[j];
if (!seen.has(ev.id) && !ev.tags?.some((tag: string[]) => tag[0] === "e")) {
seen.add(ev.id);
combined.push(ev);
}
j++;
}
count++;
}
return combined;
}, [notes, latestFeed.main]);
}, [notes, filteredLatestFeed]);
const frags = useMemo(() => {
return [
@ -138,7 +144,7 @@ export const ForYouTab = memo(function ForYouTab() {
<DisplayAsSelector activeSelection={displayAs} onSelect={a => setDisplayAs(a)} />
<FollowsHint />
<TaskList />
<TimelineRenderer frags={frags} latest={[]} displayAs={displayAs} />
<TimelineRenderer frags={frags} latest={[]} displayAs={displayAs} loadMore={() => latestFeed.loadMore()} />
</>
);
});

View File

@ -563,7 +563,7 @@
"wtLjP6": "ID kopieren",
"x/Fx2P": "Unterstütze die von dir genutzten Dienste, indem du einen Teil deiner Zaps in einen Spendenpool einzahlst!",
"x82IOl": "Stummschalten",
"xEjBS7": "For you",
"xEjBS7": "Für dich",
"xIcAOU": "Stimmen nach {type}",
"xIoGG9": "Gehe zu",
"xQtL3v": "Entsperren",

View File

@ -563,7 +563,7 @@
"wtLjP6": "Egyedi azonosító",
"x/Fx2P": "Finanszírozza az Ön által használt szolgáltatásokat úgy, hogy az összes zaps-ek egy részét egy támogatói gyűjtőbe osztja fel!",
"x82IOl": "Némítás",
"xEjBS7": "For you",
"xEjBS7": "Számodra",
"xIcAOU": "Szavazatok {type} által",
"xIoGG9": "Menj ide",
"xQtL3v": "Feloldás",

View File

@ -47,6 +47,7 @@ export function useEventReactions(link: NostrLink, related: ReadonlyArray<Tagged
positive: groupReactions[Reaction.Positive] ?? [],
negative: groupReactions[Reaction.Negative] ?? [],
},
replies: reactionKinds[String(EventKind.TextNote)] ?? [],
reposts,
zaps,
others: Object.fromEntries(

View File

@ -23,7 +23,9 @@ export function useReactions(
);
for (const [, v] of Object.entries(grouped)) {
rb.withFilter().kinds([EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]).replyToLink(v);
rb.withFilter()
.kinds([EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt])
.replyToLink(v);
}
}
others?.(rb);

View File

@ -3,7 +3,7 @@
"build": {
"beforeBuildCommand": "yarn build",
"beforeDevCommand": "yarn start",
"devPath": "http://localhost:3000",
"devPath": "http://localhost:5173",
"distDir": "../packages/app/build"
},
"package": {