@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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;
|
||||||
|
@ -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 />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
16
src/pages/HashTagsPage.tsx
Normal file
16
src/pages/HashTagsPage.tsx
Normal 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;
|
@ -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 (
|
||||||
|
@ -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"} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
Reference in New Issue
Block a user