mirror of
https://github.com/smolgrrr/TAO.git
synced 2024-09-20 01:11:25 +00:00
change casing for vercel
This commit is contained in:
parent
d0fab22d2f
commit
573b9948e0
@ -1,13 +1,13 @@
|
||||
import "./styles/App.css";
|
||||
import Home from "./components/Routes/Home";
|
||||
import Settings from "./components/Routes/Settings";
|
||||
import Home from "./Components/Routes/Home";
|
||||
import Settings from "./Components/Routes/Settings";
|
||||
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
|
||||
import Thread from "./components/Routes/Thread";
|
||||
import Header from "./components/Modals/Header";
|
||||
import AddToHomeScreenPrompt from "./components/Modals/CheckMobile/CheckMobile";
|
||||
import Notifications from "./components/Routes/Notifications";
|
||||
import Hashtags from "./components/Routes/Hashtags";
|
||||
import HashtagPage from "./components/Routes/HashtagPage";
|
||||
import Thread from "./Components/Routes/Thread";
|
||||
import Header from "./Components/Modals/Header";
|
||||
import AddToHomeScreenPrompt from "./Components/Modals/CheckMobile/CheckMobile";
|
||||
import Notifications from "./Components/Routes/Notifications";
|
||||
import Hashtags from "./Components/Routes/Hashtags";
|
||||
import HashtagPage from "./Components/Routes/HashtagPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
57
client/src/Components/Forms/Emojis/emoji-picker.tsx
Normal file
57
client/src/Components/Forms/Emojis/emoji-picker.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import data, { Emoji } from "@emoji-mart/data";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import { RefObject } from "react";
|
||||
import customEmojis from "../custom_emojis.json";
|
||||
|
||||
interface EmojiPickerProps {
|
||||
topOffset: number;
|
||||
leftOffset: number;
|
||||
onEmojiSelect: (e: Emoji) => void;
|
||||
onClickOutside: () => void;
|
||||
height?: number;
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function EmojiPicker({
|
||||
topOffset,
|
||||
leftOffset,
|
||||
onEmojiSelect,
|
||||
onClickOutside,
|
||||
height = 300,
|
||||
ref,
|
||||
}: EmojiPickerProps) {
|
||||
const customEmojiList = customEmojis.map((pack) => {
|
||||
return {
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
emojis: pack.emojis
|
||||
.filter((e) => !e.static_url.endsWith('.svg'))
|
||||
.map((e) => {
|
||||
return {
|
||||
id: e.shortcode,
|
||||
name: e.shortcode,
|
||||
skins: [{ src: e.static_url }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute z-25" ref={ref}>
|
||||
<Picker
|
||||
autoFocus
|
||||
custom={customEmojiList}
|
||||
data = {data}
|
||||
perLine={7}
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
theme="dark"
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
onClickOutside={onClickOutside}
|
||||
categories={['poast']}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
215
client/src/Components/Forms/PostFormCard.tsx
Normal file
215
client/src/Components/Forms/PostFormCard.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import {
|
||||
ServerIcon,
|
||||
CpuChipIcon,
|
||||
ArrowPathIcon,
|
||||
PlusCircleIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { UnsignedEvent, Event as NostrEvent, nip19 } from "nostr-tools";
|
||||
import { renderMedia, attachFile } from "../../utils/FileUpload";
|
||||
import EmojiPicker from "@emoji-mart/react";
|
||||
import customEmojis from './custom_emojis.json';
|
||||
import { useSubmitForm } from "./handleSubmit";
|
||||
import "../../styles/Form.css";
|
||||
|
||||
interface FormProps {
|
||||
refEvent?: NostrEvent;
|
||||
tagType?: 'Reply' | 'Quote' | '';
|
||||
hashtag?: string;
|
||||
}
|
||||
|
||||
const NewNoteCard = ({
|
||||
refEvent,
|
||||
tagType,
|
||||
hashtag,
|
||||
}: FormProps) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [file, setFile] = useState("");
|
||||
const [unsigned, setUnsigned] = useState<UnsignedEvent>({
|
||||
kind: 1,
|
||||
tags: [
|
||||
[
|
||||
"client",
|
||||
"getwired.app"
|
||||
]
|
||||
],
|
||||
content: "",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: "",
|
||||
});
|
||||
const [difficulty, setDifficulty] = useState(
|
||||
localStorage.getItem("difficulty") || "21"
|
||||
);
|
||||
const [fileSizeError, setFileSizeError] = useState(false);
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hashtag) {
|
||||
unsigned.tags.push(['t', hashtag as string]);
|
||||
}
|
||||
|
||||
if (refEvent && tagType) {
|
||||
unsigned.tags = Array.from(new Set(unsigned.tags.concat(refEvent.tags)));
|
||||
unsigned.tags.push(['p', refEvent.pubkey]);
|
||||
|
||||
if (tagType === 'Reply') {
|
||||
unsigned.tags.push(['e', refEvent.id, refEvent.tags.some(tag => tag[0] === 'e') ? 'root' : '']);
|
||||
} else {
|
||||
if (tagType === 'Quote') {
|
||||
setComment(comment + '\nnostr:' + nip19.noteEncode(refEvent.id));
|
||||
unsigned.tags.push(['q', refEvent.id]);
|
||||
} else {
|
||||
unsigned.tags.push(['e', refEvent.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDifficultyChange = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { difficulty } = customEvent.detail;
|
||||
setDifficulty(difficulty);
|
||||
};
|
||||
|
||||
window.addEventListener("difficultyChanged", handleDifficultyChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("difficultyChanged", handleDifficultyChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setUnsigned(prevUnsigned => ({
|
||||
...prevUnsigned,
|
||||
content: `${comment} ${file}`,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
}, [comment, file]);
|
||||
|
||||
const { handleSubmit: originalHandleSubmit, doingWorkProp, doingWorkProgress } = useSubmitForm(unsigned, difficulty);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
await originalHandleSubmit(event);
|
||||
setComment("");
|
||||
setFile("");
|
||||
setUnsigned(prevUnsigned => ({
|
||||
...prevUnsigned,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}));
|
||||
};
|
||||
|
||||
//Emoji stuff
|
||||
const emojiRef = useRef(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
interface Emoji {
|
||||
native?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const emojiNames = customEmojis.map(p => p.emojis).flat();
|
||||
function getEmojiById(id: string) {
|
||||
return emojiNames.find(e => e.shortcode === id);
|
||||
}
|
||||
|
||||
async function onEmojiSelect(emoji: Emoji) {
|
||||
setShowEmojiPicker(false);
|
||||
try {
|
||||
if (emoji.id) {
|
||||
const e = getEmojiById(emoji.id);
|
||||
if (e) {
|
||||
setComment(comment + " :" + e.shortcode + ":");
|
||||
unsigned.tags.push(['emoji', e.shortcode, e.url]);
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
const topOffset = ref.current?.getBoundingClientRect().top;
|
||||
const leftOffset = ref.current?.getBoundingClientRect().left;
|
||||
|
||||
function pickEmoji(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setShowEmojiPicker(!showEmojiPicker);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
name="post"
|
||||
method="post"
|
||||
encType="multipart/form-data"
|
||||
className=""
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input type="hidden" name="MAX_FILE_SIZE" defaultValue={2.5 * 1024 * 1024} />
|
||||
<div className="px-4 flex flex-col rounded-lg">
|
||||
<textarea
|
||||
name="com"
|
||||
wrap="soft"
|
||||
className="shadow-lg w-full px-4 py-3 border-blue-500 bg-black text-white"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={comment.split('\n').length || 1}
|
||||
/>
|
||||
<div className="relative">
|
||||
{file !== "" && (
|
||||
<button onClick={() => setFile("")}>
|
||||
<XCircleIcon className="h-10 w-10 absolute shadow z-100 text-blue-500" />
|
||||
</button>
|
||||
)}
|
||||
{renderMedia([file])}
|
||||
</div>
|
||||
<div className="h-14 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2 bg-neutral-800 px-1.5 py-1 rounded-lg">
|
||||
<div className="inline-flex items-center gap-1.5 text-neutral-300">
|
||||
<CpuChipIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-xs font-medium text-neutral-400">
|
||||
{difficulty} Work
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="items-center">
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker
|
||||
topOffset={topOffset || 0}
|
||||
leftOffset={leftOffset || 0}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
onClickOutside={() => setShowEmojiPicker(false)}
|
||||
ref={emojiRef}
|
||||
/>
|
||||
)}
|
||||
<PlusCircleIcon className="h-4 w-4 text-neutral-400 cursor-pointer" onClick={pickEmoji} />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={`bg-black border h-9 inline-flex items-center justify-center px-4 rounded-lg text-white font-medium text-sm ${doingWorkProp || uploadingFile ? 'cursor-not-allowed' : ''}`}
|
||||
disabled={doingWorkProp || uploadingFile}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{fileSizeError ? (
|
||||
<span className="text-red-500">File size should not exceed 2.5MB</span>
|
||||
) : null}
|
||||
{doingWorkProp ? (
|
||||
<div className="flex animate-pulse text-sm text-gray-300">
|
||||
<CpuChipIcon className="h-4 w-4 ml-auto" />
|
||||
<span>Doing Work:</span>
|
||||
{doingWorkProgress && <span>{doingWorkProgress} hashes</span>}
|
||||
</div>
|
||||
) : null}
|
||||
<div id="postFormError" className="text-red-500" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewNoteCard;
|
86
client/src/Components/Forms/RepostNote.tsx
Normal file
86
client/src/Components/Forms/RepostNote.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
CpuChipIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useState, useEffect } from "react";
|
||||
import { UnsignedEvent, Event as NostrEvent, nip19 } from "nostr-tools";
|
||||
import { useSubmitForm } from "./handleSubmit";
|
||||
import "../../styles/Form.css";
|
||||
|
||||
interface FormProps {
|
||||
refEvent: NostrEvent;
|
||||
}
|
||||
|
||||
const RepostNote = ({
|
||||
refEvent
|
||||
}: FormProps) => {
|
||||
const [difficulty, setDifficulty] = useState(
|
||||
localStorage.getItem("difficulty") || "21"
|
||||
);
|
||||
const [unsigned] = useState<UnsignedEvent>({
|
||||
kind: 6,
|
||||
tags: [
|
||||
['client', 'getwired.app'],
|
||||
['e', refEvent.id, 'wss://relay.damus.io'],
|
||||
['p', refEvent.pubkey]
|
||||
],
|
||||
content: JSON.stringify(refEvent),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleDifficultyChange = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { difficulty } = customEvent.detail;
|
||||
setDifficulty(difficulty);
|
||||
};
|
||||
|
||||
window.addEventListener("difficultyChanged", handleDifficultyChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("difficultyChanged", handleDifficultyChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { handleSubmit, doingWorkProp, doingWorkProgress } = useSubmitForm(unsigned, difficulty);
|
||||
|
||||
return (
|
||||
<form
|
||||
name="post"
|
||||
method="post"
|
||||
encType="multipart/form-data"
|
||||
className=""
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="px-4 flex flex-col rounded-lg">
|
||||
<div className="h-14 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2 bg-neutral-800 px-1.5 py-1 rounded-lg">
|
||||
<div className="inline-flex items-center gap-1.5 text-neutral-300">
|
||||
<CpuChipIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-xs font-medium text-neutral-400">
|
||||
{difficulty} PoW
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={`bg-black border h-9 inline-flex items-center justify-center px-4 rounded-lg text-white font-medium text-sm ${doingWorkProp ? 'cursor-not-allowed' : ''}`}
|
||||
disabled={doingWorkProp}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{doingWorkProp ? (
|
||||
<div className="flex animate-pulse text-sm text-gray-300">
|
||||
<CpuChipIcon className="h-4 w-4 ml-auto" />
|
||||
<span>Doing Work:</span>
|
||||
{doingWorkProgress && <span>{doingWorkProgress} hashes</span>}
|
||||
</div>
|
||||
) : null}
|
||||
<div id="postFormError" className="text-red-500" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepostNote;
|
133778
client/src/Components/Forms/custom_emojis.json
Normal file
133778
client/src/Components/Forms/custom_emojis.json
Normal file
File diff suppressed because it is too large
Load Diff
103
client/src/Components/Forms/handleSubmit.ts
Normal file
103
client/src/Components/Forms/handleSubmit.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent, UnsignedEvent } from "nostr-tools";
|
||||
import { publish } from "../../utils/relays";
|
||||
|
||||
const useWorkers = (numCores: number, unsigned: UnsignedEvent, difficulty: string, deps: any[]) => {
|
||||
const [messageFromWorker, setMessageFromWorker] = useState(null);
|
||||
const [doingWorkProgress, setDoingWorkProgress] = useState(0);
|
||||
|
||||
const startWork = () => {
|
||||
const workers = Array(numCores).fill(null).map(() => new Worker(new URL("../../powWorker", import.meta.url)));
|
||||
|
||||
workers.forEach((worker, index) => {
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.status === 'progress') {
|
||||
console.log(`Worker progress: Checked ${event.data.currentNonce} nonces.`);
|
||||
setDoingWorkProgress(event.data.currentNonce);
|
||||
} else if (event.data.found) {
|
||||
setMessageFromWorker(event.data.event);
|
||||
// Terminate all workers once a solution is found
|
||||
workers.forEach(w => w.terminate());
|
||||
}
|
||||
};
|
||||
|
||||
worker.postMessage({
|
||||
unsigned,
|
||||
difficulty,
|
||||
nonceStart: index, // Each worker starts from its index
|
||||
nonceStep: numCores // Each worker increments by the total number of workers
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return { startWork, messageFromWorker, doingWorkProgress };
|
||||
};
|
||||
|
||||
export const useSubmitForm = (unsigned: UnsignedEvent, difficulty: string) => {
|
||||
const [doingWorkProp, setDoingWorkProp] = useState(false);
|
||||
const [sk, setSk] = useState(generateSecretKey());
|
||||
const unsignedWithPubkey = { ...unsigned, pubkey: getPublicKey(sk) };
|
||||
const powServer = useState(localStorage.getItem('powserver') || '');
|
||||
const [unsignedPoWEvent, setUnsignedPoWEvent] = useState<UnsignedEvent>()
|
||||
let storedKeys = JSON.parse(localStorage.getItem('usedKeys') || '[]');
|
||||
|
||||
// Initialize the worker outside of any effects
|
||||
const numCores = navigator.hardwareConcurrency || 4;
|
||||
|
||||
const { startWork, messageFromWorker, doingWorkProgress } = useWorkers(numCores, unsignedWithPubkey, difficulty, [unsignedWithPubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (unsignedPoWEvent) {
|
||||
setDoingWorkProp(false);
|
||||
const signedEvent = finalizeEvent(unsignedPoWEvent, sk);
|
||||
publish(signedEvent);
|
||||
setSk(generateSecretKey())
|
||||
}
|
||||
}, [unsignedPoWEvent]);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setDoingWorkProp(true);
|
||||
console.log(powServer[0])
|
||||
if (powServer[0]) {
|
||||
const inEventFormat = { ...unsignedWithPubkey, sig: "" };
|
||||
const powRequest = {
|
||||
req_event: inEventFormat,
|
||||
difficulty: difficulty
|
||||
};
|
||||
|
||||
fetch(`${powServer[0]}/powgen`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(powRequest)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(data);
|
||||
// handle the response data
|
||||
setUnsignedPoWEvent(data.event)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
|
||||
} else {
|
||||
startWork();
|
||||
}
|
||||
|
||||
// Add the logic here
|
||||
storedKeys.push([sk, getPublicKey(sk)]);
|
||||
// Stringify the array and store it back to localStorage
|
||||
localStorage.setItem('usedKeys', JSON.stringify(storedKeys));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (messageFromWorker) {
|
||||
setUnsignedPoWEvent(messageFromWorker);
|
||||
}
|
||||
}, [messageFromWorker]);
|
||||
|
||||
return { handleSubmit, doingWorkProp, doingWorkProgress };
|
||||
};
|
9
client/src/Components/Modals/CardContainer.tsx
Normal file
9
client/src/Components/Modals/CardContainer.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export default function CardContainer({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className="card break-inside-avoid mb-4 h-min">
|
||||
<div className="card-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
68
client/src/Components/Modals/CardModals/LinkPreview.tsx
Normal file
68
client/src/Components/Modals/CardModals/LinkPreview.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
// import { getLinkPreview } from 'link-preview-js';
|
||||
// import { useState, useEffect } from 'react';
|
||||
|
||||
|
||||
const LinkModal = ({ url }: { url: string }) => {
|
||||
// const [linkPreview, setLinkPreview] = useState<LinkPreview | null>(null);
|
||||
// const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// const fetchWithProxy = (url: string) => {
|
||||
// const proxyUrl = 'https://api.allorigins.win/raw?url=';
|
||||
// return getLinkPreview(proxyUrl + url)
|
||||
// .then((preview) => setLinkPreview(preview as LinkPreview))
|
||||
// .catch((error) => {
|
||||
// console.error("Error fetching URL with proxy:", error);
|
||||
// setError('Unable to fetch URL with proxy.');
|
||||
// });
|
||||
// };
|
||||
|
||||
// useEffect(() => {
|
||||
// getLinkPreview(url)
|
||||
// .then((preview) => setLinkPreview(preview as LinkPreview))
|
||||
// .catch(error => {
|
||||
// console.error("Error fetching original URL, trying with proxy:", error);
|
||||
// setError('Error fetching original URL. Trying with proxy...');
|
||||
// return fetchWithProxy(url);
|
||||
// });
|
||||
// }, [url]);
|
||||
|
||||
// if (error) {
|
||||
// return <a className='hover:underline text-xs text-neutral-500' href={url}>{url}</a>; // or some loading state
|
||||
// }
|
||||
|
||||
// if (!linkPreview) {
|
||||
return <a className='hover:underline text-xs text-neutral-500' href={url}>{url}</a>; // or some loading state
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="link-preview p-1 bg-neutral-800 rounded-lg border border-neutral-800">
|
||||
// <a href={linkPreview.url} target="_blank" rel="noopener noreferrer" className="">
|
||||
// <img src={linkPreview.images[0]} alt={linkPreview.title} className="rounded-lg" />
|
||||
// <div className="font-semibold text-xs text-gray-300">
|
||||
// {linkPreview.title}
|
||||
// </div>
|
||||
// </a>
|
||||
// </div>
|
||||
// );
|
||||
};
|
||||
|
||||
// interface LinkPreview {
|
||||
// url: string;
|
||||
// title: string;
|
||||
// siteName?: string;
|
||||
// description?: string;
|
||||
// mediaType: string;
|
||||
// contentType?: string;
|
||||
// images: string[];
|
||||
// videos: {
|
||||
// url?: string;
|
||||
// secureUrl?: string;
|
||||
// type?: string;
|
||||
// width?: string;
|
||||
// height?: string;
|
||||
// [key: string]: any;
|
||||
// }[];
|
||||
// [key: string]: any;
|
||||
// }
|
||||
|
||||
export default LinkModal;
|
61
client/src/Components/Modals/CardModals/QuoteEmbed.tsx
Normal file
61
client/src/Components/Modals/CardModals/QuoteEmbed.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { parseContent } from "../../../utils/content";
|
||||
import { Event } from "nostr-tools";
|
||||
import { getMetadata } from "../../../utils/getMetadata";
|
||||
import ContentPreview from "./TextModal";
|
||||
import { renderMedia } from "../../../utils/FileUpload";
|
||||
import { getIconFromHash, timeAgo } from "../../../utils/cardUtils";
|
||||
import { CpuChipIcon } from "@heroicons/react/24/outline";
|
||||
import { verifyPow } from "../../../utils/mine";
|
||||
|
||||
const QuoteEmbed = ({
|
||||
key,
|
||||
event,
|
||||
metadata,
|
||||
}: {
|
||||
key?: string | number;
|
||||
event: Event;
|
||||
metadata: Event | null;
|
||||
}) => {
|
||||
const { files } = parseContent(event);
|
||||
const icon = getIconFromHash(event.pubkey);
|
||||
|
||||
let metadataParsed = null;
|
||||
if (metadata !== null) {
|
||||
metadataParsed = getMetadata(metadata);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-lg border border-neutral-700">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col break-words">
|
||||
<ContentPreview key={event.id} eventdata={event} />
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
{metadataParsed ?
|
||||
<img
|
||||
key={key}
|
||||
className={`h-5 w-5 rounded-full`}
|
||||
src={metadataParsed?.picture ?? icon}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async" />
|
||||
:
|
||||
<div className={`h-4 w-4 ${icon} rounded-full`} />
|
||||
}
|
||||
<div className="flex items-center ml-auto gap-2.5">
|
||||
<div className="inline-flex text-xs text-neutral-600 gap-0.5">
|
||||
<CpuChipIcon className="h-4 w-4" /> {verifyPow(event)}
|
||||
</div>
|
||||
<span className="text-neutral-700">·</span>
|
||||
<div className="text-xs font-semibold text-neutral-600">
|
||||
{timeAgo(event.created_at)}
|
||||
</div>
|
||||
<span className="text-neutral-700">·</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuoteEmbed;
|
102
client/src/Components/Modals/CardModals/TextModal.tsx
Normal file
102
client/src/Components/Modals/CardModals/TextModal.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { Event } from "nostr-tools";
|
||||
import { useEffect, useState } from "react";
|
||||
import { subNoteOnce } from "../../../utils/subscriptions";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { parseContent } from "../../../utils/content";
|
||||
import QuoteEmbed from "./QuoteEmbed";
|
||||
import LinkModal from "./LinkPreview";
|
||||
|
||||
const RichText = ({ text, isExpanded, emojiMap }: { text: string; isExpanded: boolean; emojiMap: Record<string, any> }) => {
|
||||
const content = isExpanded ? text.split('\n') : text.slice(0, 350).split('\n');
|
||||
|
||||
return (
|
||||
<>
|
||||
{content.map((line, i) => (
|
||||
<div key={i}>
|
||||
{line.split(' ').map((word, j) =>
|
||||
emojiMap[word]
|
||||
? <img className="w-8 h-8 mx-0.5 inline" src={emojiMap[word]} alt={word} key={j} />
|
||||
: `${word} `
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ContentPreview = ({ key, eventdata }: { key: string; eventdata: Event }) => {
|
||||
const { comment } = parseContent(eventdata);
|
||||
const [finalComment, setFinalComment] = useState(comment);
|
||||
const [quoteEvents, setQuoteEvents] = useState<Event[]>([]); // Initialize state
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
const [emojiMap, setEmojiMap] = useState<Record<string, any>>({});
|
||||
|
||||
// Define your callback function for subGlobalFeed
|
||||
const onEvent = (event: Event, relay: string) => {
|
||||
setQuoteEvents((prevEvents) => [...prevEvents, event]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const findUrl = comment.match(/\bhttps?:\/\/\S+/gi);
|
||||
if (findUrl && findUrl.length > 0) {
|
||||
setUrl(findUrl[0]);
|
||||
setFinalComment(finalComment.replace(findUrl[0], "").trim());
|
||||
}
|
||||
|
||||
const match = comment.match(/\bnostr:([a-z0-9]+)/i);
|
||||
const nostrQuoteID = match && match[1];
|
||||
if (nostrQuoteID && nostrQuoteID.length > 0) {
|
||||
let id_to_hex = String(nip19.decode(nostrQuoteID as string).data);
|
||||
subNoteOnce(id_to_hex, onEvent);
|
||||
setFinalComment(finalComment.replace("nostr:" + nostrQuoteID, "").trim());
|
||||
}
|
||||
|
||||
let newEmojiMap: Record<string, any> = {};
|
||||
eventdata.tags.forEach(tag => {
|
||||
if (tag[0] === "emoji") {
|
||||
newEmojiMap[`:${tag[1]}:`] = tag[2];
|
||||
}
|
||||
});
|
||||
// Update the state variable
|
||||
setEmojiMap(newEmojiMap);
|
||||
|
||||
}, [comment, finalComment]);
|
||||
|
||||
const getMetadataEvent = (event: Event) => {
|
||||
const metadataEvent = quoteEvents.find(
|
||||
(e) => e.pubkey === event.pubkey && e.kind === 0
|
||||
);
|
||||
if (metadataEvent) {
|
||||
return metadataEvent;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gap-2 flex flex-col break-words text-xs">
|
||||
<RichText text={finalComment} isExpanded={isExpanded} emojiMap={emojiMap} />
|
||||
{finalComment.length > 350 && (
|
||||
<button
|
||||
className="text-sm text-neutral-500"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? "...Read less" : "...Read more"}
|
||||
</button>
|
||||
)}
|
||||
{url !== "" && <LinkModal key={key} url={url} />}
|
||||
{quoteEvents[0] && quoteEvents.length > 0 && (
|
||||
<a href={`/thread/${nip19.noteEncode(quoteEvents[0].id)}`}>
|
||||
<QuoteEmbed
|
||||
key={key}
|
||||
event={quoteEvents[0]}
|
||||
metadata={getMetadataEvent(quoteEvents[0])}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentPreview;
|
95
client/src/Components/Modals/CheckMobile/CheckMobile.tsx
Normal file
95
client/src/Components/Modals/CheckMobile/CheckMobile.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { ArrowUpOnSquareIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
standalone?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const AddToHomeScreenPrompt: React.FC = () => {
|
||||
const [inMobileBrowser, setInMobileBrowser] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPWA = () => {
|
||||
// Check if the app is running as a PWA on Android
|
||||
const isAndroidPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.matchMedia('(display-mode: minimal-ui)').matches;
|
||||
|
||||
// Check if the app is running as a PWA on other platforms
|
||||
const isOtherPWA = window.navigator.standalone;
|
||||
|
||||
return !isAndroidPWA && !isOtherPWA;
|
||||
};
|
||||
|
||||
// Function to detect mobile browser
|
||||
const detectMobileBrowser = () => {
|
||||
return (
|
||||
(navigator.userAgent.match(/Android/i) ||
|
||||
navigator.userAgent.match(/webOS/i) ||
|
||||
navigator.userAgent.match(/iPhone/i) ||
|
||||
navigator.userAgent.match(/iPad/i) ||
|
||||
navigator.userAgent.match(/iPod/i) ||
|
||||
navigator.userAgent.match(/Windows Phone/i))
|
||||
);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setInMobileBrowser(Boolean(checkPWA() && detectMobileBrowser()));
|
||||
}, 2000); // 3000 milliseconds = 3 seconds
|
||||
|
||||
// Cleanup function to clear the timeout if the component unmounts before the timeout finishes
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (!inMobileBrowser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition appear show={inMobileBrowser} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="fixed inset-0 z-10 overflow-y-auto"
|
||||
onClose={() => setInMobileBrowser(false)}
|
||||
>
|
||||
<div className="min-h-screen px-4 text-center">
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-800 opacity-40" />
|
||||
|
||||
<span
|
||||
className="inline-block h-screen align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-neutral-900 rounded-lg m-2 border border-neutral-700 shadow-md flex justify-between items-center animate-slide-up">
|
||||
<div className="flex flex-col text-white">
|
||||
<span className="font-semibold">Stay Wired</span>
|
||||
<p className="text-xs">Add Wired to your home screen for a better experience</p>
|
||||
<ul className="list-none mt-2 text-sm">
|
||||
<li>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{'>'}</span> Click on <ArrowUpOnSquareIcon className="h-6 w-6 ml-1 text-blue-500" /> <span className="font-semibold text-blue-500">Share</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{'>'}</span> Click <PlusCircleIcon className="h-6 w-6 ml-1 text-blue-500" /> <span className="font-semibold text-blue-500">Add to Home Screen</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button className="absolute top-2 right-2" onClick={() => {setInMobileBrowser(!inMobileBrowser);}}>
|
||||
<XMarkIcon className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddToHomeScreenPrompt;
|
54
client/src/Components/Modals/Header.tsx
Normal file
54
client/src/Components/Modals/Header.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
BellIcon,
|
||||
HashtagIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export default function Header() {
|
||||
const location = useLocation();
|
||||
const pathParts = location.pathname.split('/');
|
||||
const secondLastPart = pathParts[pathParts.length - 2];
|
||||
const lastPathPart = secondLastPart === "thread" ? "/thread" : "/" + pathParts[pathParts.length - 1];
|
||||
|
||||
return (
|
||||
<header className="mx-auto px-4 sm:px-6 lg:px-8 py-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<a href="/">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/icon.png" className="h-12" alt="logo" />
|
||||
<span className="font-semibold text-white">
|
||||
{`~/WIRED${lastPathPart}>`}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<div>
|
||||
<a
|
||||
href="/hashtags"
|
||||
className="text-neutral-300 inline-flex gap-4 items-center pl-4"
|
||||
>
|
||||
<button>
|
||||
<HashtagIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</a>
|
||||
<a
|
||||
href="/notifications"
|
||||
className="text-neutral-300 inline-flex gap-4 items-center pl-4"
|
||||
>
|
||||
<button>
|
||||
<BellIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
className="text-neutral-300 inline-flex gap-4 items-center pl-4"
|
||||
>
|
||||
<button>
|
||||
<Cog6ToothIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
89
client/src/Components/Modals/NoteCard.tsx
Normal file
89
client/src/Components/Modals/NoteCard.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { FolderIcon, CpuChipIcon } from "@heroicons/react/24/outline";
|
||||
// import { parseContent } from "../../utils/content";
|
||||
import { Event, nip19 } from "nostr-tools";
|
||||
import { getMetadata } from "../../utils/getMetadata";
|
||||
// import { renderMedia } from "../../utils/FileUpload";
|
||||
import { getIconFromHash, timeAgo } from "../../utils/cardUtils";
|
||||
import { verifyPow } from "../../utils/mine";
|
||||
import { uniqBy } from "../../utils/otherUtils";
|
||||
import ContentPreview from "./CardModals/TextModal";
|
||||
import CardContainer from "./CardContainer";
|
||||
|
||||
interface CardProps {
|
||||
key?: string | number;
|
||||
event: Event;
|
||||
metadata: Event | null;
|
||||
replies: Event[];
|
||||
repliedTo?: Event[]
|
||||
type?: 'OP' | 'Reply' | 'Post';
|
||||
}
|
||||
|
||||
const PostCard = ({
|
||||
key,
|
||||
event,
|
||||
metadata,
|
||||
replies,
|
||||
repliedTo,
|
||||
type
|
||||
}: CardProps) => {
|
||||
// const { files } = parseContent(event);
|
||||
const icon = getIconFromHash(event.pubkey);
|
||||
const metadataParsed = metadata ? getMetadata(metadata) : null;
|
||||
|
||||
const handleClick = () => {
|
||||
if (type !== "OP") {
|
||||
window.location.href = `/thread/${nip19.noteEncode(event.id)}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardContainer>
|
||||
<div className={`flex flex-col gap-2`}>
|
||||
<div className={`flex flex-col break-words ${type !== "OP" ? 'hover:cursor-pointer' : ''}`} onClick={handleClick}>
|
||||
<ContentPreview key={event.id} eventdata={event} />
|
||||
</div>
|
||||
{repliedTo && <div className="flex items-center mt-1" >
|
||||
<span className="text-xs text-gray-500">Reply to: </span>
|
||||
{uniqBy(repliedTo, 'pubkey').map((event, index) => (
|
||||
<div key={index}>
|
||||
{event.kind === 0 ? (
|
||||
<img className={`h-5 w-5 rounded-full`} src={getMetadata(event)?.picture} />
|
||||
) : (
|
||||
<div className={`h-4 w-4 ${getIconFromHash(event.pubkey)} rounded-full`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
<div className={`flex justify-between items-center ${type !== "OP" ? 'hover:cursor-pointer' : ''}`} onClick={handleClick}>
|
||||
{metadataParsed ?
|
||||
<img
|
||||
key = {key}
|
||||
className={`h-5 w-5 rounded-full`}
|
||||
src={metadataParsed?.picture ?? icon}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"/>
|
||||
:
|
||||
<div className={`h-4 w-4 ${icon} rounded-full`} />
|
||||
}
|
||||
<div className="flex items-center ml-auto gap-2.5">
|
||||
<div className="inline-flex text-xs text-neutral-600 gap-0.5">
|
||||
<CpuChipIcon className="h-4 w-4" /> {verifyPow(event)}
|
||||
</div>
|
||||
<span className="text-neutral-700">·</span>
|
||||
<div className="text-xs font-semibold text-neutral-600">
|
||||
{timeAgo(event.created_at)}
|
||||
</div>
|
||||
<span className="text-neutral-700">·</span>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<FolderIcon className="h-4 w-4 text-neutral-600" />
|
||||
<span className="text-xs text-neutral-600">{replies.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostCard;
|
24
client/src/Components/Modals/Placeholder.tsx
Normal file
24
client/src/Components/Modals/Placeholder.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
const Placeholder = () => {
|
||||
|
||||
return (
|
||||
<div className="mx-auto gap-4 p-4">
|
||||
<div className="border border-blue-300 shadow rounded-md p-4 max-w-sm w-full mx-auto">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="rounded-full bg-slate-700 h-10 w-10"></div>
|
||||
<div className="flex-1 space-y-6 py-1">
|
||||
<div className="h-2 bg-slate-700 rounded"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="h-2 bg-slate-700 rounded col-span-2"></div>
|
||||
<div className="h-2 bg-slate-700 rounded col-span-1"></div>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Placeholder;
|
119
client/src/Components/Modals/RepostCard.tsx
Normal file
119
client/src/Components/Modals/RepostCard.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
// import CardContainer from "./CardContainer";
|
||||
import { CpuChipIcon } from "@heroicons/react/24/outline";
|
||||
// import { parseContent } from "../../utils/content";
|
||||
import { Event, nip19 } from "nostr-tools";
|
||||
import { getMetadata, Metadata } from "../../utils/getMetadata";
|
||||
// import { renderMedia } from "../../utils/FileUpload";
|
||||
import { getIconFromHash, timeAgo } from "../../utils/cardUtils";
|
||||
import { verifyPow } from "../../utils/mine";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { subNoteOnce } from "../../utils/subscriptions";
|
||||
import { useEffect, useState } from "react";
|
||||
import ContentPreview from "./CardModals/TextModal";
|
||||
|
||||
interface RepostProps {
|
||||
key?: string | number;
|
||||
event: Event;
|
||||
}
|
||||
|
||||
const RepostCard = ({
|
||||
key,
|
||||
event
|
||||
}: RepostProps) => {
|
||||
const repostedEvent = JSON.parse(event.content);
|
||||
// const { files } = parseContent(repostedEvent);
|
||||
const icon = getIconFromHash(event.pubkey);
|
||||
const navigate = useNavigate();
|
||||
const [cachedMetadataEvents, setCachedMetadataEvents] = useState<Event[]>(
|
||||
JSON.parse(localStorage.getItem("cachedMetadataEvents") || "[]")
|
||||
);
|
||||
const [metadata, setMetadata] = useState<Metadata>()
|
||||
|
||||
// Define your callback function for subGlobalFeed
|
||||
const onEvent = (event: Event, relay: string) => {
|
||||
const existingEvent = cachedMetadataEvents.find((e) => e.pubkey === event.pubkey)
|
||||
if (existingEvent) {
|
||||
setMetadata(getMetadata(existingEvent))
|
||||
}
|
||||
else if (!existingEvent && event.kind === 0 && event.pubkey === repostedEvent.pubkey && metadata == null) {
|
||||
setMetadata(getMetadata(event))
|
||||
|
||||
setCachedMetadataEvents((prevMetadataEvents) => {
|
||||
// Check if the event already exists in the cached metadata events
|
||||
const existingEvent = prevMetadataEvents.find((e) => e.id === event.id || e.pubkey === event.pubkey)
|
||||
if (!existingEvent) {
|
||||
// If the event doesn't exist, add it to the cached metadata events
|
||||
return [...prevMetadataEvents, event];
|
||||
} else if (existingEvent && existingEvent.created_at < event.created_at) {
|
||||
// Remove any existing metadata event with the same pubkey and id
|
||||
const updatedMetadataEvents = prevMetadataEvents.filter(
|
||||
(e) => e.id !== existingEvent.id
|
||||
);
|
||||
// Add the new metadata event
|
||||
return [...updatedMetadataEvents, event];
|
||||
}
|
||||
// If the event already exists, return the previous cached metadata events
|
||||
return prevMetadataEvents;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
subNoteOnce(repostedEvent.id, onEvent);
|
||||
}, [repostedEvent.id]);
|
||||
|
||||
// Save the cached metadataEvents to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("cachedMetadataEvents", JSON.stringify(cachedMetadataEvents));
|
||||
}, [cachedMetadataEvents]);
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/thread/${nip19.noteEncode(repostedEvent.id)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
<div className="ml-1 flex text-sm text-neutral-600 gap-2.5">
|
||||
Repost
|
||||
@
|
||||
<span className="inline-flex"><CpuChipIcon className="h-5 w-5" /> {verifyPow(event)}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-700">
|
||||
<div className="card break-inside-avoid h-min">
|
||||
<div className="card-body">
|
||||
<div className={`flex flex-col gap-2`}>
|
||||
<div className={`flex flex-col break-words hover:cursor-pointer`} onClick={handleClick}>
|
||||
<ContentPreview key={repostedEvent.id} eventdata={repostedEvent} />
|
||||
</div>
|
||||
<div className={`flex justify-between items-center hover:cursor-pointer`} onClick={handleClick}>
|
||||
{metadata ?
|
||||
<img
|
||||
key = {key}
|
||||
className={`h-5 w-5 rounded-full`}
|
||||
src={metadata?.picture ?? icon}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"/>
|
||||
:
|
||||
<div className={`h-4 w-4 ${icon} rounded-full`} />
|
||||
}
|
||||
<div className="flex items-center ml-auto gap-2.5">
|
||||
<div className="inline-flex text-xs text-neutral-600 gap-0.5">
|
||||
<CpuChipIcon className="h-4 w-4" /> {verifyPow(repostedEvent)}
|
||||
</div>
|
||||
<span className="text-neutral-700">·</span>
|
||||
<div className="text-xs font-semibold text-neutral-600">
|
||||
{timeAgo(repostedEvent.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepostCard;
|
57
client/src/Components/Routes/HashtagPage.tsx
Normal file
57
client/src/Components/Routes/HashtagPage.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import PostCard from "../Modals/NoteCard";
|
||||
import { verifyPow } from "../../utils/mine";
|
||||
import { Event } from "nostr-tools";
|
||||
import NewNoteCard from "../Forms/PostFormCard";
|
||||
import RepostCard from "../Modals/RepostCard";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useUniqEvents } from "../../hooks/useUniqEvents";
|
||||
|
||||
const DEFAULT_DIFFICULTY = 0;
|
||||
|
||||
const HashtagPage = () => {
|
||||
const { id } = useParams();
|
||||
const filterDifficulty = localStorage.getItem("filterHashtagDifficulty") || DEFAULT_DIFFICULTY;
|
||||
const { noteEvents, metadataEvents } = useUniqEvents(id as string, false);
|
||||
|
||||
const postEvents: Event[] = noteEvents
|
||||
.filter((event) =>
|
||||
verifyPow(event) >= Number(filterDifficulty) &&
|
||||
event.kind !== 0 &&
|
||||
(event.kind !== 1 || !event.tags.some((tag) => tag[0] === "e"))
|
||||
)
|
||||
|
||||
let sortedEvents = [...postEvents]
|
||||
.sort((a, b) => {
|
||||
// Sort by PoW in descending order
|
||||
const powDiff = verifyPow(b) - verifyPow(a);
|
||||
if (powDiff !== 0) return powDiff;
|
||||
|
||||
// If PoW is the same, sort by created_at in descending order
|
||||
return b.created_at - a.created_at;
|
||||
});
|
||||
|
||||
// Render the component
|
||||
return (
|
||||
<main className="text-white mb-20">
|
||||
<div className="w-full px-4 sm:px-0 sm:max-w-xl mx-auto my-2">
|
||||
<NewNoteCard hashtag={id as string} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
|
||||
{sortedEvents.map((event) => (
|
||||
event.kind === 1 ?
|
||||
<PostCard
|
||||
event={event}
|
||||
metadata={metadataEvents.find((e) => e.pubkey === event.pubkey && e.kind === 0) || null}
|
||||
replies={sortedEvents.filter((e) => e.tags.some((tag) => tag[0] === "e" && tag[1] === event.id))}
|
||||
/>
|
||||
:
|
||||
<RepostCard
|
||||
event={event}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default HashtagPage;
|
71
client/src/Components/Routes/Hashtags.tsx
Normal file
71
client/src/Components/Routes/Hashtags.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export const DefaultHashtags = ['asknostr', 'politics', 'technology', 'bitcoin', 'wired'];
|
||||
|
||||
const Hashtags = () => {
|
||||
const [addedHashtags, setAddedHashtags] = useState<string[]>(JSON.parse(localStorage.getItem('hashtags') as string) || []);
|
||||
const [newHashtag, setNewHashtag] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newHashtagArray = [...addedHashtags, newHashtag];
|
||||
setAddedHashtags(newHashtagArray);
|
||||
localStorage.setItem('addedBoards', JSON.stringify(newHashtagArray));
|
||||
};
|
||||
|
||||
const clearBoards = () => {
|
||||
localStorage.setItem('hashtags', JSON.stringify([]));
|
||||
setAddedHashtags([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page bg-black text-white p-8 flex flex-col h-full">
|
||||
<h1 className="text-lg font-semibold mb-4">Saved hashtags</h1>
|
||||
<div className="">
|
||||
{/* Map over DefaultBoards and addedBoards and display them */}
|
||||
<ul className='py-4'>
|
||||
{DefaultHashtags.map((hashtag, index) => (
|
||||
<li key={index}><a href={`/hashtag/${hashtag}`} className='hover:underline'>#{hashtag}</a></li>
|
||||
))}
|
||||
{addedHashtags.map((hashtag, index: number) => (
|
||||
<li key={index}><a href={`/hashtag/${hashtag}`} className='hover:underline'>#{hashtag}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-wrap -mx-2 my-4">
|
||||
<div className="w-full md:w-1/3 px-2 mb-4 md:mb-0">
|
||||
<label className="block text-xs mb-2" htmlFor="difficulty">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
Add Hashtag
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex">
|
||||
<input
|
||||
id="hashtag"
|
||||
type="string"
|
||||
placeholder={'Hashtag'}
|
||||
onChange={e => setNewHashtag(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-black border text-white font-bold py-2 px-4 rounded">
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearBoards}
|
||||
className="bg-black border text-white font-bold py-2 px-4 rounded mx-4">
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hashtags;
|
54
client/src/Components/Routes/Home.tsx
Normal file
54
client/src/Components/Routes/Home.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { verifyPow } from "../../utils/mine";
|
||||
import { Event } from "nostr-tools";
|
||||
import NewNoteCard from "../Forms/PostFormCard";
|
||||
import RepostCard from "../Modals/RepostCard";
|
||||
import { DEFAULT_DIFFICULTY } from "../../config";
|
||||
import { useUniqEvents } from "../../hooks/useUniqEvents";
|
||||
import PostCard from "../Modals/NoteCard";
|
||||
|
||||
const Home = () => {
|
||||
const filterDifficulty = localStorage.getItem("filterDifficulty") || DEFAULT_DIFFICULTY;
|
||||
const { noteEvents, metadataEvents } = useUniqEvents();
|
||||
|
||||
const postEvents: Event[] = noteEvents
|
||||
.filter((event) =>
|
||||
verifyPow(event) >= Number(filterDifficulty) &&
|
||||
event.kind !== 0 &&
|
||||
(event.kind !== 1 || !event.tags.some((tag) => tag[0] === "e" || tag[0] === "a"))
|
||||
)
|
||||
|
||||
let sortedEvents = [...postEvents]
|
||||
.sort((a, b) => {
|
||||
// Sort by PoW in descending order
|
||||
const powDiff = verifyPow(b) - verifyPow(a);
|
||||
if (powDiff !== 0) return powDiff;
|
||||
|
||||
// If PoW is the same, sort by created_at in descending order
|
||||
return b.created_at - a.created_at;
|
||||
});
|
||||
|
||||
// Render the component
|
||||
return (
|
||||
<main className="text-white mb-20">
|
||||
<div className="w-full px-4 sm:px-0 sm:max-w-xl mx-auto my-2">
|
||||
<NewNoteCard />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
|
||||
{sortedEvents.map((event) => (
|
||||
event.kind === 1 ?
|
||||
<PostCard
|
||||
event={event}
|
||||
metadata={metadataEvents.find((e) => e.pubkey === event.pubkey && e.kind === 0) || null}
|
||||
replies={sortedEvents.filter((e) => e.tags.some((tag) => tag[0] === "e" && tag[1] === event.id))}
|
||||
/>
|
||||
:
|
||||
<RepostCard
|
||||
event={event}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
100
client/src/Components/Routes/Notifications.tsx
Normal file
100
client/src/Components/Routes/Notifications.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import PostCard from "../Modals/NoteCard";
|
||||
import { Event } from "nostr-tools";
|
||||
import RepostCard from "../Modals/RepostCard";
|
||||
import { useUniqEvents } from "../../hooks/useUniqEvents";
|
||||
|
||||
const Notifications = () => {
|
||||
const [notifsView, setNotifsView] = useState(false);
|
||||
const { noteEvents, metadataEvents } = useUniqEvents(undefined,true);
|
||||
const storedKeys = JSON.parse(localStorage.getItem('usedKeys') || '[]');
|
||||
const storedPubkeys = storedKeys.map((key: any[]) => key[1]);
|
||||
|
||||
const postEvents = noteEvents
|
||||
.filter((event) =>
|
||||
event.kind !== 0 &&
|
||||
storedPubkeys.includes(event.pubkey)
|
||||
)
|
||||
|
||||
const sortedEvents = [...postEvents]
|
||||
.sort((a, b) =>
|
||||
b.created_at - a.created_at
|
||||
)
|
||||
|
||||
const mentions = noteEvents
|
||||
.filter((event) =>
|
||||
event.kind !== 0 &&
|
||||
event.tags.some((tag) => tag[0] === "p" && storedPubkeys.includes(tag[1]))
|
||||
)
|
||||
|
||||
const sortedMentions = [...mentions]
|
||||
.sort((a, b) =>
|
||||
b.created_at - a.created_at
|
||||
)
|
||||
|
||||
const toggleNotifs = useCallback(() => {
|
||||
setNotifsView(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const countReplies = (event: Event) => {
|
||||
return noteEvents.filter((e) => e.tags.some((tag) => tag[0] === "e" && tag[1] === event.id));
|
||||
};
|
||||
|
||||
// Render the component
|
||||
return (
|
||||
<main className="text-white mb-20">
|
||||
<div className="block sm:hidden">
|
||||
<label htmlFor="toggleC" className="p-4 flex items-center cursor-pointer">
|
||||
<div className="relative">
|
||||
<input
|
||||
id="toggleC"
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={notifsView}
|
||||
onChange={toggleNotifs}
|
||||
/>
|
||||
<div className="block bg-gray-600 w-8 h-4 rounded-full"></div>
|
||||
<div className={`dot absolute left-1 top-0.5 bg-white w-3 h-3 rounded-full transition ${notifsView ? 'transform translate-x-full bg-blue-400' : ''}`} ></div>
|
||||
</div>
|
||||
<div className={`ml-2 text-neutral-500 text-sm ${notifsView ? 'text-neutral-500' : ''}`}>
|
||||
{notifsView ? 'Mentions' : 'Prev Posts'}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className={`grid grid-cols-1 gap-4 px-4 flex-grow ${notifsView ? 'hidden sm:block' : ''}`}>
|
||||
<span>Your Recent Posts</span>
|
||||
{sortedEvents.map((event) => (
|
||||
event.kind === 1 ?
|
||||
<PostCard
|
||||
event={event}
|
||||
metadata={metadataEvents.find((e) => e.pubkey === event.pubkey && e.kind === 0) || null}
|
||||
replies={countReplies(event)}
|
||||
/>
|
||||
:
|
||||
<RepostCard
|
||||
event={event}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={`grid grid-cols-1 gap-4 px-4 flex-grow ${notifsView ? '' : 'hidden sm:block'}`}>
|
||||
<span>Mentions</span>
|
||||
{sortedMentions.map((event) => (
|
||||
event.kind === 1 ?
|
||||
<PostCard
|
||||
event={event}
|
||||
metadata={metadataEvents.find((e) => e.pubkey === event.pubkey && e.kind === 0) || null}
|
||||
replies={countReplies(event)}
|
||||
/>
|
||||
:
|
||||
<RepostCard
|
||||
event={event}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
208
client/src/Components/Routes/Settings.tsx
Normal file
208
client/src/Components/Routes/Settings.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CpuChipIcon } from '@heroicons/react/24/outline';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type TestResponse = {
|
||||
timeTaken: string;
|
||||
hashrate: string;
|
||||
};
|
||||
|
||||
const Settings = () => {
|
||||
const [filterDifficulty, setFilterDifficulty] = useState(localStorage.getItem('filterDifficulty') || 21);
|
||||
const [difficulty, setDifficulty] = useState(localStorage.getItem('difficulty') || 21);
|
||||
const [age, setAge] = useState(localStorage.getItem('age') || 24);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [powServer, setPowServer] = useState(localStorage.getItem('powserver') || '');
|
||||
const [testDiff, setTestDiff] = useState('21')
|
||||
const [testResult, setTestResult] = useState<TestResponse>()
|
||||
const [noteLink, setNoteLink] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
localStorage.setItem('filterDifficulty', String(filterDifficulty));
|
||||
localStorage.setItem('difficulty', String(difficulty));
|
||||
localStorage.setItem('powserver', String(powServer));
|
||||
localStorage.setItem('age', String(age));
|
||||
|
||||
const eventData = {
|
||||
difficulty: String(difficulty),
|
||||
filterDifficulty: String(filterDifficulty),
|
||||
powServer: String(powServer),
|
||||
age: String(age),
|
||||
};
|
||||
const event = new CustomEvent('settingsChanged', { detail: eventData });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
console.log(powServer)
|
||||
|
||||
const handleTest = () => {
|
||||
setTestResult({ timeTaken: '...', hashrate: '...' });
|
||||
console.log(powServer[0])
|
||||
if (powServer[0]) {
|
||||
const testRequest = {
|
||||
Difficulty: testDiff
|
||||
};
|
||||
|
||||
fetch(`${powServer}/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(testRequest)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(data);
|
||||
// handle the response data
|
||||
setTestResult(data)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page bg-black text-white p-8 flex flex-col h-full">
|
||||
<h1 className="text-lg font-semibold mb-4">Settings</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-wrap -mx-2 mb-4">
|
||||
<div className="w-full md:w-1/3 px-2 mb-4 md:mb-0">
|
||||
<label className="block text-xs mb-2" htmlFor="filterDifficulty">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
Proof-of-Work Filter:
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="filterDifficulty"
|
||||
type="number"
|
||||
value={filterDifficulty}
|
||||
onChange={e => setFilterDifficulty(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-1/3 px-2 mb-4 md:mb-0">
|
||||
<label className="block text-xs mb-2" htmlFor="difficulty">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
Post Difficulty {'('}<CpuChipIcon className="h-4 w-4" /> required to make post{')'}:
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="difficulty"
|
||||
type="number"
|
||||
value={difficulty}
|
||||
onChange={e => setDifficulty(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-1/3 px-2 mb-4 md:mb-0">
|
||||
<label className="block text-xs mb-2" htmlFor="difficulty">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
Thread Age Limit (hrs):
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="age"
|
||||
type="number"
|
||||
value={age}
|
||||
onChange={e => setAge(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pb-4'>
|
||||
<span onClick={() => setShowAdvancedSettings(!showAdvancedSettings)} className="">
|
||||
{">"} Advanced Settings
|
||||
</span>
|
||||
{showAdvancedSettings && (
|
||||
<><div className={`transition-height duration-200 ease-in-out overflow-hidden ${showAdvancedSettings ? 'h-auto' : 'h-0'} w-full md:w-1/3 px-2 mb-4 md:mb-0`}>
|
||||
<label className="block text-xs mb-2" htmlFor="powServer">
|
||||
Remote PoW Server:
|
||||
</label>
|
||||
<input
|
||||
id="powServer"
|
||||
type="text"
|
||||
value={powServer}
|
||||
onChange={e => setPowServer(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<label className="block text-xs mb-2" htmlFor="powServer">
|
||||
Test Your PoW Server (difficulty):
|
||||
</label>
|
||||
<input
|
||||
id="testAPI"
|
||||
type="text"
|
||||
value={testDiff}
|
||||
onChange={e => setTestDiff(e.target.value)}
|
||||
className="w-12 px-3 py-2 border rounded-md bg-black"
|
||||
/>
|
||||
<button type="button" onClick={handleTest} className="bg-black border text-white font-bold py-2 px-4 rounded">
|
||||
Test
|
||||
</button>
|
||||
{testResult && (
|
||||
<span>Time: {testResult.timeTaken}s with a hashrate of {testResult.hashrate}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-black border text-white font-bold py-2 px-4 rounded">
|
||||
Save Settings
|
||||
</button>
|
||||
</form>
|
||||
<div className="settings-page pt-10">
|
||||
<h1 className="text-lg font-semibold mb-4">Open Note</h1>
|
||||
<form onSubmit={(e) => {e.preventDefault(); navigate(`/thread/${noteLink}`);}}>
|
||||
<div className="flex flex-wrap -mx-2 mb-4">
|
||||
<div className="w-full md:w-1/3 px-2 mb-4 md:mb-0">
|
||||
<label className="block text-xs mb-2" htmlFor="filterDifficulty">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
Note ID:
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="noteIDinput"
|
||||
type="string"
|
||||
value={noteLink}
|
||||
onChange={e => setNoteLink(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-black border text-white font-bold py-2 px-4 rounded">
|
||||
Open
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="settings-page py-10">
|
||||
<h1 className="text-lg font-semibold mb-4">About</h1>
|
||||
<div className="flex flex-col">
|
||||
<p>The Anon Operation (TAO) is an anonymous-first agora, built upon the <a className="underline" href="https://nostr.com/">NOSTR protocol</a>.</p>
|
||||
<br />
|
||||
<p>TAO is built to facilitate unstoppable free speech on the internet.</p>
|
||||
<p>-PWA to be widely accessible with distribution via URLS, and to side-step App Store gatekeeping</p>
|
||||
<p>-Uses NOSTR as a censorship-resistant global "social" network</p>
|
||||
<p>-Employs Proof-of-Work (PoW) as a spam prevention mechanism, as opposed to Captcha, moderation or other verification methods</p>
|
||||
<br />
|
||||
<a href="https://github.com/smolgrrr/TAO">
|
||||
<img src="https://img.shields.io/github/stars/smolgrrr/TAO.svg?style=social" alt="Github Stars Badge" />
|
||||
</a>
|
||||
<div>
|
||||
<span>Found a bug? dm me: <a className="underline" href="https://njump.me/npub13azv2cf3kd3xdzcwqxlgcudjg7r9nzak37usnn7h374lkpvd6rcq4k8m54">doot</a> or <a className="underline" href="mailto:smolgrrr@protonmail.com">smolgrrr@protonmail.com</a></span>
|
||||
<img className="h-16" src="doot.jpeg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
195
client/src/Components/Routes/Thread.tsx
Normal file
195
client/src/Components/Routes/Thread.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useState } from "react";
|
||||
import { Event, nip19 } from "nostr-tools"
|
||||
import { subNote, subNotesOnce } from '../../utils/subscriptions';
|
||||
import { useEffect } from 'react';
|
||||
import { uniqBy } from '../../utils/otherUtils';
|
||||
import { DocumentTextIcon, FolderPlusIcon, DocumentDuplicateIcon, ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline';
|
||||
import PostCard from '../Modals/NoteCard';
|
||||
import Placeholder from '../Modals/Placeholder';
|
||||
import NewNoteCard from '../Forms/PostFormCard';
|
||||
import RepostNote from '../Forms/RepostNote';
|
||||
|
||||
type PostType = "" | "Reply" | "Quote" | undefined;
|
||||
|
||||
const Thread = () => {
|
||||
const { id } = useParams();
|
||||
const [events, setEvents] = useState<Event[]>([]); // Initialize state
|
||||
const [OPEvent, setOPEvent] = useState<Event>()
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showRepost, setShowRepost] = useState(false);
|
||||
const [postType, setPostType] = useState<PostType>("");
|
||||
const [hasRun, setHasRun] = useState(false);
|
||||
const [preOPEvents, setPreOPEvents] = useState(['']);
|
||||
// const filterDifficulty = useState(localStorage.getItem("filterDifficulty") || "20");
|
||||
// Load cached metadataEvents from localStorage
|
||||
const [cachedMetadataEvents, setCachedMetadataEvents] = useState<Event[]>(
|
||||
JSON.parse(localStorage.getItem("cachedMetadataEvents") || "[]")
|
||||
);
|
||||
|
||||
let decodeResult = nip19.decode(id as string);
|
||||
let hexID = decodeResult.data as string;
|
||||
|
||||
// Define your callback function for subGlobalFeed
|
||||
const onEvent = (event: Event, relay: string) => {
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
|
||||
// If the new event is a metadata event, add it to the cached metadata events
|
||||
if (event.kind === 0) {
|
||||
setCachedMetadataEvents((prevMetadataEvents) => {
|
||||
// Check if the event already exists in the cached metadata events
|
||||
const existingEvent = prevMetadataEvents.find((e) => e.id === event.id || e.pubkey === event.pubkey)
|
||||
if (!existingEvent) {
|
||||
// If the event doesn't exist, add it to the cached metadata events
|
||||
return [...prevMetadataEvents, event];
|
||||
} else if (existingEvent && existingEvent.created_at < event.created_at) {
|
||||
// Remove any existing metadata event with the same pubkey and id
|
||||
const updatedMetadataEvents = prevMetadataEvents.filter(
|
||||
(e) => e.id !== existingEvent.id
|
||||
);
|
||||
// Add the new metadata event
|
||||
return [...updatedMetadataEvents, event];
|
||||
}
|
||||
// If the event already exists, return the previous cached metadata events
|
||||
return prevMetadataEvents;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setHasRun(false)
|
||||
if (decodeResult.type === 'note') {
|
||||
// Call your subNote function or do whatever you need to do with id_to_hex
|
||||
subNote(hexID, onEvent);
|
||||
}
|
||||
}, [id]); // Empty dependency array means this useEffect runs once when the component mounts
|
||||
|
||||
// Save the cached metadataEvents to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("cachedMetadataEvents", JSON.stringify(cachedMetadataEvents));
|
||||
}, [cachedMetadataEvents]);
|
||||
|
||||
const uniqEvents = events.length > 0 ? uniqBy(events, "id") : [];
|
||||
const metadataEvents = [...cachedMetadataEvents, ...uniqEvents.filter(event => event.kind === 0)];
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRun && events.length > 0) {
|
||||
let OPEvent = uniqEvents.find(event => event.id === hexID);
|
||||
setOPEvent(OPEvent);
|
||||
|
||||
console.log(OPEvent)
|
||||
if (OPEvent && OPEvent.id !== hexID) {
|
||||
OPEvent = events.find(e => e.id === hexID) as Event;
|
||||
}
|
||||
|
||||
if (OPEvent) {
|
||||
setOPEvent(OPEvent);
|
||||
let OPNoteEvents = OPEvent.tags.filter(tag => tag[0] === 'e').map(tag => tag[1]);
|
||||
setHasRun(true);
|
||||
setPreOPEvents(OPNoteEvents)
|
||||
subNotesOnce(OPNoteEvents, onEvent)
|
||||
}
|
||||
}
|
||||
}, [uniqEvents, hasRun]);
|
||||
|
||||
const countReplies = (event: Event) => {
|
||||
return uniqEvents.filter(e => e.tags.some(tag => tag[0] === 'e' && tag[1] === event.id));
|
||||
}
|
||||
|
||||
const repliedList = (event: Event): Event[] => {
|
||||
return uniqEvents.filter(e => event.tags.some(tag => tag[0] === 'p' && tag[1] === e.pubkey));
|
||||
}
|
||||
|
||||
const earlierEvents = uniqEvents
|
||||
.filter(event =>
|
||||
event.kind === 1 &&
|
||||
preOPEvents.includes(event.id)
|
||||
).sort((a, b) => (b.created_at as any) - (a.created_at as any));
|
||||
|
||||
const displayedEvents = [...uniqEvents].slice(1)
|
||||
.filter(event =>
|
||||
event.kind === 1 &&
|
||||
!earlierEvents.map(e => e.id).includes(event.id) &&
|
||||
(OPEvent ? OPEvent.id !== event.id : true)
|
||||
).sort((a, b) => a.created_at - b.created_at);
|
||||
|
||||
if (uniqEvents.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Placeholder />
|
||||
<div className="col-span-full h-0.5 bg-neutral-900"/> {/* This is the white line separator */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<main className="bg-black text-white min-h-screen">
|
||||
<div className="w-full px-4 sm:px-0 sm:max-w-xl mx-auto my-2">
|
||||
{earlierEvents
|
||||
.filter(event => event.kind === 1)
|
||||
.sort((a, b) => a.created_at - b.created_at).map((event, index) => (
|
||||
<PostCard event={event} metadata={metadataEvents.find((e) => e.pubkey === event.pubkey && e.kind === 0) || null} replies={countReplies(event)} />
|
||||
))}
|
||||
{OPEvent && <PostCard event={OPEvent} metadata={metadataEvents.find((e) => e.pubkey === OPEvent.pubkey && e.kind === 0) || null} replies={countReplies(OPEvent)} type={'OP'}/>}
|
||||
</div>
|
||||
<div className="col-span-full flex justify-center space-x-16 pb-4">
|
||||
<DocumentTextIcon
|
||||
className="h-5 w-5 text-gray-200 cursor-pointer"
|
||||
onClick={() => {
|
||||
setShowForm(prevShowForm => !prevShowForm);
|
||||
setPostType('Reply');
|
||||
setShowRepost(false)
|
||||
}}
|
||||
/>
|
||||
<DocumentDuplicateIcon
|
||||
className="h-5 w-5 text-gray-200 cursor-pointer"
|
||||
onClick={() => {
|
||||
setShowRepost(prevShowRepost => !prevShowRepost);
|
||||
setShowForm(false);
|
||||
}}
|
||||
/>
|
||||
<FolderPlusIcon
|
||||
className="h-5 w-5 text-gray-200 cursor-pointer"
|
||||
onClick={() => {
|
||||
setShowForm(prevShowForm => !prevShowForm);
|
||||
setPostType('Quote');
|
||||
setShowRepost(false)
|
||||
}}
|
||||
/>
|
||||
<a href={`nostr:${id}`} target="_blank" rel="noopener noreferrer">
|
||||
<ArrowTopRightOnSquareIcon
|
||||
className="h-5 w-5 text-gray-200 cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
{(showForm && postType) &&
|
||||
<div className="w-full px-4 sm:px-0 sm:max-w-xl mx-auto my-2">
|
||||
<div className='text-center'>
|
||||
<span >{postType}-post</span>
|
||||
</div>
|
||||
<NewNoteCard refEvent={OPEvent} tagType={postType}/>
|
||||
</div>}
|
||||
{showRepost && OPEvent && <div className="w-full px-4 sm:px-0 sm:max-w-xl mx-auto my-2">
|
||||
<div className='text-center'>
|
||||
<span>Repost note</span>
|
||||
</div>
|
||||
<RepostNote refEvent={OPEvent}/>
|
||||
</div>}
|
||||
<div className="col-span-full h-0.5 bg-neutral-900"/> {/* This is the white line separator */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
||||
{displayedEvents.map((event, index) => (
|
||||
<PostCard
|
||||
key={index}
|
||||
event={event}
|
||||
metadata={metadataEvents.find((e) => e.pubkey === event.pubkey && e.kind === 0) || null}
|
||||
replies={displayedEvents.filter((e) => e.tags.some((tag) => tag[0] === "e" && tag[1] === event.id))}
|
||||
repliedTo={repliedList(event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Thread;
|
@ -1,13 +1,13 @@
|
||||
import CardContainer from "./CardContainer";
|
||||
import { FolderIcon, CpuChipIcon } from "@heroicons/react/24/outline";
|
||||
// import { parseContent } from "../../utils/content";
|
||||
import { Event, nip19 } from "nostr-tools";
|
||||
import { getMetadata } from "../../utils/getMetadata";
|
||||
import ContentPreview from "./CardModals/TextModal";
|
||||
// import { renderMedia } from "../../utils/FileUpload";
|
||||
import { getIconFromHash, timeAgo } from "../../utils/cardUtils";
|
||||
import { verifyPow } from "../../utils/mine";
|
||||
import { uniqBy } from "../../utils/otherUtils";
|
||||
import ContentPreview from "./CardModals/TextModal";
|
||||
import CardContainer from "./CardContainer";
|
||||
|
||||
interface CardProps {
|
||||
key?: string | number;
|
||||
|
@ -3,13 +3,13 @@ import { CpuChipIcon } from "@heroicons/react/24/outline";
|
||||
// import { parseContent } from "../../utils/content";
|
||||
import { Event, nip19 } from "nostr-tools";
|
||||
import { getMetadata, Metadata } from "../../utils/getMetadata";
|
||||
import ContentPreview from "./CardModals/TextModal";
|
||||
// import { renderMedia } from "../../utils/FileUpload";
|
||||
import { getIconFromHash, timeAgo } from "../../utils/cardUtils";
|
||||
import { verifyPow } from "../../utils/mine";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { subNoteOnce } from "../../utils/subscriptions";
|
||||
import { useEffect, useState } from "react";
|
||||
import ContentPreview from "./CardModals/TextModal";
|
||||
|
||||
interface RepostProps {
|
||||
key?: string | number;
|
||||
|
@ -1,10 +1,10 @@
|
||||
import PostCard from "../Modals/NoteCard";
|
||||
import { verifyPow } from "../../utils/mine";
|
||||
import { Event } from "nostr-tools";
|
||||
import NewNoteCard from "../Forms/PostFormCard";
|
||||
import RepostCard from "../Modals/RepostCard";
|
||||
import { DEFAULT_DIFFICULTY } from "../../config";
|
||||
import { useUniqEvents } from "../../hooks/useUniqEvents";
|
||||
import PostCard from "../Modals/NoteCard";
|
||||
|
||||
const Home = () => {
|
||||
const filterDifficulty = localStorage.getItem("filterDifficulty") || DEFAULT_DIFFICULTY;
|
||||
|
Loading…
Reference in New Issue
Block a user