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 './App.css';
import Home from './components/Home';
import Settings from './components/Settings';
import SwipeableViews from 'react-swipeable-views';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Thread from './components/Thread/Thread';
import Header from './components/Header/Header';
import React from "react";
import "./App.css";
import Home from "./components/Home";
import Settings from "./components/Settings";
import SwipeableViews from "react-swipeable-views";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Thread from "./components/Thread/Thread";
import Header from "./components/Header/Header";
function App() {
const [index, setIndex] = React.useState(1);
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);
};
@ -21,17 +21,23 @@ function App() {
<Routes>
<Route path="/settings" element={<Settings />} />
<Route path="/home" element={<Home />} />
<Route path='/thread/:id' element={<Thread />} />
<Route path="/" element={
<SwipeableViews index={index} onChangeIndex={handleChangeIndex}>
<Settings />
<Home />
</SwipeableViews>
} />
<Route path="/thread/:id" element={<Thread />} />
<Route
path="/"
element={
<SwipeableViews
index={index}
onChangeIndex={handleChangeIndex}
className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"
>
<Settings />
<Home />
</SwipeableViews>
}
/>
</Routes>
</Router>
);
}
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() {
return (
<header className="hidden lg:block text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<a href='/'>
<div className="flex items-center">
<img src="/tao.png" className="h-8" />
<span className="font-bold">The Anon Operation</span>
</div>
</a>
<a href='/settings'>
<button className="ml-auto pr-4">
<QuestionMarkCircleIcon className="h-6 w-6 text-transperant" />
</button>
<button className="">
<Cog6ToothIcon className="h-6 w-6 text-transperant" />
</button>
</a>
</div>
</header>
);
return (
<header className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<a href="/">
<div className="flex items-center gap-2">
<img src="/tao.png" className="h-8" alt="logo" />
<span className="font-semibold text-white text-sm">
The Anon Operation
</span>
</div>
</a>
<a
href="/settings"
className="text-neutral-300 inline-flex gap-4 items-center"
>
<button>
<QuestionMarkCircleIcon className="h-5 w-5" />
</button>
<button>
<Cog6ToothIcon className="h-5 w-5" />
</button>
</a>
</div>
</header>
);
}

View File

