Hashtags (#92)

* feat: hashtags

* Show tag in header
This commit is contained in:
Kieran 2023-01-19 18:00:56 +00:00 committed by GitHub
parent 39cd6fc3a8
commit 147c7502dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 91 additions and 32 deletions

View File

@ -1,9 +1,10 @@
import { Link } from 'react-router-dom'
import './Hashtag.css' import './Hashtag.css'
const Hashtag = ({ children }: any) => { const Hashtag = ({ tag }: { tag: string }) => {
return ( return (
<span className="hashtag"> <span className="hashtag">
{children} <Link to={`/t/${tag}`} onClick={(e) => e.stopPropagation()}>#{tag}</Link>
</span> </span>
) )
} }

View File

@ -100,6 +100,9 @@ function extractMentions(fragments: Fragment[], tags: Tag[], users: Map<string,
let eText = hexToBech32("note", ref.Event!).substring(0, 12); let eText = hexToBech32("note", ref.Event!).substring(0, 12);
return <Link key={ref.Event} to={eventLink(ref.Event!)} onClick={(e) => e.stopPropagation()}>#{eText}</Link>; return <Link key={ref.Event} to={eventLink(ref.Event!)} onClick={(e) => e.stopPropagation()}>#{eText}</Link>;
} }
case "t": {
return <Hashtag tag={ref.Hashtag!} />
}
} }
} }
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>; return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
@ -132,7 +135,7 @@ function extractHashtags(fragments: Fragment[]) {
if (typeof f === "string") { if (typeof f === "string") {
return f.split(HashtagRegex).map(i => { return f.split(HashtagRegex).map(i => {
if (i.toLowerCase().startsWith("#")) { if (i.toLowerCase().startsWith("#")) {
return <Hashtag>{i}</Hashtag> return <Hashtag tag={i.substring(1)} />
} else { } else {
return i; return i;
} }

View File

@ -1,24 +1,22 @@
import { useMemo } from "react"; import { useMemo } from "react";
import useTimelineFeed from "../feed/TimelineFeed"; import useTimelineFeed, { TimelineSubject } from "../feed/TimelineFeed";
import { HexKey, TaggedRawEvent, u256 } from "../nostr"; import { TaggedRawEvent } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import LoadMore from "./LoadMore"; import LoadMore from "./LoadMore";
import Note from "./Note"; import Note from "./Note";
import NoteReaction from "./NoteReaction"; import NoteReaction from "./NoteReaction";
export interface TimelineProps { export interface TimelineProps {
global: boolean,
postsOnly: boolean, postsOnly: boolean,
pubkeys: HexKey[], subject: TimelineSubject,
method: "TIME_RANGE" | "LIMIT_UNTIL" method: "TIME_RANGE" | "LIMIT_UNTIL"
} }
/** /**
* A list of notes by pubkeys * A list of notes by pubkeys
*/ */
export default function Timeline({ global, pubkeys, postsOnly = false, method }: TimelineProps) { export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) {
const { main, others, loadMore } = useTimelineFeed(pubkeys, { const { main, others, loadMore } = useTimelineFeed(subject, {
global,
method method
}); });

View File

@ -6,6 +6,7 @@ import Tag from "../nostr/Tag";
import { RootState } from "../state/Store"; import { RootState } from "../state/Store";
import { HexKey, RawEvent, u256, UserMetadata } from "../nostr"; import { HexKey, RawEvent, u256, UserMetadata } from "../nostr";
import { bech32ToHex } from "../Util" import { bech32ToHex } from "../Util"
import { HashtagRegex } from "../Const";
declare global { declare global {
interface Window { interface Window {
@ -41,7 +42,7 @@ export default function useEventPublisher() {
return ev; return ev;
} }
function processMentions(ev: NEvent, msg: string) { function processContent(ev: NEvent, msg: string) {
const replaceNpub = (match: string) => { const replaceNpub = (match: string) => {
const npub = match.slice(1); const npub = match.slice(1);
try { try {
@ -53,7 +54,14 @@ export default function useEventPublisher() {
return match return match
} }
} }
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub) const replaceHashtag = (match: string) => {
const tag = match.slice(1);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
return `#[${idx}]`;
}
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub);
content = content.replace(HashtagRegex, replaceHashtag);
ev.Content = content; ev.Content = content;
} }
@ -76,7 +84,7 @@ export default function useEventPublisher() {
if (pubKey) { if (pubKey) {
let ev = NEvent.ForPubKey(pubKey); let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote; ev.Kind = EventKind.TextNote;
processMentions(ev, msg); processContent(ev, msg);
return await signEvent(ev); return await signEvent(ev);
} }
}, },
@ -113,7 +121,7 @@ export default function useEventPublisher() {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length)); ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
} }
} }
processMentions(ev, msg); processContent(ev, msg);
return await signEvent(ev); return await signEvent(ev);
} }
}, },

