This commit is contained in:
reya 2023-11-02 08:57:04 +07:00
parent 4a5ca8d030
commit a8c05ee5f5
10 changed files with 415 additions and 316 deletions

View File

@ -1,17 +1,17 @@
import React from 'react'; import React from "react";
import './App.css'; import "./App.css";
import Home from './components/Home'; import Home from "./components/Home";
import Settings from './components/Settings'; import Settings from "./components/Settings";
import SwipeableViews from 'react-swipeable-views'; import SwipeableViews from "react-swipeable-views";
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Thread from './components/Thread/Thread'; import Thread from "./components/Thread/Thread";
import Header from './components/Header/Header'; import Header from "./components/Header/Header";
function App() { function App() {
const [index, setIndex] = React.useState(1); const [index, setIndex] = React.useState(1);
const handleChangeIndex = (index: number) => { const handleChangeIndex = (index: number) => {
console.log("Changed index to:", index); // Add a log to see if this function is called console.log("Changed index to:", index); // Add a log to see if this function is called
setIndex(index); setIndex(index);
}; };
@ -21,17 +21,23 @@ function App() {
<Routes> <Routes>
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/home" element={<Home />} /> <Route path="/home" element={<Home />} />
<Route path='/thread/:id' element={<Thread />} /> <Route path="/thread/:id" element={<Thread />} />
<Route path="/" element={ <Route
<SwipeableViews index={index} onChangeIndex={handleChangeIndex}> path="/"
<Settings /> element={
<Home /> <SwipeableViews
</SwipeableViews> index={index}
} /> onChangeIndex={handleChangeIndex}
className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"
>
<Settings />
<Home />
</SwipeableViews>
}
/>
</Routes> </Routes>
</Router> </Router>
); );
} }
export default App; export default App;

View File