@ -1,16 +1,18 @@
import { useEffect, useState } from 'react';
import PostCard from './PostCard/PostCard';
import NewThreadCard from './PostCard/NewThreadCard';
import { getPow } from '../utils/mine';
import { Event } from 'nostr-tools';
import { subGlobalFeed } from '../utils/subscriptions';
import { uniqBy } from '../utils/utils';
import PWAInstallPopup from './Modals/PWACheckModal';
import { useEffect, useState } from "react";
import PostCard from "./PostCard/PostCard";
import NewThreadCard from "./PostCard/NewThreadCard";
import { getPow } from "../utils/mine";
import { Event } from "nostr-tools";
import { subGlobalFeed } from "../utils/subscriptions";
import { uniqBy } from "../utils/utils";
import PWAInstallPopup from "./Modals/PWACheckModal";
const Home = () => {
const [events, setEvents] = useState<Event[]>([]);
const [filterDifficulty, setFilterDifficulty] = useState(localStorage.getItem('filterDifficulty') || '20');
const [inBrowser, setInBrowser] = useState(false)
const [filterDifficulty, setFilterDifficulty] = useState(
localStorage.getItem("filterDifficulty") || "20"
);
const [inBrowser, setInBrowser] = useState(false);
const onEvent = (event: Event) => {
setEvents((prevEvents) => [...prevEvents, event]);
@ -33,43 +35,54 @@ const Home = () => {
// setInBrowser(true)
// }
window.addEventListener('difficultyChanged', handleDifficultyChange);
window.addEventListener("difficultyChanged", handleDifficultyChange);
return () => {
window.removeEventListener('difficultyChanged', handleDifficultyChange);
window.removeEventListener("difficultyChanged", handleDifficultyChange);
};
}, []);
const uniqEvents = events.length > 0 ? uniqBy(events, "id") : [];
const filteredAndSortedEvents = uniqEvents
.filter(event =>
getPow(event.id) > Number(filterDifficulty) &&
event.kind === 1 &&
!event.tags.some(tag => tag[0] === 'e')
.filter(
(event) =>
getPow(event.id) > Number(filterDifficulty) &&
event.kind === 1 &&
!event.tags.some((tag) => tag[0] === "e")
)
.sort((a, b) => (b.created_at as any) - (a.created_at as any));
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) {
return metadataEvent;
}
return null;
}
};
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 (
<main className="bg-black text-white min-h-screen">
<main className="text-white mb-20">
{/* {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 />
{filteredAndSortedEvents.map((event, index) => (
<PostCard key={event.id} event={event} metadata={getMetadataEvent(event)} replyCount={countReplies(event)}/>
</div>
<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>
</main>

View File

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

View File

@ -1,15 +1,15 @@
import QuoteEmbed from "./QuoteEmbed";
import { Event } from 'nostr-tools';
import { Event } from "nostr-tools";
import { useEffect, useState } from "react";
import { subNoteOnce } from '../../utils/subscriptions';
import { subNoteOnce } from "../../utils/subscriptions";
import { nip19 } from "nostr-tools";
import LinkModal from "./LinkPreview";
const ContentPreview = ({ key, comment }: { key: string, comment: string }) => {
const [finalComment, setFinalComment] = useState(comment)
const ContentPreview = ({ key, comment }: { key: string; comment: string }) => {
const [finalComment, setFinalComment] = useState(comment);
const [quoteEvents, setQuoteEvents] = useState<Event[]>([]); // Initialize state
const [isExpanded, setIsExpanded] = useState(false);
const [url, setUrl] = useState('')
const [url, setUrl] = useState("");
// Define your callback function for subGlobalFeed
const onEvent = (event: Event, relay: string) => {
@ -19,8 +19,8 @@ const ContentPreview = ({ key, comment }: { key: string, comment: string }) => {
useEffect(() => {
const findUrl = comment.match(/\bhttps?:\/\/\S+/gi);
if (findUrl && findUrl.length > 0) {
setUrl(findUrl[0])
setFinalComment(finalComment.replace(findUrl[0], '').trim())
setUrl(findUrl[0]);
setFinalComment(finalComment.replace(findUrl[0], "").trim());
}
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) {
let id_to_hex = String(nip19.decode(nostrQuoteID as string).data);
subNoteOnce(id_to_hex, onEvent);
setFinalComment(finalComment.replace('nostr:' + nostrQuoteID, '').trim())
setFinalComment(finalComment.replace("nostr:" + nostrQuoteID, "").trim());
}
}, [comment, finalComment]);
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) {
return metadataEvent;
}
return null;
}
};
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)}
{finalComment.length > 240 && (
<button
className="text-gray-500 text-sm text-neutral-500"
className="text-sm text-neutral-500"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? '...Read less' : '...Read more'}
{isExpanded ? "...Read less" : "...Read more"}
</button>
)}
{url !== '' && (
<LinkModal key={key} url={url} />
)}
{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])} />
<QuoteEmbed
key={key}
event={quoteEvents[0]}
metadata={getMetadataEvent(quoteEvents[0])}
/>
</a>
)}
</div>
);
}
};
export default ContentPreview;

View File

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

View File

@ -1,22 +1,31 @@
import CardContainer from './CardContainer';
import { ArrowUpTrayIcon, CpuChipIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { XCircleIcon } from '@heroicons/react/24/solid';
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';
import CardContainer from "./CardContainer";
import {
ArrowUpTrayIcon,
CpuChipIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import { XCircleIcon } from "@heroicons/react/24/solid";
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 [comment, setComment] = useState("");
const [file, setFile] = useState("");
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 [messageFromWorker, setMessageFromWorker] = useState(null);
const [doingWorkProp, setDoingWorkProp] = useState(false);
// 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(() => {
worker.onmessage = (event) => {
@ -29,10 +38,10 @@ const NewThreadCard: React.FC = () => {
setDifficulty(difficulty);
};
window.addEventListener('difficultyChanged', handleDifficultyChange);
window.addEventListener("difficultyChanged", handleDifficultyChange);
return () => {
window.removeEventListener('difficultyChanged', handleDifficultyChange);
window.removeEventListener("difficultyChanged", handleDifficultyChange);
};
}, []);
@ -46,12 +55,12 @@ const NewThreadCard: React.FC = () => {
created_at: Math.floor(Date.now() / 1000),
pubkey: getPublicKey(sk),
},
difficulty
difficulty,
});
};
useEffect(() => {
setDoingWorkProp(false)
setDoingWorkProp(false);
if (messageFromWorker) {
try {
const signedEvent = finishEvent(messageFromWorker, sk);
@ -66,17 +75,17 @@ const NewThreadCard: React.FC = () => {
worker.terminate();
};
} catch (error) {
setComment(error + ' ' + comment);
setComment(error + " " + comment);
}
}
}, [messageFromWorker]);
async function attachFile(file_input: File | null) {
setUploadingFile(true); // start loading
setUploadingFile(true); // start loading
try {
if (file_input) {
const rx = await FileUpload(file_input);
setUploadingFile(false); // stop loading
setUploadingFile(false); // stop loading
if (rx.url) {
setFile(rx.url);
} else if (rx?.error) {
@ -84,86 +93,100 @@ const NewThreadCard: React.FC = () => {
}
}
} catch (error: unknown) {
setUploadingFile(false); // stop loading
setUploadingFile(false); // stop loading
if (error instanceof Error) {
setFile(error?.message);
}
}
}
return (
<>
<CardContainer>
<form
name="post"
method="post"
encType="multipart/form-data"
className=""
onSubmit={(event) => {
handleSubmit(event);
setDoingWorkProp(true);
}}
>
<input type="hidden" name="MAX_FILE_SIZE" defaultValue={4194304} />
<div id="togglePostFormLink" className="text-lg font-semibold">
Start a New Thread
<form
name="post"
method="post"
encType="multipart/form-data"
className=""
onSubmit={(event) => {
handleSubmit(event);
setDoingWorkProp(true);
}}
>
<input type="hidden" name="MAX_FILE_SIZE" defaultValue={4194304} />
<div
id="togglePostFormLink"
className="text-lg text-neutral-700 text-center mb-2 font-semibold"
>
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>
<textarea
name="com"
wrap="soft"
className="w-full p-2 rounded bg-gradient-to-r from-blue-900 to-cyan-500 text-white border-none placeholder-blue-300"
placeholder='Shitpost here...'
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</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 className="relative">
{file !== "" && (
<button onClick={() => setFile("")}>
<XCircleIcon className="h-10 w-10 absolute shadow z-100 text-blue-500" />
</button>
)}
{renderMedia(file)}
</div>
<span className="flex items-center"><CpuChipIcon className="h-6 w-6 text-white" />: {difficulty}</span>
<button type="submit" className="px-4 py-2 bg-gradient-to-r from-cyan-900 to-blue-500 rounded text-white font-semibold">
Submit
</button>
</div>
{doingWorkProp ? (
<div className='flex animate-pulse text-sm text-gray-300'>
<CpuChipIcon className="h-4 w-4 ml-auto" />
<span>Generating Proof-of-Work...</span>
<div className="flex items-center gap-4">
<div className="flex items-center">
<ArrowUpTrayIcon
className="h-4 w-4 text-neutral-400 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>
<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>
) : null}
<div id="postFormError" className="text-red-500" />
</form>
</CardContainer>
</>
{doingWorkProp ? (
<div className="flex animate-pulse text-sm text-gray-300">
<CpuChipIcon className="h-4 w-4 ml-auto" />
<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 { FolderIcon } from '@heroicons/react/24/outline';
import { parseContent } from '../../utils/content';
import { Event } from 'nostr-tools';
import { nip19 } from 'nostr-tools';
import { getMetadata } from '../../utils/utils';
import ContentPreview from'../Modals/TextModal';
import { renderMedia } from '../../utils/FileUpload';
import CardContainer from "./CardContainer";
import { ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline";
import { parseContent } from "../../utils/content";
import { Event } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { getMetadata } from "../../utils/utils";
import ContentPreview from "../Modals/TextModal";
import { renderMedia } from "../../utils/FileUpload";
const colorCombos = [
'from-red-400 to-yellow-500',
'from-green-400 to-blue-500',
'from-purple-400 to-pink-500',
'from-yellow-400 to-orange-500',
'from-indigo-400 to-purple-500',
'from-pink-400 to-red-500',
'from-blue-400 to-indigo-500',
'from-orange-400 to-red-500',
'from-teal-400 to-green-500',
'from-cyan-400 to-teal-500',
'from-lime-400 to-green-500',
'from-amber-400 to-orange-500',
'from-rose-400 to-pink-500',
'from-violet-400 to-purple-500',
'from-sky-400 to-cyan-500'
"from-red-400 to-yellow-500",
"from-green-400 to-blue-500",
"from-purple-400 to-pink-500",
"from-yellow-400 to-orange-500",
"from-indigo-400 to-purple-500",
"from-pink-400 to-red-500",
"from-blue-400 to-indigo-500",
"from-orange-400 to-red-500",
"from-teal-400 to-green-500",
"from-cyan-400 to-teal-500",
"from-lime-400 to-green-500",
"from-amber-400 to-orange-500",
"from-rose-400 to-pink-500",
"from-violet-400 to-purple-500",
"from-sky-400 to-cyan-500",
];
const getColorFromHash = (id: string, colors: string[]): string => {
@ -38,7 +38,7 @@ const getColorFromHash = (id: string, colors: string[]): string => {
};
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`;
@ -55,7 +55,17 @@ const timeAgo = (unixTime: number) => {
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);
const colorCombo = getColorFromHash(event.pubkey, colorCombos);
@ -65,39 +75,55 @@ const PostCard = ({ key, event, metadata, replyCount }: { key: string, event: Ev
}
return (
<>
<CardContainer>
<a href={`/thread/${nip19.noteEncode(event.id)}`}>
<div className="flex flex-col">
<div className="flex justify-between items-center">
<div className="flex items-center">
{metadataParsed ?
<>
<img className={`h-8 w-8 rounded-full`} src={metadataParsed.picture} />
<div className="ml-2 text-md font-semibold">{metadataParsed.name}</div>
</>
:
<>
<div className={`h-8 w-8 bg-gradient-to-r ${colorCombo} rounded-full`} />
<div className="ml-2 text-md font-semibold">Anonymous</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>
<FolderIcon className="h-5 w-5 mr-1 text-gray-500" />
<span className="text-xs text-gray-500">{replyCount}</span>
</div>
<CardContainer>
<a href={`/thread/${nip19.noteEncode(event.id)}`}>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2.5">
{metadataParsed ? (
<>
<img
className={`h-8 w-8 rounded-full`}
src={metadataParsed.picture}
alt=""
loading="lazy"
decoding="async"
/>
<div className="text-md font-semibold">
{metadataParsed.name}
</div>
</>
) : (
<>
<div
className={`h-7 w-7 bg-gradient-to-r ${colorCombo} rounded-full`}
/>
<div className="text-sm font-semibold">Anonymous</div>
</>
)}
</div>
<div className="mr-2 flex flex-col break-words">
<ContentPreview key={event.id} comment={comment} />
<div className="flex items-center ml-auto gap-2.5">
<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>
</a>
{renderMedia(file)}
</CardContainer>
</>
<div className="flex flex-col break-words">
<ContentPreview key={event.id} comment={comment} />
</div>
</div>
</a>
{renderMedia(file)}
</CardContainer>
);
};

View File

@ -20,7 +20,7 @@ code {
@layer components {
.card {
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
* https://void.cat/swagger/index.html
*/
* Upload file to void.cat
* 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 req = await fetch("https://void.cat/upload", {
@ -23,10 +23,10 @@ export default async function FileUpload(file: File ): Promise<UploadResult> {
},
});
if (req.ok) {
let rsp: VoidUploadResponse = await req.json();
const fileExtension = file.name.split('.').pop(); // Extracting the file extension
const resultUrl = `https://void.cat/d/${rsp.file?.id}.${fileExtension}`;
return {url: resultUrl};
let rsp: VoidUploadResponse = await req.json();
const fileExtension = file.name.split(".").pop(); // Extracting the file extension
const resultUrl = `https://void.cat/d/${rsp.file?.id}.${fileExtension}`;
return { url: resultUrl };
}
return {
error: "Upload failed",
@ -36,48 +36,56 @@ export default async function FileUpload(file: File ): Promise<UploadResult> {
export const renderMedia = (file: string) => {
if (file && (file.endsWith(".mp4") || file.endsWith(".webm"))) {
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" />
</video>
);
} else if (!file.includes("http")) {
return (
<></>
);
return <></>;
} else {
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 {
url?: string;
error?: string;
url?: string;
error?: string;
}
export type VoidUploadResponse = {
ok: boolean,
file?: VoidFile,
errorMessage?: string,
}
ok: boolean;
file?: VoidFile;
errorMessage?: string;
};
export type VoidFile = {
id: string,
meta?: VoidFileMeta
}
id: string;
meta?: VoidFileMeta;
};
export type VoidFileMeta = {
version: number,
id: string,
name?: string,
size: number,
uploaded: Date,
description?: string,
mimeType?: string,
digest?: string,
url?: string,
expires?: Date,
storage?: string,
encryptionParams?: string,
}
version: number;
id: string;
name?: string;
size: number;
uploaded: Date;
description?: string;
mimeType?: string;
digest?: string;
url?: string;
expires?: Date;
storage?: string;
encryptionParams?: string;
};