refactor: inline hashtag posts

This commit is contained in:
2023-11-14 10:38:02 +00:00
parent de1b982e93
commit f2a41cb474
7 changed files with 79 additions and 109 deletions

View File

@ -1,5 +1,5 @@
import "./Note.css"; import "./Note.css";
import React from "react"; import { ReactNode } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system"; import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { NostrFileElement } from "Element/Event/NostrFileHeader"; import { NostrFileElement } from "Element/Event/NostrFileHeader";
import ZapstrEmbed from "Element/Embed/ZapstrEmbed"; import ZapstrEmbed from "Element/Embed/ZapstrEmbed";
@ -21,6 +21,7 @@ export interface NoteProps {
depth?: number; depth?: number;
searchedValue?: string; searchedValue?: string;
threadChains?: Map<string, Array<NostrEvent>>; threadChains?: Map<string, Array<NostrEvent>>;
context?: ReactNode;
options?: { options?: {
showHeader?: boolean; showHeader?: boolean;
showContextMenu?: boolean; showContextMenu?: boolean;

View File

@ -290,6 +290,7 @@ export function NoteInner(props: NoteProps) {
link={opt?.canClick === undefined ? undefined : ""} link={opt?.canClick === undefined ? undefined : ""}
/> />
<div className="info"> <div className="info">
{props.context}
{(options.showTime || options.showBookmarked) && ( {(options.showTime || options.showBookmarked) && (
<> <>
{options.showBookmarked && ( {options.showBookmarked && (

View File

@ -1,5 +1,6 @@
import "./Timeline.css"; import "./Timeline.css";
import { ReactNode, useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react"; import { ReactNode, useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react";
import { Link } from "react-router-dom";
import { EventKind, NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system"; import { EventKind, NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { SnortContext, useReactions } from "@snort/system-react"; import { SnortContext, useReactions } from "@snort/system-react";
@ -9,10 +10,9 @@ import useModeration from "Hooks/useModeration";
import { FollowsFeed } from "Cache"; import { FollowsFeed } from "Cache";
import { LiveStreams } from "Element/LiveStreams"; import { LiveStreams } from "Element/LiveStreams";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { TimelineFragment, TimelineRenderer } from "./TimelineFragment"; import { TimelineRenderer } from "./TimelineFragment";
import useHashtagsFeed from "Feed/HashtagsFeed"; import useHashtagsFeed from "Feed/HashtagsFeed";
import { ShowMoreInView } from "Element/Event/ShowMore"; import { ShowMoreInView } from "Element/Event/ShowMore";
import { HashTagHeader } from "Pages/HashTagsPage";
export interface TimelineFollowsProps { export interface TimelineFollowsProps {
postsOnly: boolean; postsOnly: boolean;
@ -42,6 +42,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
const { muted, isEventMuted } = useModeration(); const { muted, isEventMuted } = useModeration();
const sortedFeed = useMemo(() => orderDescending(feed), [feed]); const sortedFeed = useMemo(() => orderDescending(feed), [feed]);
const oldest = useMemo(() => sortedFeed.at(-1)?.created_at, [sortedFeed]);
const postsOnly = useCallback( const postsOnly = useCallback(
(a: NostrEvent) => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true), (a: NostrEvent) => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true),
@ -61,32 +62,28 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
const mixin = useHashtagsFeed(); const mixin = useHashtagsFeed();
const mainFeed = useMemo(() => { const mainFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at <= latest)); return filterPosts((sortedFeed ?? []).filter(a => a.created_at <= latest));
}, [sortedFeed, filterPosts, latest, login.follows.timestamp, mixin]); }, [sortedFeed, filterPosts, latest, login.follows.timestamp]);
const hashTagsGroups = useMemo(() => { const findHashTagContext = (a: NostrEvent) => {
const tag = a.tags.filter(a => a[0] === "t").find(a => login.tags.item.includes(a[1].toLowerCase()))?.[1];
if (tag) {
return <Link to={`/t/${tag}`}>{`#${tag}`}</Link>;
}
};
const mixinFiltered = useMemo(() => {
const mainFeedIds = new Set(mainFeed.map(a => a.id)); const mainFeedIds = new Set(mainFeed.map(a => a.id));
const included = new Set<string>();
return (mixin.data.data ?? []) return (mixin.data.data ?? [])
.filter(a => !mainFeedIds.has(a.id) && postsOnly(a)) .filter(a => !mainFeedIds.has(a.id) && postsOnly(a) && !isEventMuted(a))
.filter(a => a.tags.filter(a => a[0] === "t").length < 5) .filter(a => a.tags.filter(a => a[0] === "t").length < 5)
.reduce( .filter(a => a.created_at >= (oldest ?? unixNow()))
(acc, v) => { .map(
if (included.has(v.id)) return acc; a =>
const tags = v.tags ({
.filter(a => a[0] === "t") ...a,
.map(v => v[1].toLocaleLowerCase()) context: findHashTagContext(a),
.filter(a => mixin.hashtags.includes(a)); }) as TaggedNostrEvent,
for (const t of tags) {
acc[t] ??= [];
acc[t].push(v);
break;
}
included.add(v.id);
return acc;
},
{} as Record<string, Array<TaggedNostrEvent>>,
); );
}, [mixin, mainFeed, postsOnly]); }, [mixin, mainFeed, postsOnly, isEventMuted]);
const latestFeed = useMemo(() => { const latestFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at > latest)); return filterPosts((sortedFeed ?? []).filter(a => a.created_at > latest));
@ -111,7 +108,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
<> <>
{(props.liveStreams ?? true) && <LiveStreams evs={liveStreams} />} {(props.liveStreams ?? true) && <LiveStreams evs={liveStreams} />}
<TimelineRenderer <TimelineRenderer
frags={weaveTimeline(mainFeed, hashTagsGroups)} frags={[{ events: orderDescending(mainFeed.concat(mixinFiltered)), refTime: latest }]}
related={reactions.data ?? []} related={reactions.data ?? []}
latest={latestAuthors} latest={latestAuthors}
showLatest={t => onShowLatest(t)} showLatest={t => onShowLatest(t)}
@ -119,55 +116,9 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
noteRenderer={props.noteRenderer} noteRenderer={props.noteRenderer}
/> />
{sortedFeed.length > 0 && ( {sortedFeed.length > 0 && (
<ShowMoreInView <ShowMoreInView onClick={async () => await FollowsFeed.loadMore(system, login, oldest ?? unixNow())} />
onClick={async () => await FollowsFeed.loadMore(system, login, sortedFeed[sortedFeed.length - 1].created_at)}
/>
)} )}
</> </>
); );
}; };
export default TimelineFollows; export default TimelineFollows;
function weaveTimeline(
main: Array<TaggedNostrEvent>,
hashtags: Record<string, Array<TaggedNostrEvent>>,
): Array<TimelineFragment> {
// always skip 5 posts from start to avoid heavy handed weaving
let skip = 5;
if (main.length < skip) {
skip = Math.min(skip, main.length - 1);
}
const frags = Object.entries(hashtags).map(([k, v]) => {
const take = v.slice(0, 5);
return {
title: (
<div className="bb p">
<HashTagHeader tag={k} />
</div>
),
events: take,
refTime: Math.min(
main.at(skip)?.created_at ?? unixNow(),
take.reduce((acc, v) => (acc > v.created_at ? acc : v.created_at), 0),
),
} as TimelineFragment;
});
if (main.length === 0) {
return frags;
}
return [
{
events: main.slice(0, skip),
refTime: main[0].created_at,
},
...frags,
{
events: main.slice(skip),
refTime: main[skip].created_at,
},
].sort((a, b) => (a.refTime > b.refTime ? -1 : 1));
}

View File

@ -1,11 +1,12 @@
import { ReactNode, useCallback } from "react";
import { FormattedMessage } from "react-intl";
import { useInView } from "react-intersection-observer";
import { TaggedNostrEvent } from "@snort/system"; import { TaggedNostrEvent } from "@snort/system";
import Note from "Element/Event/Note"; import Note from "Element/Event/Note";
import ProfileImage from "Element/User/ProfileImage"; import ProfileImage from "Element/User/ProfileImage";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import { findTag } from "SnortUtils"; import { findTag } from "SnortUtils";
import { ReactNode, useCallback } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
export interface TimelineFragment { export interface TimelineFragment {
events: Array<TaggedNostrEvent>; events: Array<TaggedNostrEvent>;
@ -13,7 +14,7 @@ export interface TimelineFragment {
title?: ReactNode; title?: ReactNode;
} }
export interface TimelineFragmentProps { export interface TimelineRendererProps {
frags: Array<TimelineFragment>; frags: Array<TimelineFragment>;
related: Array<TaggedNostrEvent>; related: Array<TaggedNostrEvent>;
/** /**
@ -25,14 +26,8 @@ export interface TimelineFragmentProps {
noteOnClick?: (ev: TaggedNostrEvent) => void; noteOnClick?: (ev: TaggedNostrEvent) => void;
} }
export function TimelineRenderer(props: TimelineFragmentProps) { export function TimelineRenderer(props: TimelineRendererProps) {
const { ref, inView } = useInView(); const { ref, inView } = useInView();
const relatedFeed = useCallback(
(id: string) => {
return props.related.filter(a => findTag(a, "e") === id);
},
[props.related],
);
return ( return (
<> <>
@ -64,19 +59,48 @@ export function TimelineRenderer(props: TimelineFragmentProps) {
)} )}
</> </>
)} )}
{props.frags.map(f => { {props.frags.map(f => (
return ( <TimelineFragment
<> frag={f}
{f.title} related={props.related}
{f.events.map( noteRenderer={props.noteRenderer}
e => noteOnClick={props.noteOnClick}
props.noteRenderer?.(e) ?? ( />
<Note data={e} related={relatedFeed(e.id)} key={e.id} depth={0} onClick={props.noteOnClick} /> ))}
), </>
)} );
</> }
);
})} export interface TimelineFragProps {
frag: TimelineFragment;
related: Array<TaggedNostrEvent>;
noteRenderer?: (ev: TaggedNostrEvent) => ReactNode;
noteOnClick?: (ev: TaggedNostrEvent) => void;
}
export function TimelineFragment(props: TimelineFragProps) {
const relatedFeed = useCallback(
(id: string) => {
return props.related.filter(a => findTag(a, "e") === id);
},
[props.related],
);
return (
<>
{props.frag.title}
{props.frag.events.map(
e =>
props.noteRenderer?.(e) ?? (
<Note
data={e}
related={relatedFeed(e.id)}
key={e.id}
depth={0}
onClick={props.noteOnClick}
context={e.context}
/>
),
)}
</> </>
); );
} }

View File

@ -739,9 +739,6 @@
"defaultMessage": "Recent", "defaultMessage": "Recent",
"description": "Sort order name" "description": "Sort order name"
}, },
"RkW5we": {
"defaultMessage": "Bitcoin"
},
"RoOyAh": { "RoOyAh": {
"defaultMessage": "Relays" "defaultMessage": "Relays"
}, },
@ -910,9 +907,6 @@
"Zff6lu": { "Zff6lu": {
"defaultMessage": "Username iris.to/<b>{name}</b> is reserved for you!" "defaultMessage": "Username iris.to/<b>{name}</b> is reserved for you!"
}, },
"a+6cHB": {
"defaultMessage": "Derogatory"
},
"a5UPxh": { "a5UPxh": {
"defaultMessage": "Fund developers and platforms providing NIP-05 verification services" "defaultMessage": "Fund developers and platforms providing NIP-05 verification services"
}, },
@ -1162,9 +1156,6 @@
"k2veDA": { "k2veDA": {
"defaultMessage": "Write" "defaultMessage": "Write"
}, },
"k7+5Ny": {
"defaultMessage": "Hate Speech"
},
"k7sKNy": { "k7sKNy": {
"defaultMessage": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!" "defaultMessage": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!"
}, },

View File

@ -243,7 +243,6 @@
"RfhLwC": "By: {author}", "RfhLwC": "By: {author}",
"RhDAoS": "Are you sure you want to delete {id}", "RhDAoS": "Are you sure you want to delete {id}",
"RjpoYG": "Recent", "RjpoYG": "Recent",
"RkW5we": "Bitcoin",
"RoOyAh": "Relays", "RoOyAh": "Relays",
"Rs4kCE": "Bookmark", "Rs4kCE": "Bookmark",
"RwFaYs": "Sort", "RwFaYs": "Sort",
@ -299,7 +298,6 @@
"ZLmyG9": "Contributors", "ZLmyG9": "Contributors",
"ZS+jRE": "Send zap splits to", "ZS+jRE": "Send zap splits to",
"Zff6lu": "Username iris.to/<b>{name}</b> is reserved for you!", "Zff6lu": "Username iris.to/<b>{name}</b> is reserved for you!",
"a+6cHB": "Derogatory",
"a5UPxh": "Fund developers and platforms providing NIP-05 verification services", "a5UPxh": "Fund developers and platforms providing NIP-05 verification services",
"a7TDNm": "Notes will stream in real time into global and notes tab", "a7TDNm": "Notes will stream in real time into global and notes tab",
"aHje0o": "Name or nym", "aHje0o": "Name or nym",
@ -382,7 +380,6 @@
"jvo0vs": "Save", "jvo0vs": "Save",
"jzgQ2z": "{n} Reactions", "jzgQ2z": "{n} Reactions",
"k2veDA": "Write", "k2veDA": "Write",
"k7+5Ny": "Hate Speech",
"k7sKNy": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!", "k7sKNy": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!",
"kEZUR8": "Register an Iris username", "kEZUR8": "Register an Iris username",
"kJYo0u": "{n,plural,=0{{name} reposted} other{{name} & {n} others reposted}}", "kJYo0u": "{n,plural,=0{{name} reposted} other{{name} & {n} others reposted}}",

View File

@ -14,7 +14,12 @@ export interface TaggedNostrEvent extends NostrEvent {
/** /**
* A list of relays this event was seen on * A list of relays this event was seen on
*/ */
relays: string[]; relays: Array<string>;
/**
* Additional context
*/
context?: never;
} }
/** /**