@ -1,25 +1,32 @@
import { Cog6ToothIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline'; import {
Cog6ToothIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/24/outline";
export default function Header() { export default function Header() {
return ( return (
<header className="hidden lg:block text-white p-4"> <header className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="container mx-auto flex justify-between items-center"> <div className="flex justify-between items-center h-16">
<a href='/'> <a href="/">
<div className="flex items-center"> <div className="flex items-center gap-2">
<img src="/tao.png" className="h-8" /> <img src="/tao.png" className="h-8" alt="logo" />
<span className="font-bold">The Anon Operation</span> <span className="font-semibold text-white text-sm">
</div> The Anon Operation
</a> </span>
<a href='/settings'> </div>
<button className="ml-auto pr-4"> </a>
<QuestionMarkCircleIcon className="h-6 w-6 text-transperant" /> <a
</button> href="/settings"
className="text-neutral-300 inline-flex gap-4 items-center"
<button className=""> >
<Cog6ToothIcon className="h-6 w-6 text-transperant" /> <button>
</button> <QuestionMarkCircleIcon className="h-5 w-5" />
</a> </button>
</div> <button>
</header> <Cog6ToothIcon className="h-5 w-5" />
); </button>
</a>
</div>
</header>
);
} }

View File

@ -1,16 +1,18 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import PostCard from './PostCard/PostCard'; import PostCard from "./PostCard/PostCard";
import NewThreadCard from './PostCard/NewThreadCard'; import NewThreadCard from "./PostCard/NewThreadCard";
import { getPow } from '../utils/mine'; import { getPow } from "../utils/mine";
import { Event } from 'nostr-tools'; import { Event } from "nostr-tools";
import { subGlobalFeed } from '../utils/subscriptions'; import { subGlobalFeed } from "../utils/subscriptions";
import { uniqBy } from '../utils/utils'; import { uniqBy } from "../utils/utils";
import PWAInstallPopup from './Modals/PWACheckModal'; import PWAInstallPopup from "./Modals/PWACheckModal";
const Home = () => { const Home = () => {
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [filterDifficulty, setFilterDifficulty] = useState(localStorage.getItem('filterDifficulty') || '20'); const [filterDifficulty, setFilterDifficulty] = useState(
const [inBrowser, setInBrowser] = useState(false) localStorage.getItem("filterDifficulty") || "20"
);
const [inBrowser, setInBrowser] = useState(false);
const onEvent = (event: Event) => { const onEvent = (event: Event) => {
setEvents((prevEvents) => [...prevEvents, event]); setEvents((prevEvents) => [...prevEvents, event]);
@ -33,43 +35,54 @@ const Home = () => {
// setInBrowser(true) // setInBrowser(true)
// } // }
window.addEventListener('difficultyChanged', handleDifficultyChange); window.addEventListener("difficultyChanged", handleDifficultyChange);
return () => { return () => {
window.removeEventListener('difficultyChanged', handleDifficultyChange); window.removeEventListener("difficultyChanged", handleDifficultyChange);
}; };
}, []); }, []);
const uniqEvents = events.length > 0 ? uniqBy(events, "id") : []; const uniqEvents = events.length > 0 ? uniqBy(events, "id") : [];
const filteredAndSortedEvents = uniqEvents const filteredAndSortedEvents = uniqEvents
.filter(event => .filter(
getPow(event.id) > Number(filterDifficulty) && (event) =>
event.kind === 1 && getPow(event.id) > Number(filterDifficulty) &&
!event.tags.some(tag => tag[0] === 'e') event.kind === 1 &&
!event.tags.some((tag) => tag[0] === "e")
) )
.sort((a, b) => (b.created_at as any) - (a.created_at as any)); .sort((a, b) => (b.created_at as any) - (a.created_at as any));
const getMetadataEvent = (event: Event) => { const getMetadataEvent = (event: Event) => {
const metadataEvent = uniqEvents.find(e => e.pubkey === event.pubkey && e.kind === 0); const metadataEvent = uniqEvents.find(
(e) => e.pubkey === event.pubkey && e.kind === 0
);
if (metadataEvent) { if (metadataEvent) {
return metadataEvent; return metadataEvent;
} }
return null; return null;
} };
const countReplies = (event: Event) => { const countReplies = (event: Event) => {
return uniqEvents.filter(e => e.tags.some(tag => tag[0] === 'e' && tag[1] === event.id)).length; return uniqEvents.filter((e) =>
} e.tags.some((tag) => tag[0] === "e" && tag[1] === event.id)
).length;
};
return ( return (
<main className="bg-black text-white min-h-screen"> <main className="text-white mb-20">
{/* {inBrowser && <PWAInstallPopup onClose={() => setInBrowser(false)} />} */} {/* {inBrowser && <PWAInstallPopup onClose={() => setInBrowser(false)} />} */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4"> <div className="w-full px-4 sm:px-0 sm:max-w-xl mx-auto my-16">
<NewThreadCard /> <NewThreadCard />
{filteredAndSortedEvents.map((event, index) => ( </div>
<PostCard key={event.id} event={event} metadata={getMetadataEvent(event)} replyCount={countReplies(event)}/> <div className="columns-1 md:columns-2 lg:columns-3 [column-fill:_balance] box-border gap-4">
{filteredAndSortedEvents.map((event) => (
<PostCard
key={event.id}
event={event}
metadata={getMetadataEvent(event)}
replyCount={countReplies(event)}
/>
))} ))}
</div> </div>
</main> </main>

View File

@ -1,25 +1,25 @@
import { parseContent } from '../../utils/content'; import { parseContent } from "../../utils/content";
import { Event } from 'nostr-tools'; import { Event } from "nostr-tools";
import { getMetadata, uniqBy } from '../../utils/utils'; import { getMetadata, uniqBy } from "../../utils/utils";
import ContentPreview from './TextModal'; import ContentPreview from "./TextModal";
import { renderMedia } from '../../utils/FileUpload'; import { renderMedia } from "../../utils/FileUpload";
const colorCombos = [ const colorCombos = [
'from-red-400 to-yellow-500', "from-red-400 to-yellow-500",
'from-green-400 to-blue-500', "from-green-400 to-blue-500",
'from-purple-400 to-pink-500', "from-purple-400 to-pink-500",
'from-yellow-400 to-orange-500', "from-yellow-400 to-orange-500",
'from-indigo-400 to-purple-500', "from-indigo-400 to-purple-500",
'from-pink-400 to-red-500', "from-pink-400 to-red-500",
'from-blue-400 to-indigo-500', "from-blue-400 to-indigo-500",
'from-orange-400 to-red-500', "from-orange-400 to-red-500",
'from-teal-400 to-green-500', "from-teal-400 to-green-500",
'from-cyan-400 to-teal-500', "from-cyan-400 to-teal-500",
'from-lime-400 to-green-500', "from-lime-400 to-green-500",
'from-amber-400 to-orange-500', "from-amber-400 to-orange-500",
'from-rose-400 to-pink-500', "from-rose-400 to-pink-500",
'from-violet-400 to-purple-500', "from-violet-400 to-purple-500",
'from-sky-400 to-cyan-500' "from-sky-400 to-cyan-500",
]; ];
const getColorFromHash = (id: string, colors: string[]): string => { const getColorFromHash = (id: string, colors: string[]): string => {
@ -35,7 +35,7 @@ const getColorFromHash = (id: string, colors: string[]): string => {
}; };
const timeAgo = (unixTime: number) => { const timeAgo = (unixTime: number) => {
const seconds = Math.floor((new Date().getTime() / 1000) - unixTime); const seconds = Math.floor(new Date().getTime() / 1000 - unixTime);
if (seconds < 60) return `now`; if (seconds < 60) return `now`;
@ -52,38 +52,50 @@ const timeAgo = (unixTime: number) => {
return `${weeks}w`; return `${weeks}w`;
}; };
const QuoteEmbed = ({ event, metadata }: { event: Event, metadata: Event | null}) => { const QuoteEmbed = ({
const { comment, file } = parseContent(event); event,
const colorCombo = getColorFromHash(event.pubkey, colorCombos); metadata,
}: {
event: Event;
metadata: Event | null;
}) => {
const { comment, file } = parseContent(event);
const colorCombo = getColorFromHash(event.pubkey, colorCombos);
let metadataParsed = null; let metadataParsed = null;
if (metadata !== null) { if (metadata !== null) {
metadataParsed = getMetadata(metadata); metadataParsed = getMetadata(metadata);
} }
return ( return (
<div className="p-1 bg-gradient-to-r from-black to-neutral-900 rounded-lg border border-neutral-800"> <div className="p-3 rounded-lg border border-neutral-700 bg-neutral-800">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center"> <div className="flex items-center gap-1.5">
{metadataParsed ? {metadataParsed ? (
<> <>
<img className={`h-5 w-5 rounded-full`} src={metadataParsed.picture} /> <img
<div className="ml-2 text-sm font-semibold">{metadataParsed.name}</div> className={`h-5 w-5 rounded-full`}
src={metadataParsed.picture}
alt=""
/>
<div className="text-sm font-medium">{metadataParsed.name}</div>
</> </>
: ) : (
<> <>
<div className={`h-5 w-5 bg-gradient-to-r ${colorCombo} rounded-full`} /> <div
<div className="ml-2 text-sm font-semibold">Anonymous</div> className={`h-5 w-5 bg-gradient-to-r ${colorCombo} rounded-full`}
/>
<div className="text-sm font-medium">Anonymous</div>
</> </>
} )}
</div>
</div> </div>
<div className="mr-2 flex flex-col break-words">
<ContentPreview key={event.id} comment={comment} />
</div>
{renderMedia(file)}
</div> </div>
<div className="flex flex-col break-words">
<ContentPreview key={event.id} comment={comment} />
</div>
{renderMedia(file)}
</div>
</div> </div>
); );
}; };

View File

@ -1,15 +1,15 @@
import QuoteEmbed from "./QuoteEmbed"; import QuoteEmbed from "./QuoteEmbed";
import { Event } from 'nostr-tools'; import { Event } from "nostr-tools";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { subNoteOnce } from '../../utils/subscriptions'; import { subNoteOnce } from "../../utils/subscriptions";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import LinkModal from "./LinkPreview"; import LinkModal from "./LinkPreview";
const ContentPreview = ({ key, comment }: { key: string, comment: string }) => { const ContentPreview = ({ key, comment }: { key: string; comment: string }) => {
const [finalComment, setFinalComment] = useState(comment) const [finalComment, setFinalComment] = useState(comment);
const [quoteEvents, setQuoteEvents] = useState<Event[]>([]); // Initialize state const [quoteEvents, setQuoteEvents] = useState<Event[]>([]); // Initialize state
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [url, setUrl] = useState('') const [url, setUrl] = useState("");
// Define your callback function for subGlobalFeed // Define your callback function for subGlobalFeed
const onEvent = (event: Event, relay: string) => { const onEvent = (event: Event, relay: string) => {
@ -19,8 +19,8 @@ const ContentPreview = ({ key, comment }: { key: string, comment: string }) => {
useEffect(() => { useEffect(() => {
const findUrl = comment.match(/\bhttps?:\/\/\S+/gi); const findUrl = comment.match(/\bhttps?:\/\/\S+/gi);
if (findUrl && findUrl.length > 0) { if (findUrl && findUrl.length > 0) {
setUrl(findUrl[0]) setUrl(findUrl[0]);
setFinalComment(finalComment.replace(findUrl[0], '').trim()) setFinalComment(finalComment.replace(findUrl[0], "").trim());
} }
const match = comment.match(/\bnostr:([a-z0-9]+)/i); const match = comment.match(/\bnostr:([a-z0-9]+)/i);
@ -28,39 +28,43 @@ const ContentPreview = ({ key, comment }: { key: string, comment: string }) => {
if (nostrQuoteID && nostrQuoteID.length > 0) { if (nostrQuoteID && nostrQuoteID.length > 0) {
let id_to_hex = String(nip19.decode(nostrQuoteID as string).data); let id_to_hex = String(nip19.decode(nostrQuoteID as string).data);
subNoteOnce(id_to_hex, onEvent); subNoteOnce(id_to_hex, onEvent);
setFinalComment(finalComment.replace('nostr:' + nostrQuoteID, '').trim()) setFinalComment(finalComment.replace("nostr:" + nostrQuoteID, "").trim());
} }
}, [comment, finalComment]); }, [comment, finalComment]);
const getMetadataEvent = (event: Event) => { const getMetadataEvent = (event: Event) => {
const metadataEvent = quoteEvents.find(e => e.pubkey === event.pubkey && e.kind === 0); const metadataEvent = quoteEvents.find(
(e) => e.pubkey === event.pubkey && e.kind === 0
);
if (metadataEvent) { if (metadataEvent) {
return metadataEvent; return metadataEvent;
} }
return null; return null;
} };
return ( return (
<div className="mr-2 flex flex-col break-words text-sm"> <div className="gap-2 flex flex-col break-words text-sm">
{isExpanded ? finalComment : finalComment.slice(0, 240)} {isExpanded ? finalComment : finalComment.slice(0, 240)}
{finalComment.length > 240 && ( {finalComment.length > 240 && (
<button <button
className="text-gray-500 text-sm text-neutral-500" className="text-sm text-neutral-500"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
> >
{isExpanded ? '...Read less' : '...Read more'} {isExpanded ? "...Read less" : "...Read more"}
</button> </button>
)} )}
{url !== '' && ( {url !== "" && <LinkModal key={key} url={url} />}
<LinkModal key={key} url={url} />
)}
{quoteEvents[0] && quoteEvents.length > 0 && ( {quoteEvents[0] && quoteEvents.length > 0 && (
<a href={`/thread/${nip19.noteEncode(quoteEvents[0].id)}`}> <a href={`/thread/${nip19.noteEncode(quoteEvents[0].id)}`}>
<QuoteEmbed key={key} event={quoteEvents[0]} metadata={getMetadataEvent(quoteEvents[0])} /> <QuoteEmbed
key={key}
event={quoteEvents[0]}
metadata={getMetadataEvent(quoteEvents[0])}
/>
</a> </a>
)} )}
</div> </div>
); );
} };
export default ContentPreview; export default ContentPreview;

View File

@ -1,9 +1,9 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from "react";
export default function CardContainer({ children }: PropsWithChildren) { export default function CardContainer({ children }: PropsWithChildren) {
return ( return (
<div className="card bg-gradient-to-r from-black to-neutral-950 shadow-lg shadow-black"> <div className="card break-inside-avoid mb-4 bg-neutral-900 border border-transparent hover:border-neutral-800">
<div className="card-body p-4">{children}</div> <div className="card-body">{children}</div>
</div> </div>
); );
} }

View File

@ -1,22 +1,31 @@
import CardContainer from './CardContainer'; import CardContainer from "./CardContainer";
import { ArrowUpTrayIcon, CpuChipIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; import {
import { XCircleIcon } from '@heroicons/react/24/solid'; ArrowUpTrayIcon,
import { useState, useEffect, useMemo } from 'react'; CpuChipIcon,
import { generatePrivateKey, getPublicKey, finishEvent } from 'nostr-tools'; ArrowPathIcon,
import { publish } from '../../utils/relays'; } from "@heroicons/react/24/outline";
import FileUpload from '../../utils/FileUpload'; import { XCircleIcon } from "@heroicons/react/24/solid";
import { renderMedia } from '../../utils/FileUpload'; import { useState, useEffect, useMemo } from "react";
import { generatePrivateKey, getPublicKey, finishEvent } from "nostr-tools";
import { publish } from "../../utils/relays";
import FileUpload from "../../utils/FileUpload";
import { renderMedia } from "../../utils/FileUpload";
const NewThreadCard: React.FC = () => { const NewThreadCard: React.FC = () => {
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [file, setFile] = useState(""); const [file, setFile] = useState("");
const [sk, setSk] = useState(generatePrivateKey()); const [sk, setSk] = useState(generatePrivateKey());
const [difficulty, setDifficulty] = useState(localStorage.getItem('difficulty') || '21'); const [difficulty, setDifficulty] = useState(
localStorage.getItem("difficulty") || "21"
);
const [uploadingFile, setUploadingFile] = useState(false); const [uploadingFile, setUploadingFile] = useState(false);
const [messageFromWorker, setMessageFromWorker] = useState(null); const [messageFromWorker, setMessageFromWorker] = useState(null);
const [doingWorkProp, setDoingWorkProp] = useState(false); const [doingWorkProp, setDoingWorkProp] = useState(false);
// Initialize the worker outside of any effects // Initialize the worker outside of any effects
const worker = useMemo(() => new Worker(new URL('../../powWorker', import.meta.url)), []); const worker = useMemo(
() => new Worker(new URL("../../powWorker", import.meta.url)),
[]
);
useEffect(() => { useEffect(() => {
worker.onmessage = (event) => { worker.onmessage = (event) => {
@ -29,10 +38,10 @@ const NewThreadCard: React.FC = () => {
setDifficulty(difficulty); setDifficulty(difficulty);
}; };
window.addEventListener('difficultyChanged', handleDifficultyChange); window.addEventListener("difficultyChanged", handleDifficultyChange);
return () => { return () => {
window.removeEventListener('difficultyChanged', handleDifficultyChange); window.removeEventListener("difficultyChanged", handleDifficultyChange);
}; };
}, []); }, []);
@ -46,12 +55,12 @@ const NewThreadCard: React.FC = () => {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
pubkey: getPublicKey(sk), pubkey: getPublicKey(sk),
}, },
difficulty difficulty,
}); });
}; };
useEffect(() => { useEffect(() => {
setDoingWorkProp(false) setDoingWorkProp(false);
if (messageFromWorker) { if (messageFromWorker) {
try { try {
const signedEvent = finishEvent(messageFromWorker, sk); const signedEvent = finishEvent(messageFromWorker, sk);
@ -66,17 +75,17 @@ const NewThreadCard: React.FC = () => {
worker.terminate(); worker.terminate();
}; };
} catch (error) { } catch (error) {
setComment(error + ' ' + comment); setComment(error + " " + comment);
} }
} }
}, [messageFromWorker]); }, [messageFromWorker]);
async function attachFile(file_input: File | null) { async function attachFile(file_input: File | null) {
setUploadingFile(true); // start loading setUploadingFile(true); // start loading
try { try {
if (file_input) { if (file_input) {
const rx = await FileUpload(file_input); const rx = await FileUpload(file_input);
setUploadingFile(false); // stop loading setUploadingFile(false); // stop loading
if (rx.url) { if (rx.url) {
setFile(rx.url); setFile(rx.url);
} else if (rx?.error) { } else if (rx?.error) {
@ -84,86 +93,100 @@ const NewThreadCard: React.FC = () => {
} }
} }
} catch (error: unknown) { } catch (error: unknown) {
setUploadingFile(false); // stop loading setUploadingFile(false); // stop loading
if (error instanceof Error) { if (error instanceof Error) {
setFile(error?.message); setFile(error?.message);
} }
} }
} }
return ( return (
<> <form
<CardContainer> name="post"
<form method="post"
name="post" encType="multipart/form-data"
method="post" className=""
encType="multipart/form-data" onSubmit={(event) => {
className="" handleSubmit(event);
onSubmit={(event) => { setDoingWorkProp(true);
handleSubmit(event); }}
setDoingWorkProp(true); >
}} <input type="hidden" name="MAX_FILE_SIZE" defaultValue={4194304} />
> <div
<input type="hidden" name="MAX_FILE_SIZE" defaultValue={4194304} /> id="togglePostFormLink"
<div id="togglePostFormLink" className="text-lg font-semibold"> className="text-lg text-neutral-700 text-center mb-2 font-semibold"
Start a New Thread >
Start a New Thread
</div>
<div className="px-4 pt-4 flex flex-col bg-neutral-900 border border-neutral-800 rounded-lg">
<textarea
name="com"
wrap="soft"
className="shadow-lg w-full px-4 py-3 h-28 rounded-md outline-none focus:outline-none bg-neutral-800 border border-neutral-700 text-white placeholder:text-neutral-500"
placeholder="Shitpost here..."
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<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> </div>
<div> <div>
<textarea <div className="relative">
name="com" {file !== "" && (
wrap="soft" <button onClick={() => setFile("")}>
className="w-full p-2 rounded bg-gradient-to-r from-blue-900 to-cyan-500 text-white border-none placeholder-blue-300" <XCircleIcon className="h-10 w-10 absolute shadow z-100 text-blue-500" />
placeholder='Shitpost here...' </button>
value={comment} )}
onChange={(e) => setComment(e.target.value)} {renderMedia(file)}
/>
</div>
<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="flex justify-between items-center">
<div className="flex items-center">
<ArrowUpTrayIcon
className="h-6 w-6 text-white cursor-pointer"
onClick={() => document.getElementById('file_input')?.click()}
/>
<input
type="file"
name="file_input"
id="file_input"
style={{ display: 'none' }}
onChange={(e) => {
const file_input = e.target.files?.[0];
if (file_input) {
attachFile(file_input);
}
}}
/>
{uploadingFile ? (
<div className='flex animate-spin text-sm text-gray-300'>
<ArrowPathIcon className="h-4 w-4 ml-auto" />
</div>
) : null}
</div> </div>
<span className="flex items-center"><CpuChipIcon className="h-6 w-6 text-white" />: {difficulty}</span> <div className="flex items-center gap-4">
<button type="submit" className="px-4 py-2 bg-gradient-to-r from-cyan-900 to-blue-500 rounded text-white font-semibold"> <div className="flex items-center">
Submit <ArrowUpTrayIcon
</button> className="h-4 w-4 text-neutral-400 cursor-pointer"
</div> onClick={() => document.getElementById("file_input")?.click()}
{doingWorkProp ? ( />
<div className='flex animate-pulse text-sm text-gray-300'> <input
<CpuChipIcon className="h-4 w-4 ml-auto" /> type="file"
<span>Generating Proof-of-Work...</span> name="file_input"
id="file_input"
style={{ display: "none" }}
onChange={(e) => {
const file_input = e.target.files?.[0];
if (file_input) {
attachFile(file_input);
}
}}
/>
{uploadingFile ? (
<div className="flex animate-spin text-sm text-gray-300">
<ArrowPathIcon className="h-4 w-4 ml-auto" />
</div>
) : null}
</div>
<button
type="submit"
className="h-9 inline-flex items-center justify-center px-4 bg-blue-500 hover:bg-blue-600 rounded-lg text-white font-medium text-sm"
>
Submit
</button>
</div> </div>
) : null} {doingWorkProp ? (
<div id="postFormError" className="text-red-500" /> <div className="flex animate-pulse text-sm text-gray-300">
</form> <CpuChipIcon className="h-4 w-4 ml-auto" />
</CardContainer> <span>Generating Proof-of-Work...</span>
</> </div>
) : null}
</div>
</div>
</div>
<div id="postFormError" className="text-red-500" />
</form>
); );
}; };

View File

@ -1,28 +1,28 @@
import CardContainer from './CardContainer'; import CardContainer from "./CardContainer";
import { FolderIcon } from '@heroicons/react/24/outline'; import { ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline";
import { parseContent } from '../../utils/content'; import { parseContent } from "../../utils/content";
import { Event } from 'nostr-tools'; import { Event } from "nostr-tools";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import { getMetadata } from '../../utils/utils'; import { getMetadata } from "../../utils/utils";
import ContentPreview from'../Modals/TextModal'; import ContentPreview from "../Modals/TextModal";
import { renderMedia } from '../../utils/FileUpload'; import { renderMedia } from "../../utils/FileUpload";
const colorCombos = [ const colorCombos = [
'from-red-400 to-yellow-500', "from-red-400 to-yellow-500",
'from-green-400 to-blue-500', "from-green-400 to-blue-500",
'from-purple-400 to-pink-500', "from-purple-400 to-pink-500",
'from-yellow-400 to-orange-500', "from-yellow-400 to-orange-500",
'from-indigo-400 to-purple-500', "from-indigo-400 to-purple-500",
'from-pink-400 to-red-500', "from-pink-400 to-red-500",
'from-blue-400 to-indigo-500', "from-blue-400 to-indigo-500",
'from-orange-400 to-red-500', "from-orange-400 to-red-500",
'from-teal-400 to-green-500', "from-teal-400 to-green-500",
'from-cyan-400 to-teal-500', "from-cyan-400 to-teal-500",
'from-lime-400 to-green-500', "from-lime-400 to-green-500",
'from-amber-400 to-orange-500', "from-amber-400 to-orange-500",
'from-rose-400 to-pink-500', "from-rose-400 to-pink-500",
'from-violet-400 to-purple-500', "from-violet-400 to-purple-500",
'from-sky-400 to-cyan-500' "from-sky-400 to-cyan-500",
]; ];
const getColorFromHash = (id: string, colors: string[]): string => { const getColorFromHash = (id: string, colors: string[]): string => {
@ -38,7 +38,7 @@ const getColorFromHash = (id: string, colors: string[]): string => {
}; };
const timeAgo = (unixTime: number) => { const timeAgo = (unixTime: number) => {
const seconds = Math.floor((new Date().getTime() / 1000) - unixTime); const seconds = Math.floor(new Date().getTime() / 1000 - unixTime);
if (seconds < 60) return `now`; if (seconds < 60) return `now`;
@ -55,7 +55,17 @@ const timeAgo = (unixTime: number) => {
return `${weeks}w`; return `${weeks}w`;
}; };
const PostCard = ({ key, event, metadata, replyCount }: { key: string, event: Event, metadata: Event | null, replyCount: number }) => { const PostCard = ({
key,
event,
metadata,
replyCount,
}: {
key: string;
event: Event;
metadata: Event | null;
replyCount: number;
}) => {
let { comment, file } = parseContent(event); let { comment, file } = parseContent(event);
const colorCombo = getColorFromHash(event.pubkey, colorCombos); const colorCombo = getColorFromHash(event.pubkey, colorCombos);
@ -65,39 +75,55 @@ const PostCard = ({ key, event, metadata, replyCount }: { key: string, event: Ev
} }
return ( return (
<> <CardContainer>
<CardContainer> <a href={`/thread/${nip19.noteEncode(event.id)}`}>
<a href={`/thread/${nip19.noteEncode(event.id)}`}> <div className="flex flex-col gap-2">
<div className="flex flex-col"> <div className="flex justify-between items-center">
<div className="flex justify-between items-center"> <div className="flex items-center gap-2.5">
<div className="flex items-center"> {metadataParsed ? (
{metadataParsed ? <>
<> <img
<img className={`h-8 w-8 rounded-full`} src={metadataParsed.picture} /> className={`h-8 w-8 rounded-full`}
<div className="ml-2 text-md font-semibold">{metadataParsed.name}</div> src={metadataParsed.picture}
</> alt=""
: loading="lazy"
<> decoding="async"
<div className={`h-8 w-8 bg-gradient-to-r ${colorCombo} rounded-full`} /> />
<div className="ml-2 text-md font-semibold">Anonymous</div> <div className="text-md font-semibold">
</> {metadataParsed.name}
} </div>
</div> </>
<div className="flex items-center ml-auto"> ) : (
<div className="text-xs text-gray-500">{event.id.match(/^0*([^\0]{2})/)?.[0] || 0}</div> &nbsp; <>
<div className="text-xs font-semibold text-gray-500 mr-2"> {timeAgo(event.created_at)}</div> <div
<FolderIcon className="h-5 w-5 mr-1 text-gray-500" /> className={`h-7 w-7 bg-gradient-to-r ${colorCombo} rounded-full`}
<span className="text-xs text-gray-500">{replyCount}</span> />
</div> <div className="text-sm font-semibold">Anonymous</div>
</>
)}
</div> </div>
<div className="mr-2 flex flex-col break-words"> <div className="flex items-center ml-auto gap-2.5">
<ContentPreview key={event.id} comment={comment} /> <div className="text-xs text-neutral-600">
{event.id.match(/^0*([^\0]{2})/)?.[0] || 0}
</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.5">
<ChatBubbleLeftEllipsisIcon className="h-4 w-4 text-neutral-600" />
<span className="text-xs text-neutral-600">{replyCount}</span>
</div>
</div> </div>
</div> </div>
</a> <div className="flex flex-col break-words">
{renderMedia(file)} <ContentPreview key={event.id} comment={comment} />
</CardContainer> </div>
</> </div>
</a>
{renderMedia(file)}
</CardContainer>
); );
}; };

View File

@ -20,7 +20,7 @@ code {
@layer components { @layer components {
.card { .card {
border-radius: theme('borderRadius.lg'); border-radius: theme('borderRadius.lg');
padding: theme('spacing.6'); padding: theme('spacing.4');
} }
/* ... */ /* ... */
} }

View File

@ -4,11 +4,11 @@ export interface UploadResult {
} }
/** /**
* Upload file to void.cat * Upload file to void.cat
* https://void.cat/swagger/index.html * https://void.cat/swagger/index.html
*/ */
export default async function FileUpload(file: File ): Promise<UploadResult> { export default async function FileUpload(file: File): Promise<UploadResult> {
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
const req = await fetch("https://void.cat/upload", { const req = await fetch("https://void.cat/upload", {
@ -23,10 +23,10 @@ export default async function FileUpload(file: File ): Promise<UploadResult> {
}, },
}); });
if (req.ok) { if (req.ok) {
let rsp: VoidUploadResponse = await req.json(); let rsp: VoidUploadResponse = await req.json();
const fileExtension = file.name.split('.').pop(); // Extracting the file extension const fileExtension = file.name.split(".").pop(); // Extracting the file extension
const resultUrl = `https://void.cat/d/${rsp.file?.id}.${fileExtension}`; const resultUrl = `https://void.cat/d/${rsp.file?.id}.${fileExtension}`;
return {url: resultUrl}; return { url: resultUrl };
} }
return { return {
error: "Upload failed", error: "Upload failed",
@ -36,48 +36,56 @@ export default async function FileUpload(file: File ): Promise<UploadResult> {
export const renderMedia = (file: string) => { export const renderMedia = (file: string) => {
if (file && (file.endsWith(".mp4") || file.endsWith(".webm"))) { if (file && (file.endsWith(".mp4") || file.endsWith(".webm"))) {
return ( return (
<video controls className="thumb"> <video
controls
muted
preload="metadata"
className="thumb mt-2 rounded-md w-full ring-1 ring-neutral-800"
>
<source src={file} type="video/mp4" /> <source src={file} type="video/mp4" />
</video> </video>
); );
} else if (!file.includes("http")) { } else if (!file.includes("http")) {
return ( return <></>;
<></>
);
} else { } else {
return ( return (
<img alt="Invalid thread" loading="lazy" className="thumb" src={file} /> <img
alt="Invalid thread"
loading="lazy"
className="thumb mt-2 rounded-md w-full ring-1 ring-neutral-800"
src={file}
/>
); );
} }
}; };
export interface UploadResult { export interface UploadResult {
url?: string; url?: string;
error?: string; error?: string;
} }
export type VoidUploadResponse = { export type VoidUploadResponse = {
ok: boolean, ok: boolean;
file?: VoidFile, file?: VoidFile;
errorMessage?: string, errorMessage?: string;
} };
export type VoidFile = { export type VoidFile = {
id: string, id: string;
meta?: VoidFileMeta meta?: VoidFileMeta;
} };
export type VoidFileMeta = { export type VoidFileMeta = {
version: number, version: number;
id: string, id: string;
name?: string, name?: string;
size: number, size: number;
uploaded: Date, uploaded: Date;
description?: string, description?: string;
mimeType?: string, mimeType?: string;
digest?: string, digest?: string;
url?: string, url?: string;
expires?: Date, expires?: Date;
storage?: string, storage?: string;
encryptionParams?: string, encryptionParams?: string;
} };