feat: hashtags header
This commit is contained in:
parent
e9b7c7c6e3
commit
540f29dd69
@ -47,7 +47,7 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
|
|||||||
const keys = (await this.table?.toCollection().primaryKeys()) ?? [];
|
const keys = (await this.table?.toCollection().primaryKeys()) ?? [];
|
||||||
this.onTable = new Set<string>(keys.map(a => a as string));
|
this.onTable = new Set<string>(keys.map(a => a as string));
|
||||||
|
|
||||||
// load only latest 10 posts, rest can be loaded on-demand
|
// load only latest 50 posts, rest can be loaded on-demand
|
||||||
const latest = await this.table?.orderBy("created_at").reverse().limit(50).toArray();
|
const latest = await this.table?.orderBy("created_at").reverse().limit(50).toArray();
|
||||||
latest?.forEach(v => this.cache.set(this.key(v), v));
|
latest?.forEach(v => this.cache.set(this.key(v), v));
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ import useLogin from "Hooks/useLogin";
|
|||||||
import { TimelineFragment, TimelineRenderer } from "./TimelineFragment";
|
import { TimelineFragment, 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;
|
||||||
@ -67,6 +68,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
|||||||
const included = new Set<string>();
|
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))
|
||||||
|
.filter(a => a.tags.filter(a => a[0] === "t").length < 5)
|
||||||
.reduce(
|
.reduce(
|
||||||
(acc, v) => {
|
(acc, v) => {
|
||||||
if (included.has(v.id)) return acc;
|
if (included.has(v.id)) return acc;
|
||||||
@ -116,9 +118,9 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
|||||||
noteOnClick={props.noteOnClick}
|
noteOnClick={props.noteOnClick}
|
||||||
noteRenderer={props.noteRenderer}
|
noteRenderer={props.noteRenderer}
|
||||||
/>
|
/>
|
||||||
<ShowMoreInView
|
{sortedFeed.length > 0 && <ShowMoreInView
|
||||||
onClick={async () => await FollowsFeed.loadMore(system, login, sortedFeed[sortedFeed.length - 1].created_at)}
|
onClick={async () => await FollowsFeed.loadMore(system, login, sortedFeed[sortedFeed.length - 1].created_at)}
|
||||||
/>
|
/>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -129,28 +131,32 @@ function weaveTimeline(
|
|||||||
hashtags: Record<string, Array<TaggedNostrEvent>>,
|
hashtags: Record<string, Array<TaggedNostrEvent>>,
|
||||||
): Array<TimelineFragment> {
|
): Array<TimelineFragment> {
|
||||||
// always skip 5 posts from start to avoid heavy handed weaving
|
// always skip 5 posts from start to avoid heavy handed weaving
|
||||||
const skip = 5;
|
let skip = 5;
|
||||||
|
|
||||||
if (main.length < skip) {
|
if (main.length < skip) {
|
||||||
return [{ events: main, refTime: unixNow() }];
|
skip = Math.min(skip, main.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const frags = Object.entries(hashtags).map(([k, v]) => {
|
const frags = Object.entries(hashtags).map(([k, v]) => {
|
||||||
const take = v.slice(0, 5);
|
const take = v.slice(0, 5);
|
||||||
return {
|
return {
|
||||||
title: (
|
title: (
|
||||||
<div className="flex bb p">
|
<div className="bb p">
|
||||||
<h2>#{k}</h2>
|
<HashTagHeader tag={k} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
events: take,
|
events: take,
|
||||||
refTime: Math.min(
|
refTime: Math.min(
|
||||||
main[skip].created_at,
|
main.at(skip)?.created_at ?? unixNow(),
|
||||||
take.reduce((acc, v) => (acc > v.created_at ? acc : v.created_at), 0),
|
take.reduce((acc, v) => (acc > v.created_at ? acc : v.created_at), 0),
|
||||||
),
|
),
|
||||||
} as TimelineFragment;
|
} as TimelineFragment;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (main.length === 0) {
|
||||||
|
return frags;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
events: main.slice(0, skip),
|
events: main.slice(0, skip),
|
||||||
|
@ -39,6 +39,10 @@
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text iframe[src*="youtube.com"] {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
}
|
||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
@ -1,51 +1,25 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||||
import { NostrHashtagLink } from "@snort/system";
|
import { EventKind, NostrHashtagLink, NoteCollection, RequestBuilder } from "@snort/system";
|
||||||
|
import { dedupe } from "@snort/shared";
|
||||||
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
|
|
||||||
import Timeline from "Element/Feed/Timeline";
|
import Timeline from "Element/Feed/Timeline";
|
||||||
import useEventPublisher from "Hooks/useEventPublisher";
|
import useEventPublisher from "Hooks/useEventPublisher";
|
||||||
import useLogin from "Hooks/useLogin";
|
import useLogin from "Hooks/useLogin";
|
||||||
import { setTags } from "Login";
|
import { setTags } from "Login";
|
||||||
|
import AsyncButton from "Element/AsyncButton";
|
||||||
|
import ProfileImage from "Element/User/ProfileImage";
|
||||||
|
|
||||||
const HashTagsPage = () => {
|
const HashTagsPage = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const tag = (params.tag ?? "").toLowerCase();
|
const tag = (params.tag ?? "").toLowerCase();
|
||||||
const login = useLogin();
|
|
||||||
const isFollowing = useMemo(() => {
|
|
||||||
return login.tags.item.includes(tag);
|
|
||||||
}, [login, tag]);
|
|
||||||
const { publisher, system } = useEventPublisher();
|
|
||||||
|
|
||||||
async function followTags(ts: string[]) {
|
|
||||||
if (publisher) {
|
|
||||||
const ev = await publisher.bookmarks(
|
|
||||||
ts.map(a => new NostrHashtagLink(a)),
|
|
||||||
"follow",
|
|
||||||
);
|
|
||||||
system.BroadcastEvent(ev);
|
|
||||||
setTags(login, ts, ev.created_at * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="main-content p">
|
<div className="bb p">
|
||||||
<div className="action-heading">
|
<HashTagHeader tag={tag} />
|
||||||
<h2>#{tag}</h2>
|
|
||||||
{isFollowing ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => followTags(login.tags.item.filter(t => t !== tag))}>
|
|
||||||
<FormattedMessage defaultMessage="Unfollow" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={() => followTags(login.tags.item.concat([tag]))}>
|
|
||||||
<FormattedMessage defaultMessage="Follow" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Timeline
|
<Timeline
|
||||||
key={tag}
|
key={tag}
|
||||||
@ -58,3 +32,58 @@ const HashTagsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default HashTagsPage;
|
export default HashTagsPage;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function HashTagHeader({ tag }: { tag: string }) {
|
||||||
|
const login = useLogin();
|
||||||
|
const isFollowing = useMemo(() => {
|
||||||
|
return login.tags.item.includes(tag);
|
||||||
|
}, [login, tag]);
|
||||||
|
const { publisher, system } = useEventPublisher();
|
||||||
|
|
||||||
|
async function followTags(ts: string[]) {
|
||||||
|
if (publisher) {
|
||||||
|
const ev = await publisher.bookmarks(
|
||||||
|
ts.map(a => new NostrHashtagLink(a)),
|
||||||
|
"follow",
|
||||||
|
);
|
||||||
|
setTags(login, ts, ev.created_at * 1000);
|
||||||
|
await system.BroadcastEvent(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = useMemo(() => {
|
||||||
|
const rb = new RequestBuilder(`hashtag-counts:${tag}`);
|
||||||
|
rb.withFilter()
|
||||||
|
.kinds([EventKind.CategorizedBookmarks])
|
||||||
|
.tag("d", ["follow"])
|
||||||
|
.tag("t", [tag.toLowerCase()]);
|
||||||
|
return rb;
|
||||||
|
}, [tag]);
|
||||||
|
const followsTag = useRequestBuilder(NoteCollection, sub);
|
||||||
|
const pubkeys = dedupe((followsTag.data ?? []).map(a => a.pubkey));
|
||||||
|
|
||||||
|
return <div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col g8">
|
||||||
|
<h2>#{tag}</h2>
|
||||||
|
<div className="flex">
|
||||||
|
{pubkeys.slice(0, 5).map(a => <ProfileImage pubkey={a} showUsername={false} link={""} showFollowingMark={false} size={40} />)}
|
||||||
|
{pubkeys.length > 5 && <span>
|
||||||
|
+<FormattedNumber value={pubkeys.length - 5} />
|
||||||
|
</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isFollowing ? (
|
||||||
|
<AsyncButton
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => followTags(login.tags.item.filter(t => t !== tag))}>
|
||||||
|
<FormattedMessage defaultMessage="Unfollow" />
|
||||||
|
</AsyncButton>
|
||||||
|
) : (
|
||||||
|
<AsyncButton onClick={() => followTags(login.tags.item.concat([tag]))}>
|
||||||
|
<FormattedMessage defaultMessage="Follow" />
|
||||||
|
</AsyncButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
@ -31,7 +31,6 @@ enum EventKind {
|
|||||||
CategorizedPeople = 30000, // NIP-51a
|
CategorizedPeople = 30000, // NIP-51a
|
||||||
CategorizedBookmarks = 30001, // NIP-51b
|
CategorizedBookmarks = 30001, // NIP-51b
|
||||||
|
|
||||||
TagLists = 30002, // NIP-51c
|
|
||||||
Badge = 30009, // NIP-58
|
Badge = 30009, // NIP-58
|
||||||
ProfileBadges = 30008, // NIP-58
|
ProfileBadges = 30008, // NIP-58
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user