View File

@ -1,36 +1,44 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { HexKey, u256 } from "../nostr"; import { u256 } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions"; import { Subscriptions } from "../nostr/Subscriptions";
import { unixNow } from "../Util"; import { unixNow } from "../Util";
import useSubscription from "./Subscription"; import useSubscription from "./Subscription";
export interface TimelineFeedOptions { export interface TimelineFeedOptions {
global: boolean,
method: "TIME_RANGE" | "LIMIT_UNTIL" method: "TIME_RANGE" | "LIMIT_UNTIL"
} }
export default function useTimelineFeed(pubKeys: HexKey | Array<HexKey>, options: TimelineFeedOptions) { export interface TimelineSubject {
type: "pubkey" | "hashtag" | "global",
items: string[]
}
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow(); const now = unixNow();
const [window, setWindow] = useState<number>(60 * 60); const [window, setWindow] = useState<number>(60 * 60);
const [until, setUntil] = useState<number>(now); const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window); const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]); const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const subTab = options.global ? "global" : "follows";
const sub = useMemo(() => { const sub = useMemo(() => {
if (!Array.isArray(pubKeys)) { if (subject.type !== "global" && subject.items.length == 0) {
pubKeys = [pubKeys];
}
if (!options.global && (!pubKeys || pubKeys.length === 0)) {
return null; return null;
} }
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `timeline:${subTab}`; sub.Id = `timeline:${subject.type}`;
sub.Authors = options.global ? undefined : new Set(pubKeys);
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]); sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
switch (subject.type) {
case "pubkey": {
sub.Authors = new Set(subject.items);
break;
}
case "hashtag": {
sub.HashTags = new Set(subject.items);
break;
}
}
if (options.method === "LIMIT_UNTIL") { if (options.method === "LIMIT_UNTIL") {
sub.Until = until; sub.Until = until;
sub.Limit = 10; sub.Limit = 10;
@ -43,14 +51,14 @@ export default function useTimelineFeed(pubKeys: HexKey | Array<HexKey>, options
} }
return sub; return sub;
}, [pubKeys, until, since, window]); }, [subject, until, since, window]);
const main = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });
const subNext = useMemo(() => { const subNext = useMemo(() => {
if (trackingEvents.length > 0) { if (trackingEvents.length > 0) {
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `timeline-related:${subTab}`; sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.Repost]); sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.Repost]);
sub.ETags = new Set(trackingEvents); sub.ETags = new Set(trackingEvents);
return sub; return sub;

View File

@ -25,6 +25,7 @@ import VerificationPage from './pages/Verification';
import MessagesPage from './pages/MessagesPage'; import MessagesPage from './pages/MessagesPage';
import ChatPage from './pages/ChatPage'; import ChatPage from './pages/ChatPage';
import DonatePage from './pages/DonatePage'; import DonatePage from './pages/DonatePage';
import HashTagsPage from './pages/HashTagsPage';
/** /**
* HTTP query provider * HTTP query provider
@ -81,6 +82,10 @@ const router = createBrowserRouter([
{ {
path: "/donate", path: "/donate",
element: <DonatePage /> element: <DonatePage />
},
{
path: "/t/:tag",
element: <HashTagsPage />
} }
] ]
} }

View File

@ -37,6 +37,11 @@ export class Subscriptions {
*/ */
PTags?: Set<u256>; PTags?: Set<u256>;
/**
* A list of "t" tags to search
*/
HashTags?: Set<string>;
/** /**
* a timestamp, events must be newer than this to pass * a timestamp, events must be newer than this to pass
*/ */
@ -125,6 +130,9 @@ export class Subscriptions {
if (this.PTags) { if (this.PTags) {
ret["#p"] = Array.from(this.PTags); ret["#p"] = Array.from(this.PTags);
} }
if(this.HashTags) {
ret["#t"] = Array.from(this.HashTags);
}
if (this.Since !== null) { if (this.Since !== null) {
ret.since = this.Since; ret.since = this.Since;
} }

View File

@ -7,6 +7,7 @@ export default class Tag {
PubKey?: HexKey; PubKey?: HexKey;
Relay?: string; Relay?: string;
Marker?: string; Marker?: string;
Hashtag?: string;
Index: number; Index: number;
Invalid: boolean; Invalid: boolean;
@ -35,6 +36,10 @@ export default class Tag {
} }
break; break;
} }
case "t": {
this.Hashtag = tag[1];
break;
}
case "delegation": { case "delegation": {
this.PubKey = tag[1]; this.PubKey = tag[1];
break; break;
@ -53,6 +58,9 @@ export default class Tag {
case "p": { case "p": {
return this.PubKey ? ["p", this.PubKey] : null; return this.PubKey ? ["p", this.PubKey] : null;
} }
case "t": {
return ["t", this.Hashtag!];
}
default: { default: {
return this.Original; return this.Original;
} }

View File

@ -34,6 +34,7 @@ export type RawReqFilter = {
kinds?: number[], kinds?: number[],
"#e"?: u256[], "#e"?: u256[],
"#p"?: u256[], "#p"?: u256[],
"#t"?: string[],
since?: number, since?: number,
until?: number, until?: number,
limit?: number limit?: number

View File

@ -0,0 +1,16 @@
import { useParams } from "react-router-dom";
import Timeline from "../element/Timeline";
const HashTagsPage = () => {
const params = useParams();
const tag = params.tag!.toLowerCase();
return (
<>
<h2>#{tag}</h2>
<Timeline subject={{ type: "hashtag", items: [tag] }} postsOnly={false} method={"TIME_RANGE"} />
</>
)
}
export default HashTagsPage;

View File

@ -49,15 +49,15 @@ export default function ProfilePage() {
return ( return (
<div className="name"> <div className="name">
<h2> <h2>
{user?.display_name || user?.name || 'Nostrich'} {user?.display_name || user?.name || 'Nostrich'}
<FollowsYou pubkey={id} /> <FollowsYou pubkey={id} />
</h2> </h2>
<Copy text={params.id || ""} /> <Copy text={params.id || ""} />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />} {user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div> </div>
) )
} }
function bio() { function bio() {
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || ""); const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
return ( return (
@ -88,7 +88,7 @@ export default function ProfilePage() {
function tabContent() { function tabContent() {
switch (tab) { switch (tab) {
case ProfileTab.Notes: case ProfileTab.Notes:
return <Timeline key={id} pubkeys={[id]} global={false} postsOnly={false} method={"LIMIT_UNTIL"} />; return <Timeline key={id} subject={{ type: "pubkey", items: [id] }} postsOnly={false} method={"LIMIT_UNTIL"} />;
case ProfileTab.Follows: { case ProfileTab.Follows: {
if (isMe) { if (isMe) {
return ( return (

View File

@ -6,6 +6,7 @@ import Timeline from "../element/Timeline";
import { useState } from "react"; import { useState } from "react";
import { RootState } from "../state/Store"; import { RootState } from "../state/Store";
import { HexKey } from "../nostr"; import { HexKey } from "../nostr";
import { TimelineSubject } from "../feed/TimelineFeed";
const RootTab = { const RootTab = {
Posts: 0, Posts: 0,
@ -25,6 +26,8 @@ export default function RootPage() {
} }
} }
const isGlobal = loggedOut || tab === RootTab.Global;
const timelineSubect: TimelineSubject = isGlobal ? { type: "global", items: [] } : { type: "pubkey", items: follows };
return ( return (
<> <>
{pubKey ? <> {pubKey ? <>
@ -41,7 +44,7 @@ export default function RootPage() {
</div> </div>
</div></> : null} </div></> : null}
{followHints()} {followHints()}
<Timeline key={tab} pubkeys={follows} global={loggedOut || tab === RootTab.Global} postsOnly={tab === RootTab.Posts} method={"TIME_RANGE"} /> <Timeline key={tab} subject={timelineSubect} postsOnly={tab === RootTab.Posts} method={"TIME_RANGE"} />
</> </>
); );
} }