├── README.md ├── src ├── react-app-env.d.ts ├── components │ ├── ColorScheme │ │ ├── index.tsx │ │ └── ColorSchemeToggle.tsx │ ├── PollResponse │ │ ├── style.css │ │ ├── MultipleChoiceOptions.tsx │ │ ├── SingleChoiceOptions.tsx │ │ ├── PollTimer.tsx │ │ ├── ProofofWorkModal.tsx │ │ ├── index.tsx │ │ ├── FetchResults.tsx │ │ └── Filter.tsx │ ├── EventCreator │ │ ├── index.tsx │ │ ├── EventForm.tsx │ │ ├── NotePreview.tsx │ │ ├── OptionsCard.tsx │ │ └── PollPreview.tsx │ ├── Topics │ │ └── HashtagCard.tsx │ ├── Event │ │ └── EventJSONCard.tsx │ ├── Profile │ │ └── ProfileCard.tsx │ ├── Common │ │ ├── TranslationPopover.tsx │ │ ├── Utils │ │ │ └── index.ts │ │ ├── Comments │ │ │ ├── CommentInput.tsx │ │ │ └── CommentTrigger.tsx │ │ ├── OverlappingAvatars.tsx │ │ ├── Youtube │ │ │ └── index.tsx │ │ ├── Repost │ │ │ └── reposts.tsx │ │ └── Likes │ │ │ └── likes.tsx │ ├── Feed │ │ ├── NotesFeed │ │ │ ├── components │ │ │ │ ├── NotesFeedTabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── ReactedFeed.tsx │ │ │ │ ├── RepostedNoteCard.tsx │ │ │ │ ├── ReactedNoteCard.tsx │ │ │ │ ├── FollowingFeed.tsx │ │ │ │ └── DiscoverFeed.tsx │ │ │ └── hooks │ │ │ │ ├── useReactedNotes.ts │ │ │ │ └── useDiscoverNotes.ts │ │ ├── FeedsLayout.tsx │ │ ├── Feed.tsx │ │ ├── TopicsFeed │ │ │ └── TopicMetadataModal.tsx │ │ └── MoviesFeed.tsx │ ├── Header │ │ ├── SettingsModal.tsx │ │ ├── index.tsx │ │ ├── notification-utils.ts │ │ ├── UserMenu.tsx │ │ └── AISettings.tsx │ ├── Notes │ │ └── PrepareNote.tsx │ ├── Ratings │ │ ├── ReviewCard.tsx │ │ ├── RateHashtagModal.tsx │ │ ├── RateMovieModal.tsx │ │ ├── RateProfileModal.tsx │ │ ├── Rate.tsx │ │ └── RateEventModal.tsx │ ├── User │ │ └── ViewKeysModal.tsx │ ├── FeedbackMenu │ │ └── index.tsx │ ├── Moderator │ │ └── ModeratorSelectorDialog.tsx │ ├── Login │ │ └── LoginModal.tsx │ ├── PollResults │ │ ├── index.tsx │ │ └── Analytics.tsx │ └── Movies │ │ ├── MoviePage.tsx │ │ └── MovieMetadataModal.tsx ├── Images │ ├── logo.png │ ├── logo.svg │ └── FilterIcon.tsx ├── singletons │ ├── index.ts │ └── Signer │ │ ├── types.ts │ │ ├── LocalSigner.ts │ │ ├── NIP07Signer.ts │ │ └── BunkerSigner.ts ├── interfaces │ └── index.ts ├── constants │ ├── nostr.ts │ └── notifications.ts ├── nostr │ ├── types.ts │ ├── requestThrottler.ts │ └── index.ts ├── utils │ ├── constants.ts │ ├── utils.ts │ ├── common.ts │ ├── mining-worker.ts │ └── localStorage.ts ├── setupTests.ts ├── App.test.tsx ├── index.tsx ├── hooks │ ├── useAppContext.tsx │ ├── useUserContext.tsx │ ├── useListContext.tsx │ ├── useRelays.ts │ ├── useResizeObserver.tsx │ ├── useMiningWorker.ts │ ├── useRating.ts │ ├── MetadataProvider.tsx │ └── useTopicExplorerScroll.tsx ├── index.css ├── types │ └── ollama.d.ts ├── App.css ├── contexts │ ├── user-context.tsx │ ├── relay-context.tsx │ ├── notification-context.tsx │ └── RatingProvider.tsx ├── styles │ └── theme.ts └── logo.svg ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── fonts │ ├── ShantellSans.ttf │ └── ShantellSans-Italic.ttf ├── manifest.json ├── logo.svg └── index.html ├── .gitignore ├── tsconfig.json ├── LICENSE.md ├── package.json ├── XYZ.md └── Moderation.md /README.md: -------------------------------------------------------------------------------- 1 | Polls on nostr. 2 | 3 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/components/ColorScheme/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './ColorSchemeToggle' -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abh3po/nostr-polls/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abh3po/nostr-polls/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abh3po/nostr-polls/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/Images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abh3po/nostr-polls/HEAD/src/Images/logo.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/fonts/ShantellSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abh3po/nostr-polls/HEAD/public/fonts/ShantellSans.ttf -------------------------------------------------------------------------------- /src/singletons/index.ts: -------------------------------------------------------------------------------- 1 | import { SimplePool } from "nostr-tools"; 2 | 3 | export const pool = new SimplePool(); 4 | -------------------------------------------------------------------------------- /public/fonts/ShantellSans-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abh3po/nostr-polls/HEAD/public/fonts/ShantellSans-Italic.ttf -------------------------------------------------------------------------------- /src/components/PollResponse/style.css: -------------------------------------------------------------------------------- 1 | .radio-label { 2 | .MuiFormControlLabel-label { 3 | flex: 1; 4 | } 5 | } -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export type Option = [id: string, label: string] 2 | export type Response = [placeholder: string, value: string] -------------------------------------------------------------------------------- /src/constants/nostr.ts: -------------------------------------------------------------------------------- 1 | // Standard Nostr Event Kinds 2 | export const NOSTR_EVENT_KINDS = { 3 | TEXT_NOTE: 1, 4 | POLL: 1068, 5 | } as const; 6 | -------------------------------------------------------------------------------- /src/components/EventCreator/index.tsx: -------------------------------------------------------------------------------- 1 | import EventForm from "./EventForm" 2 | 3 | export const EventCreator = () => { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/nostr/types.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "nostr-tools"; 2 | 3 | export type Profile = { 4 | event: Event; 5 | picture: string; 6 | [key: string]: any; 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_IMAGE_URL="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Anonymous.svg/200px-Anonymous.svg.png" 2 | 3 | export const USER_DATA_TTL_HOURS = 168; // 7 days -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | 6 | const root = ReactDOM.createRoot( 7 | document.getElementById("root") as HTMLElement 8 | ); 9 | root.render( 10 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /src/hooks/useAppContext.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { AppContext } from '../contexts/app-context' 3 | 4 | export function useAppContext() { 5 | const context = useContext(AppContext); 6 | 7 | if (!context) { 8 | throw new Error('useAppContext must be used within a AppContextProvider'); 9 | } 10 | 11 | return context; 12 | } -------------------------------------------------------------------------------- /src/hooks/useUserContext.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { UserContext } from "../contexts/user-context"; 3 | 4 | export function useUserContext() { 5 | const context = useContext(UserContext); 6 | 7 | if (!context) { 8 | throw new Error("UserContext must be used within a UserContextProvider"); 9 | } 10 | 11 | return context; 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/useListContext.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { ListContext } from "../contexts/lists-context"; 3 | 4 | export function useListContext() { 5 | const context = useContext(ListContext); 6 | 7 | if (!context) { 8 | throw new Error("useListContext must be used within a ListContextProvider"); 9 | } 10 | 11 | return context; 12 | } 13 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .vscode* 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | .idea/ -------------------------------------------------------------------------------- /src/hooks/useRelays.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { RelayContext } from "../contexts/relay-context"; 3 | import { defaultRelays } from "../nostr"; 4 | 5 | export function useRelays() { 6 | const context = useContext(RelayContext); 7 | 8 | if (!context) { 9 | console.warn("useRelays must be used within a RelayProvider"); 10 | return { relays: defaultRelays, isUsingUserRelays: false }; 11 | } 12 | 13 | return context; 14 | } -------------------------------------------------------------------------------- /src/hooks/useResizeObserver.tsx: -------------------------------------------------------------------------------- 1 | // hooks/useResizeObserver.ts 2 | import { useEffect } from "react"; 3 | 4 | export const useResizeObserver = ( 5 | ref: React.RefObject, 6 | callback: () => void 7 | ) => { 8 | useEffect(() => { 9 | if (!ref.current) return; 10 | 11 | const observer = new ResizeObserver(callback); 12 | observer.observe(ref.current); 13 | 14 | return () => { 15 | observer.disconnect(); 16 | }; 17 | }, [ref, callback]); 18 | }; 19 | -------------------------------------------------------------------------------- /src/singletons/Signer/types.ts: -------------------------------------------------------------------------------- 1 | import { Event, EventTemplate } from "nostr-tools/lib/types"; 2 | 3 | export interface NostrSigner { 4 | getPublicKey: () => Promise; 5 | signEvent: (event: EventTemplate) => Promise; 6 | encrypt?: (pubkey: string, plaintext: string) => Promise; 7 | decrypt?: (pubkey: string, ciphertext: string) => Promise; 8 | nip44Encrypt?: (pubkey: string, txt: string) => Promise; 9 | nip44Decrypt?: (pubkey: string, ct: string) => Promise; 10 | } 11 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo.svg", 12 | "type": "image/svg", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo.svg", 17 | "type": "image/svg", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } -------------------------------------------------------------------------------- /src/types/ollama.d.ts: -------------------------------------------------------------------------------- 1 | // src/types/ollama.d.ts 2 | export {}; 3 | 4 | declare global { 5 | interface Window { 6 | ollama?: { 7 | getModels: () => Promise<{ 8 | success: boolean; 9 | data: { 10 | models: { name: string }[]; 11 | }; 12 | error?: string; 13 | }>; 14 | generate?: (params: { 15 | model: string; 16 | prompt: string; 17 | stream?: boolean; 18 | }) => Promise<{ 19 | success: boolean; 20 | data: { response: string }; 21 | error?: string; 22 | }>; 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Topics/HashtagCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, CardContent, Typography } from "@mui/material"; 3 | import Rate from "../Ratings/Rate"; 4 | 5 | const HashtagCard: React.FC<{ tag: string }> = ({ tag }) => ( 6 | 7 | 8 | window.open(`https://snort.social/t/${tag}`, "_blank")} 12 | > 13 | {tag} 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | export default HashtagCard; 21 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "paths": { 20 | "*": ["node_modules/*"], 21 | "nostr-tools/nip46": ["node_modules/nostr-tools/lib/types/nip46.d.ts"] 22 | } 23 | }, 24 | "include": ["src"] 25 | } -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | // src/utils/selectBestMetadataEvent.ts 2 | import { Event } from "nostr-tools"; 3 | 4 | export function selectBestMetadataEvent( 5 | events: Event[], 6 | follows: string[] | undefined 7 | ): Event | null { 8 | const seen = new Set(); 9 | 10 | const uniqueEvents = events.filter((e) => { 11 | if (seen.has(e.id)) return false; 12 | seen.add(e.id); 13 | return true; 14 | }); 15 | 16 | return ( 17 | uniqueEvents 18 | .sort((a, b) => { 19 | const aFollowed = follows?.includes(a.pubkey) ?? false; 20 | const bFollowed = follows?.includes(b.pubkey) ?? false; 21 | 22 | if (aFollowed && !bFollowed) return -1; 23 | if (!aFollowed && bFollowed) return 1; 24 | 25 | return b.created_at - a.created_at; 26 | })[0] || null 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Event/EventJSONCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, CardContent, Typography } from "@mui/material"; 3 | import Rate from "../Ratings/Rate"; 4 | 5 | interface Props { 6 | event: any; 7 | } 8 | 9 | const EventJsonCard: React.FC = ({ event }) => { 10 | return ( 11 | 12 | 13 | 14 | Event JSON 15 | 16 |
17 |           {JSON.stringify(event, null, 2)}
18 |         
19 |
20 | 21 |
22 | ); 23 | }; 24 | 25 | export default EventJsonCard; 26 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/Images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | export const isImageUrl = (url: string): boolean => { 2 | return url.match(/\.(jpeg|jpg|gif|png|webp)$/) != null; 3 | }; 4 | 5 | export const calculateTimeAgo = (timestamp: number): string => { 6 | const now = Date.now(); 7 | const postDate = new Date(timestamp * 1000); // Convert timestamp to milliseconds 8 | const differenceInMilliseconds = now - postDate.getTime(); 9 | 10 | const seconds = Math.floor(differenceInMilliseconds / 1000); 11 | const minutes = Math.floor(seconds / 60); 12 | const hours = Math.floor(minutes / 60); 13 | const days = Math.floor(hours / 24); 14 | 15 | if (days > 1) return `${days} days ago`; 16 | if (days === 1) return `1 day ago`; 17 | if (hours > 1) return `${hours} hours ago`; 18 | if (hours === 1) return `1 hour ago`; 19 | if (minutes > 1) return `${minutes} minutes ago`; 20 | if (minutes === 1) return `1 minute ago`; 21 | return `now`; 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/ColorScheme/ColorSchemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {ToggleButtonGroup, ToggleButton, useColorScheme} from "@mui/material"; 3 | import {LightMode, SettingsBrightness, DarkMode} from "@mui/icons-material"; 4 | 5 | export const ColorSchemeToggle: React.FC = () => { 6 | const {mode, setMode} = useColorScheme() 7 | return setMode(newMode)} aria-label={'color scheme toggle'}> 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | } -------------------------------------------------------------------------------- /src/components/PollResponse/MultipleChoiceOptions.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, FormControlLabel, FormGroup } from "@mui/material"; 2 | import { TextWithImages } from "../Common/Parsers/TextWithImages"; 3 | 4 | interface MultipleChoiceOptionsProps { 5 | options: Array<[string, string, string]>; 6 | response: string[]; 7 | handleResponseChange: (value: string) => void; 8 | } 9 | 10 | export const MultipleChoiceOptions: React.FC = ({ 11 | options, 12 | response, 13 | handleResponseChange, 14 | }) => ( 15 | 16 | {options.map((option) => ( 17 | } 20 | label={} 21 | value={option[1]} 22 | className="radio-label" 23 | checked={response.includes(option[1])} 24 | onChange={() => handleResponseChange(option[1])} 25 | /> 26 | ))} 27 | 28 | ); 29 | -------------------------------------------------------------------------------- /src/components/Profile/ProfileCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Event } from "nostr-tools"; 3 | import { Avatar, Card, CardContent, Typography } from "@mui/material"; 4 | import Rate from "../Ratings/Rate"; 5 | import { DEFAULT_IMAGE_URL } from "../../utils/constants"; 6 | 7 | const ProfileCard: React.FC<{ event: Event }> = ({ event }) => { 8 | const profile = JSON.parse(event.content || "{}"); 9 | return ( 10 | 11 | 12 | 16 | {profile.name || "Unnamed"} 17 | 18 | {profile.about} 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default ProfileCard; 27 | -------------------------------------------------------------------------------- /src/components/Common/TranslationPopover.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Popover, Typography } from "@mui/material"; 3 | 4 | export const TranslationPopover: React.FC<{ 5 | translatedText: string | null; 6 | buttonRef: HTMLElement | null; 7 | open: boolean; 8 | onClose: () => void; 9 | }> = ({ translatedText, buttonRef, open, onClose }) => { 10 | const id = open ? "translation-popover" : undefined; 11 | 12 | return ( 13 | <> 14 | 27 | 28 | {translatedText} 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/PollResponse/SingleChoiceOptions.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlLabel, Radio, RadioGroup } from "@mui/material"; 2 | import { TextWithImages } from "../Common/Parsers/TextWithImages"; 3 | import "./style.css"; 4 | 5 | interface SingleChoiceOptionsProps { 6 | options: Array<[string, string, string]>; 7 | response: string[]; 8 | handleResponseChange: (value: string) => void; 9 | } 10 | 11 | export const SingleChoiceOptions: React.FC = ({ 12 | options, 13 | response, 14 | handleResponseChange, 15 | }) => ( 16 | handleResponseChange(e.target.value)} 20 | > 21 | {options.map((option) => ( 22 | } 26 | style={{ flex: 1 }} 27 | className="radio-label" 28 | label={} 29 | /> 30 | ))} 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/components/Common/Utils/index.ts: -------------------------------------------------------------------------------- 1 | export function isEmbeddableYouTubeUrl(url: string): boolean { 2 | try { 3 | const parsedUrl = new URL(url); 4 | const hostname = parsedUrl.hostname.toLowerCase(); 5 | const pathname = parsedUrl.pathname; 6 | const searchParams = parsedUrl.searchParams; 7 | 8 | if (hostname === "youtu.be") { 9 | // youtu.be short link always has video ID directly after slash 10 | return /^\/[a-zA-Z0-9_-]{11}$/.test(pathname); 11 | } 12 | 13 | if (hostname.includes("youtube.com")) { 14 | // watch?v=... form 15 | if (pathname === "/watch" && searchParams.has("v")) { 16 | return /^[a-zA-Z0-9_-]{11}$/.test(searchParams.get("v") || ""); 17 | } 18 | 19 | // embed/... form 20 | if (/^\/embed\/[a-zA-Z0-9_-]{11}$/.test(pathname)) { 21 | return true; 22 | } 23 | 24 | // shorts/... form 25 | if (/^\/shorts\/[a-zA-Z0-9_-]{11}$/.test(pathname)) { 26 | return true; 27 | } 28 | } 29 | 30 | return false; 31 | } catch { 32 | return false; // in case URL constructor fails on invalid URLs 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Common/Comments/CommentInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { TextField, Button } from "@mui/material"; 3 | 4 | interface CommentInputProps { 5 | onSubmit: (content: string) => void; 6 | initialContent?: string; 7 | } 8 | 9 | const CommentInput: React.FC = ({ 10 | onSubmit, 11 | initialContent = "", 12 | }) => { 13 | const [newComment, setNewComment] = useState(initialContent); 14 | 15 | const handleSubmit = () => { 16 | if (newComment.trim()) { 17 | onSubmit(newComment); 18 | setNewComment(""); 19 | } 20 | }; 21 | 22 | return ( 23 |
24 | setNewComment(e.target.value)} 27 | label="Add a comment" 28 | fullWidth 29 | multiline 30 | rows={2} 31 | style={{ marginBottom: 8 }} 32 | /> 33 | 40 |
41 | ); 42 | }; 43 | 44 | export default CommentInput; 45 | -------------------------------------------------------------------------------- /src/components/Common/Comments/CommentTrigger.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Tooltip, Typography } from "@mui/material"; 3 | import CommentIcon from "@mui/icons-material/Comment"; 4 | import { useAppContext } from "../../../hooks/useAppContext"; 5 | 6 | interface CommentTriggerProps { 7 | eventId: string; 8 | showComments: boolean; 9 | onToggleComments: () => void; 10 | } 11 | 12 | const CommentTrigger: React.FC = ({ 13 | eventId, 14 | showComments, 15 | onToggleComments 16 | }) => { 17 | const { commentsMap } = useAppContext(); 18 | const comments = commentsMap?.get(eventId) || []; 19 | 20 | return ( 21 | 22 | 26 | ({ 28 | color: theme.palette.mode === "light" ? "black" : "white", 29 | })} 30 | /> 31 | {comments.length ? comments.length : null} 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default CommentTrigger; 38 | -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/components/NotesFeedTabs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Tabs, Tab, useTheme, useMediaQuery, Box } from "@mui/material"; 3 | 4 | interface Props { 5 | activeTab: "following" | "reacted" | "discover"; // 🆕 6 | setActiveTab: (tab: "following" | "reacted" | "discover") => void; 7 | } 8 | 9 | const NotesFeedTabs: React.FC = ({ activeTab, setActiveTab }) => { 10 | const theme = useTheme(); 11 | const isMobile = useMediaQuery(theme.breakpoints.down("sm")); 12 | 13 | return ( 14 | 15 | 18 | setActiveTab(newValue) 19 | } 20 | variant="scrollable" 21 | scrollButtons="auto" 22 | allowScrollButtonsMobile 23 | sx={{ 24 | "& .MuiTab-root": { 25 | textTransform: "none", 26 | minWidth: isMobile ? 100 : 160, 27 | fontWeight: 500, 28 | }, 29 | }} 30 | > 31 | {/* 🆕 */} 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default NotesFeedTabs; 40 | -------------------------------------------------------------------------------- /src/singletons/Signer/LocalSigner.ts: -------------------------------------------------------------------------------- 1 | // singletons/Signer/LocalSigner.ts 2 | import { getPublicKey, finalizeEvent, nip04, nip44 } from "nostr-tools"; 3 | import { NostrSigner } from "./types"; 4 | import { hexToBytes } from "@noble/hashes/utils"; 5 | export function createLocalSigner(privkey: string): NostrSigner { 6 | const pubkey = getPublicKey(hexToBytes(privkey)); 7 | 8 | return { 9 | getPublicKey: async () => pubkey, 10 | 11 | signEvent: async (event) => { 12 | const signedEvent = finalizeEvent(event, hexToBytes(privkey)); 13 | return signedEvent; 14 | }, 15 | 16 | encrypt: async (peerPubkey: string, plaintext: string) => { 17 | return nip04.encrypt(privkey, peerPubkey, plaintext); 18 | }, 19 | 20 | decrypt: async (peerPubkey: string, ciphertext: string) => { 21 | return nip04.decrypt(privkey, peerPubkey, ciphertext); 22 | }, 23 | 24 | nip44Encrypt: async (peerPubkey, plaintext) => { 25 | let conversationKey = nip44.getConversationKey( 26 | hexToBytes(privkey), 27 | peerPubkey 28 | ); 29 | return nip44.encrypt(plaintext, conversationKey); 30 | }, 31 | 32 | nip44Decrypt: async (peerPubkey, ciphertext) => { 33 | let conversationKey = nip44.getConversationKey( 34 | hexToBytes(privkey), 35 | peerPubkey 36 | ); 37 | return nip44.decrypt(ciphertext, conversationKey); 38 | }, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Header/SettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Modal, Tab, Tabs, Typography, useTheme } from "@mui/material"; 2 | import { useState } from "react"; 3 | import { RelaySettings } from "./RelaySettings"; 4 | import { AISettings } from "./AISettings"; 5 | 6 | interface SettingsModalProps { 7 | open: boolean; 8 | onClose: () => void; 9 | } 10 | 11 | export const SettingsModal: React.FC = ({ 12 | open, 13 | onClose, 14 | }) => { 15 | const [tabIndex, setTabIndex] = useState(0); 16 | const theme = useTheme(); 17 | 18 | return ( 19 | 20 | 33 | 34 | Settings 35 | 36 | 37 | setTabIndex(newVal)} 40 | sx={{ mb: 2 }} 41 | > 42 | 43 | 44 | 45 | 46 | {tabIndex === 0 && } 47 | {tabIndex === 1 && } 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/components/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, lazy, Suspense } from "react"; 2 | import { Typography, CircularProgress } from "@mui/material"; 3 | import RateEventModal from "../../../Ratings/RateEventModal"; 4 | import NotesFeedTabs from "./NotesFeedTabs"; 5 | 6 | const FollowingFeed = lazy(() => import("./FollowingFeed")); 7 | const ReactedFeed = lazy(() => import("./ReactedFeed")); 8 | const DiscoverFeed = lazy(() => import("./DiscoverFeed")); // 🆕 9 | 10 | const NotesFeed = () => { 11 | const [activeTab, setActiveTab] = useState< 12 | "following" | "reacted" | "discover" 13 | >("discover"); 14 | const [modalOpen, setModalOpen] = useState(false); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | {activeTab === "following" 22 | ? "Notes from people you follow" 23 | : activeTab === "reacted" 24 | ? "Notes reacted to by contacts" 25 | : "Discover new posts from friends of friends"} 26 | 27 | 28 | }> 29 | {activeTab === "following" ? ( 30 | 31 | ) : activeTab === "reacted" ? ( 32 | 33 | ) : ( 34 | 35 | )} 36 | 37 | 38 | setModalOpen(false)} /> 39 | 40 | ); 41 | }; 42 | 43 | export default NotesFeed; 44 | -------------------------------------------------------------------------------- /src/components/Notes/PrepareNote.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useRelays } from "../../hooks/useRelays"; 3 | import { Event, nip19 } from "nostr-tools"; 4 | import { Notes } from "."; 5 | import { Button, Typography } from "@mui/material"; 6 | import { pool } from "../..//singletons"; 7 | import { EventPointer } from "nostr-tools/lib/types/nip19"; 8 | 9 | interface PrepareNoteInterface { 10 | neventId: string; 11 | } 12 | 13 | export const PrepareNote: React.FC = ({ neventId }) => { 14 | const { relays } = useRelays(); 15 | const [event, setEvent] = useState(null); 16 | 17 | useEffect(() => { 18 | const fetchEvent = async (neventId: string) => { 19 | try { 20 | const decoded = nip19.decode(neventId).data as EventPointer; 21 | const filter = { ids: [decoded.id] }; 22 | const neventRelays = decoded.relays; 23 | const relaysToUse = Array.from( 24 | new Set([...relays, ...(neventRelays || [])]) 25 | ); 26 | let result = await pool.get(relaysToUse, filter); 27 | setEvent(result); 28 | } catch (error) { 29 | console.error("Error fetching event:", error); 30 | } 31 | }; 32 | if (neventId && !event) { 33 | fetchEvent(neventId); 34 | } 35 | }, [neventId, event, , relays]); 36 | 37 | if (event) return ; 38 | else 39 | return ( 40 | 41 | Loading... 42 |

{neventId}

43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/singletons/Signer/NIP07Signer.ts: -------------------------------------------------------------------------------- 1 | import { Event, EventTemplate } from "nostr-tools"; 2 | import { NostrSigner } from "./types"; // Adjust the path as needed 3 | 4 | export const nip07Signer: NostrSigner = { 5 | getPublicKey: async (): Promise => { 6 | if (!window.nostr) throw new Error("NIP-07 signer not found"); 7 | return window.nostr.getPublicKey(); 8 | }, 9 | 10 | signEvent: async (event: EventTemplate): Promise => { 11 | if (!window.nostr) throw new Error("NIP-07 signer not found"); 12 | return window.nostr.signEvent(event); 13 | }, 14 | 15 | encrypt: async (pubkey: string, plaintext: string): Promise => { 16 | if (!window.nostr?.nip04) 17 | throw new Error("NIP-04 encryption not supported"); 18 | return window.nostr.nip04.encrypt(pubkey, plaintext); 19 | }, 20 | 21 | decrypt: async (pubkey: string, ciphertext: string): Promise => { 22 | if (!window.nostr?.nip04) 23 | throw new Error("NIP-04 decryption not supported"); 24 | return window.nostr.nip04.decrypt(pubkey, ciphertext); 25 | }, 26 | nip44Decrypt: async (pubkey: string, ciphertext: string): Promise => { 27 | if (!window.nostr?.nip44) 28 | throw new Error("NIP-44 decryption not supported"); 29 | return window.nostr.nip44.decrypt(pubkey, ciphertext); 30 | }, 31 | nip44Encrypt: async (pubkey: string, plaintext: string): Promise => { 32 | if (!window.nostr?.nip44) 33 | throw new Error("NIP-44 encryption not supported"); 34 | return window.nostr.nip44.encrypt(pubkey, plaintext); 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nostr-polls", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "private": true, 6 | "dependencies": { 7 | "@emotion/react": "^11.11.4", 8 | "@emotion/styled": "^11.11.5", 9 | "@mui/icons-material": "^6.1.2", 10 | "@mui/material": "^6.1.2", 11 | "@mui/styles": "^6.1.2", 12 | "@mui/x-date-pickers": "^7.17.0", 13 | "@testing-library/jest-dom": "^5.17.0", 14 | "@testing-library/react": "^13.4.0", 15 | "@types/jest": "^27.5.2", 16 | "@types/node": "^16.18.101", 17 | "@types/react": "^18.3.3", 18 | "@types/react-dom": "^18.3.0", 19 | "@types/react-router-dom": "^5.3.3", 20 | "dayjs": "^1.11.13", 21 | "emoji-picker-react": "^4.16.1", 22 | "nostr-tools": "^2.12.0", 23 | "react": "^18.3.1", 24 | "react-dom": "^18.3.1", 25 | "react-pull-to-refresh": "^2.0.1", 26 | "react-router-dom": "^6.24.0", 27 | "react-scripts": "5.0.1", 28 | "react-virtuoso": "^4.13.0", 29 | "typescript": "^5.2.2" 30 | }, 31 | "scripts": { 32 | "start": "react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test", 35 | "eject": "react-scripts eject" 36 | }, 37 | "eslintConfig": { 38 | "extends": [ 39 | "react-app", 40 | "react-app/jest" 41 | ] 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.2%", 46 | "not dead", 47 | "not op_mini all" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /XYZ.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | This NIP defines a standard for rating Nostr anything and everything. Ratings are tallied against a unique id. 4 | 5 | ## Definitions 6 | 7 | - **Rating**: A user-assigned value indicating the perceived quality or relevance of a target event, normalized to be a value between 0 and 1 8 | - **m** : mark : A tag used to specify the type of entity being rated. Example values could include 'event', 'profile', 'relay', 'hashtag', 'books', 'movies', etc. If empty it is asssumed to be a nostr event. 9 | 10 | ## Event Kind 11 | 12 | A new event kind (suggested: `KIND 34259`) is introduced for submitting ratings. 13 | 14 | ## Event Structure 15 | 16 | ```json 17 | { 18 | "kind": 34259, 19 | "content": "Optional Comment", 20 | "tags": [ 21 | ["d", ""], 22 | ["m", "type of entity being rated, example: event, profile, relay, etc."] 23 | ["rating", ""] 24 | ] 25 | ... 26 | } 27 | ``` 28 | 29 | ## Notes 30 | 31 | For d-tags that have identifiers that are not unique, the id should be prefixed by the m-tag value. For example: If you want to rate a hashtag use `hashtag:` as the identifier for the d-tag. This will help in differentiating between different types of entities with the same ID. For example: 32 | `hasthag:books` this will help distinguish the events from other contexts like market listings or other categories. 33 | 34 | ## Example Event 35 | 36 | ```json 37 | { 38 | "kind": 34259, 39 | "content": "Great event!", 40 | "tags": [ 41 | ["d", "hashtag:books"], 42 | ["m", "event"], 43 | ["rating", ""0.8""] 44 | ] 45 | 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /src/components/Ratings/ReviewCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Box, 4 | Typography, 5 | Card, 6 | CardContent, 7 | Avatar, 8 | Stack, 9 | } from "@mui/material"; 10 | import { nip19, Event } from "nostr-tools"; 11 | import { useAppContext } from "../../hooks/useAppContext"; 12 | 13 | interface Props { 14 | event: Event; 15 | } 16 | 17 | const ReviewCard: React.FC = ({ event }) => { 18 | const rating = 19 | parseFloat(event.tags.find((t) => t[0] === "rating")?.[1] || "0") * 5; 20 | const content = event.content; 21 | const pubkey = event.pubkey; 22 | 23 | const { profiles, fetchUserProfileThrottled } = useAppContext(); 24 | const reviewUser = profiles?.get(pubkey); 25 | if(!reviewUser) fetchUserProfileThrottled(pubkey) 26 | 27 | const displayName = reviewUser?.name || nip19.npubEncode(pubkey); 28 | const picture = reviewUser?.picture; 29 | 30 | return ( 31 | 32 | 33 | 34 | 39 | 40 | 41 | {displayName} 42 | 43 | 44 | {rating.toFixed(1)} ★ 45 | 46 | 47 | 48 | 49 | {content && ( 50 | 51 | {content} 52 | 53 | )} 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default ReviewCard; 60 | -------------------------------------------------------------------------------- /src/components/Feed/FeedsLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Tabs, Tab, useMediaQuery, useTheme } from "@mui/material"; 3 | import { useNavigate, useLocation, Outlet } from "react-router-dom"; 4 | 5 | const feedOptions = [ 6 | { value: "polls", label: "Polls" }, 7 | { value: "topics", label: "Topics" }, 8 | { value: "notes", label: "Notes" }, 9 | { value: "movies", label: "Movies" }, 10 | ]; 11 | 12 | const FeedsLayout: React.FC = () => { 13 | const navigate = useNavigate(); 14 | const location = useLocation(); 15 | const theme = useTheme(); 16 | const isMobile = useMediaQuery(theme.breakpoints.down("sm")); 17 | 18 | // Extract feed from URL path like "/feeds/movies" -> "movies" 19 | const currentFeed = location.pathname.split("/")[2] || "polls"; 20 | 21 | const handleChange = (_: any, newValue: string) => { 22 | navigate(`/feeds/${newValue}`); 23 | }; 24 | 25 | return ( 26 | 27 | 43 | {feedOptions.map((option) => ( 44 | 45 | ))} 46 | 47 | 48 | {/* Renders the selected feed component here */} 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default FeedsLayout; 55 | -------------------------------------------------------------------------------- /src/components/EventCreator/EventForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Container, Typography, Box, Card, Switch, FormControlLabel } from "@mui/material"; 3 | import NoteTemplateForm from "./NoteTemplateForm"; 4 | import PollTemplateForm from "./PollTemplateForm"; 5 | 6 | const EventForm = () => { 7 | const [isNote, setIsNote] = useState(true); 8 | const [eventContent, setEventContent] = useState(""); 9 | 10 | return ( 11 | 12 | 13 | 18 | {isNote ? "Create A Note" : "Create A Poll"} 19 | 20 | 21 | 22 | 23 | 24 | {isNote ? "Note" : "Poll"} 25 | 26 | setIsNote((prev) => !prev)} 31 | color="primary" 32 | /> 33 | } 34 | label={isNote ? "Switch to Poll" : "Switch to Note"} 35 | labelPlacement="start" 36 | sx={{ ml: 2 }} 37 | /> 38 | 39 | {isNote ? ( 40 | 41 | ) : ( 42 | 43 | )} 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default EventForm; 50 | -------------------------------------------------------------------------------- /src/contexts/user-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useEffect, useState } from "react"; 2 | import { LoginModal } from "../components/Login/LoginModal"; 3 | import { signerManager } from "../singletons/Signer/SignerManager"; 4 | 5 | export type User = { 6 | name?: string; 7 | picture?: string; 8 | pubkey: string; 9 | privateKey?: string; 10 | follows?: string[]; 11 | webOfTrust?: Set; 12 | about?: string; 13 | }; 14 | 15 | interface UserContextInterface { 16 | user: User | null; 17 | setUser: React.Dispatch>; 18 | requestLogin: () => void; 19 | } 20 | 21 | export const ANONYMOUS_USER_NAME = "Anon..."; 22 | 23 | export const UserContext = createContext(null); 24 | 25 | export function UserProvider({ children }: { children: ReactNode }) { 26 | const [user, setUser] = useState(null); 27 | const [loginModalOpen, setLoginModalOpen] = useState(false); 28 | useEffect(() => { 29 | if (!user) { 30 | console.log("ATTEMPTING RESTORE FROM LOCAL STORAGE"); 31 | signerManager.restoreFromStorage(); 32 | } 33 | }, []); 34 | useEffect(() => { 35 | signerManager.registerLoginModal(() => { 36 | return new Promise((resolve) => { 37 | setLoginModalOpen(true); 38 | }); 39 | }); 40 | signerManager.onChange(async () => { 41 | setUser(await signerManager.getUser()); 42 | }); 43 | }, []); 44 | 45 | const requestLogin = () => { 46 | setLoginModalOpen(true); 47 | }; 48 | 49 | return ( 50 | 51 | {children} 52 | setLoginModalOpen(false)} 55 | /> 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/User/ViewKeysModal.tsx: -------------------------------------------------------------------------------- 1 | // components/User/ViewKeysModal.tsx 2 | import React from "react"; 3 | import { 4 | Dialog, 5 | DialogTitle, 6 | DialogContent, 7 | DialogActions, 8 | Typography, 9 | Alert, 10 | Button, 11 | Box, 12 | Stack, 13 | } from "@mui/material"; 14 | import { nip19 } from "nostr-tools"; 15 | import { MonospaceDisplay } from "../Login/CreateAccountModal"; 16 | import { hexToBytes } from "@noble/hashes/utils"; 17 | 18 | interface Props { 19 | open: boolean; 20 | onClose: () => void; 21 | pubkey: string; 22 | privkey: string; 23 | } 24 | 25 | export const ViewKeysModal: React.FC = ({ 26 | open, 27 | onClose, 28 | pubkey, 29 | privkey, 30 | }) => { 31 | return ( 32 | 33 | Your Keys 34 | 35 | 36 | These keys are stored insecurely in your browser. 37 | Back them up or import them into a NIP-07 extension or remote signer. 38 | If you lose them, your account is gone forever. 39 | 40 | 41 | 42 | 43 | Public Key (npub) 44 | 45 | 46 | 47 | Private Key (nsec) 48 | 49 | 50 | 51 | 52 | 53 | 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/constants/notifications.ts: -------------------------------------------------------------------------------- 1 | export const NOTIFICATION_MESSAGES = { 2 | // Clipboard operations 3 | POLL_URL_COPIED: "Poll URL copied to clipboard!", 4 | EVENT_COPIED: "Event copied to clipboard!", 5 | POLL_URL_COPY_FAILED: "Failed to copy poll URL.", 6 | EVENT_COPY_FAILED: "Failed to copy event.", 7 | 8 | // Authentication 9 | ANONYMOUS_LOGIN: "Login not found, submitting anonymously", 10 | NIP07_INIT_FAILED: "Failed to initialize NIP-07 signer", 11 | NIP46_INIT_FAILED: "Failed to initialize NIP-46 signer", 12 | 13 | // Validation errors 14 | INVALID_URL: "Invalid URL", 15 | INVALID_AMOUNT: "Invalid amount.", 16 | PAST_DATE_ERROR: "You cannot select a past date/time.", 17 | RECIPIENT_PROFILE_ERROR: "Could not fetch recipient profile", 18 | EMPTY_POLL_OPTIONS: "Poll options cannot be empty.", 19 | MIN_POLL_OPTIONS: "A poll must have at least one option.", 20 | 21 | // Poll operations 22 | POLL_NOT_FOUND: "Could not find the given poll", 23 | POLL_FETCH_ERROR: "Error fetching poll event.", 24 | 25 | // User actions 26 | LOGIN_TO_LIKE: "Login To Like!", 27 | LOGIN_TO_COMMENT: "Login To Comment", 28 | LOGIN_TO_ZAP: "Log In to send zaps!", 29 | LOGIN_TO_REPOST: "Login to repost!", 30 | 31 | // Event creation 32 | EMPTY_NOTE_CONTENT: "Note content cannot be empty.", 33 | NOTE_SIGN_FAILED: "Failed to sign the note.", 34 | NOTE_PUBLISHED_SUCCESS: "Note published successfully!", 35 | NOTE_PUBLISH_FAILED: "Failed to publish note. Please try again.", 36 | EMPTY_POLL_QUESTION: "Poll question cannot be empty.", 37 | POLL_SIGN_FAILED: "Failed to sign the poll.", 38 | POLL_PUBLISHED_SUCCESS: "Poll published successfully!", 39 | POLL_PUBLISH_FAILED: "Failed to publish poll. Please try again.", 40 | } as const; 41 | 42 | export type NotificationMessageKey = keyof typeof NOTIFICATION_MESSAGES; 43 | -------------------------------------------------------------------------------- /src/singletons/Signer/BunkerSigner.ts: -------------------------------------------------------------------------------- 1 | // nip46.ts 2 | import { EventTemplate } from "nostr-tools"; 3 | import { 4 | BunkerSignerParams, 5 | BunkerPointer, 6 | parseBunkerInput, 7 | BunkerSigner, 8 | } from "nostr-tools/nip46"; 9 | import { NostrSigner } from "./types"; 10 | import { getAppSecretKeyFromLocalStorage } from "../../utils/localStorage"; 11 | 12 | export async function createNip46Signer( 13 | /** e.g. "bunker://…", or "nostrconnect://…" */ 14 | bunkerUri: string, 15 | /** optional: relay pool, onauth callback, etc. */ 16 | params: BunkerSignerParams = {} 17 | ): Promise { 18 | // 1️⃣ Parse URI to get relays / remote-signer-pubkey / secret 19 | const bp: BunkerPointer | null = await parseBunkerInput(bunkerUri); 20 | 21 | if (!bp) throw new Error("Invalid NIP-46 URI"); 22 | 23 | // 2️⃣ Generate disposable client keypair 24 | const clientSecretKey: Uint8Array = getAppSecretKeyFromLocalStorage(); 25 | 26 | // 3️⃣ Instantiate the NIP-46 signer 27 | const bunker = new BunkerSigner(clientSecretKey, bp, params); 28 | console.log("BUNKER Created", bunker); 29 | 30 | // 4️⃣ Handshake: ping → connect → get_public_key 31 | await bunker.connect(); 32 | const wrapper: NostrSigner = { 33 | getPublicKey: async () => await bunker.getPublicKey(), 34 | signEvent: async (event: EventTemplate) => { 35 | // client-pubkey is baked into the conversation, remote returns correctly‐signed user-event 36 | return bunker.signEvent(event); 37 | }, 38 | encrypt: async (pubkey, plaintext) => 39 | bunker.nip04Encrypt(pubkey, plaintext), 40 | decrypt: async (pubkey, ciphertext) => 41 | bunker.nip04Decrypt(pubkey, ciphertext), 42 | nip44Encrypt: async (pubkey, txt) => bunker.nip44Encrypt(pubkey, txt), 43 | nip44Decrypt: async (pubkey, ct) => bunker.nip44Decrypt(pubkey, ct), 44 | }; 45 | 46 | return wrapper; 47 | } 48 | -------------------------------------------------------------------------------- /src/Images/FilterIcon.tsx: -------------------------------------------------------------------------------- 1 | export const FilterIcon = ({fill}: {fill?: string}) => 3 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/EventCreator/NotePreview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Event } from 'nostr-tools'; 3 | import { 4 | Card, 5 | CardContent, 6 | Typography, 7 | Box, 8 | } from '@mui/material'; 9 | import { Notes } from '../Notes'; 10 | import { useUserContext } from '../../hooks/useUserContext'; 11 | import { NOSTR_EVENT_KINDS } from "../../constants/nostr"; 12 | 13 | // Valid 64-character hex string for preview purposes 14 | const MOCK_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000001'; 15 | 16 | interface NotePreviewProps { 17 | noteEvent: Partial; 18 | } 19 | 20 | export const NotePreview: React.FC = ({ noteEvent }) => { 21 | const { user } = useUserContext(); 22 | const previewPubkey = user?.pubkey || MOCK_PUBKEY; 23 | 24 | return ( 25 | 26 | 27 | 28 | Note Preview 29 | 30 | {!noteEvent.content?.trim() ? ( 31 | 38 | 39 | Start typing to see a preview of your note 40 | 41 | 42 | ) : ( 43 | 44 | 53 | 54 | )} 55 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/EventCreator/OptionsCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card, CardContent, TextField, Button, CardActions, IconButton } from '@mui/material'; 3 | import { Add, Delete } from '@mui/icons-material'; 4 | import { Option } from "../../interfaces" 5 | 6 | interface OptionsCardProps { 7 | onAddOption: () => void; 8 | onRemoveOption: (index: number) => void; 9 | onEditOptions: (newOptions: Option[]) => void; 10 | options: Option[] 11 | } 12 | 13 | const OptionsCard: React.FC = ({ 14 | onAddOption, 15 | onRemoveOption, 16 | onEditOptions, 17 | options, 18 | }) => { 19 | const handleEditOption = (index: number, value: string) => { 20 | const newOptions = [...options]; 21 | newOptions[index][1] = value; 22 | onEditOptions(newOptions); 23 | }; 24 | 25 | return ( 26 | 27 | {options.length > 0 && ( 28 | 29 | {options.map((option, index) => ( 30 |
31 | handleEditOption(index, e.target.value)} 37 | sx={{ mr: 1 }} 38 | /> 39 | onRemoveOption(index)} 42 | > 43 | 44 | 45 |
46 | ))} 47 |
48 | )} 49 | 50 | 57 | 58 |
59 | ); 60 | }; 61 | 62 | export default OptionsCard; 63 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AppBar, Toolbar, Typography, Button } from "@mui/material"; 3 | import { styled } from "@mui/system"; 4 | import logo from "../../Images/logo.svg"; 5 | import { UserMenu } from "./UserMenu"; 6 | import { useNavigate } from "react-router-dom"; 7 | import { getColorsWithTheme } from "../../styles/theme"; 8 | import { NotificationBell } from "./NotificationBell"; 9 | 10 | const StyledAppBar = styled(AppBar)(({ theme }) => { 11 | return { 12 | backgroundColor: theme.palette.mode === "dark" ? "#000000" : "#ffffff", 13 | }; 14 | }); 15 | 16 | const StyledButton = styled(Button)(({ theme }) => ({ 17 | ...getColorsWithTheme(theme, { 18 | color: "#000000", 19 | }), 20 | })); 21 | 22 | const HeaderCenterSection = styled("div")({ 23 | flexGrow: 1, 24 | display: "flex", 25 | justifyContent: "center", 26 | alignItems: "center", 27 | }); 28 | 29 | const HeaderRightSection = styled("div")({ 30 | marginLeft: "auto", 31 | display: "flex", 32 | }); 33 | 34 | const LogoAndTitle = styled("div")({ 35 | display: "flex", 36 | alignItems: "center", 37 | }); 38 | 39 | const Header: React.FC = () => { 40 | let navigate = useNavigate(); 41 | 42 | return ( 43 | 44 | 45 | 46 | 47 | navigate("/")} variant="text"> 48 | Logo 49 | Pollerama 50 | 51 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | export default Header; 66 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 25 | 34 | Pollerama 35 | 36 | 37 | 38 | 39 |
40 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/components/EventCreator/PollPreview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Event } from 'nostr-tools'; 3 | import { 4 | Card, 5 | CardContent, 6 | Typography, 7 | Box 8 | } from '@mui/material'; 9 | import PollResponseForm from '../PollResponse/PollResponseForm'; 10 | import { useUserContext } from '../../hooks/useUserContext'; 11 | import { NOSTR_EVENT_KINDS } from "../../constants/nostr"; 12 | 13 | // Valid 64-character hex strings for preview purposes 14 | const MOCK_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000001'; 15 | 16 | interface PollPreviewProps { 17 | pollEvent: Partial; 18 | } 19 | 20 | export const PollPreview: React.FC = ({ pollEvent }) => { 21 | const { user } = useUserContext(); 22 | const previewPubkey = user?.pubkey || MOCK_PUBKEY; 23 | 24 | return ( 25 | 26 | 27 | 28 | Poll Preview 29 | 30 | {!pollEvent.content?.trim() ? ( 31 | 38 | 39 | Enter a poll question to see the preview 40 | 41 | 42 | ) : ( 43 | 44 | 55 | 56 | )} 57 | 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/components/ReactedFeed.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { CircularProgress } from "@mui/material"; 3 | import { useUserContext } from "../../../../hooks/useUserContext"; 4 | import { useReactedNotes } from "../hooks/useReactedNotes"; 5 | import ReactedNoteCard from "./ReactedNoteCard"; 6 | import { Event } from "nostr-tools"; 7 | import { Virtuoso } from "react-virtuoso"; 8 | import type { VirtuosoHandle } from "react-virtuoso"; 9 | import useImmersiveScroll from "../../../../hooks/useImmersiveScroll"; 10 | 11 | const ReactedFeed = () => { 12 | const { user } = useUserContext(); 13 | const { reactedEvents, reactionEvents, fetchReactedNotes, loading } = 14 | useReactedNotes(user); 15 | const virtuosoRef = useRef(null); 16 | const containerRef = useRef(null); 17 | 18 | useImmersiveScroll(containerRef, virtuosoRef, { smooth: true }); 19 | 20 | useEffect(() => { 21 | if (reactedEvents.size === 0) { 22 | fetchReactedNotes(); 23 | } 24 | }, [user, reactedEvents, fetchReactedNotes]); 25 | 26 | const sorted = Array.from(reactedEvents.values()).sort( 27 | (a, b) => b.created_at - a.created_at 28 | ); 29 | 30 | return ( 31 |
32 | ( 36 | 41 | )} 42 | endReached={() => { 43 | console.log("Reached bottom, loading more..."); 44 | fetchReactedNotes(); 45 | }} 46 | components={{ 47 | Footer: () => 48 | loading ? ( 49 |
50 | 51 |
52 | ) : null, 53 | }} 54 | /> 55 |
56 | ); 57 | }; 58 | 59 | export default ReactedFeed; 60 | -------------------------------------------------------------------------------- /src/contexts/relay-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useEffect, useState } from "react"; 2 | import { defaultRelays } from "../nostr"; 3 | import { useUserContext } from "../hooks/useUserContext"; 4 | import { pool } from "../singletons"; 5 | 6 | interface RelayContextInterface { 7 | relays: string[]; 8 | isUsingUserRelays: boolean; 9 | } 10 | 11 | export const RelayContext = createContext({ 12 | relays: defaultRelays, 13 | isUsingUserRelays: false, 14 | }); 15 | 16 | export function RelayProvider({ children }: { children: ReactNode }) { 17 | const [relays, setRelays] = useState(defaultRelays); 18 | const [isUsingUserRelays, setIsUsingUserRelays] = useState(false); 19 | const { user } = useUserContext(); 20 | 21 | useEffect(() => { 22 | // Reset to default relays when user logs out 23 | if (!user) { 24 | setRelays(defaultRelays); 25 | setIsUsingUserRelays(false); 26 | return; 27 | } 28 | 29 | // Fetch user's relay list when logged in 30 | const fetchUserRelays = async () => { 31 | try { 32 | const filters = { kinds: [10002], authors: [user.pubkey] }; 33 | const results = await pool.querySync(defaultRelays, filters); 34 | 35 | if (results && results.length > 0) { 36 | const userRelays = results[0].tags 37 | .filter((tag) => tag[0] === "r") 38 | .map((tag) => tag[1]); 39 | 40 | if (userRelays.length > 0) { 41 | setRelays(userRelays); 42 | setIsUsingUserRelays(true); 43 | return; 44 | } 45 | } 46 | 47 | // Fallback to default relays if no user relays found 48 | setRelays(defaultRelays); 49 | setIsUsingUserRelays(false); 50 | } catch (error) { 51 | console.error("Error fetching user relays:", error); 52 | setRelays(defaultRelays); 53 | setIsUsingUserRelays(false); 54 | } 55 | }; 56 | 57 | fetchUserRelays(); 58 | }, [user]); 59 | 60 | return ( 61 | 62 | {children} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/nostr/requestThrottler.ts: -------------------------------------------------------------------------------- 1 | import { SimplePool } from "nostr-tools"; 2 | import { fetchComments, fetchLikes, fetchUserProfiles, fetchZaps, fetchReposts } from "."; 3 | import { Event } from "nostr-tools/lib/types/core"; 4 | 5 | type QueueType = "profiles" | "comments" | "likes" | "zaps" | "reposts"; 6 | 7 | export class Throttler { 8 | private queue: string[] = []; 9 | private intervalId: NodeJS.Timeout | null = null; 10 | private limit: number; 11 | private pool: SimplePool; 12 | private callback: (events: Event[]) => void; 13 | private queueType: QueueType; 14 | private delay: number; 15 | 16 | constructor( 17 | limit: number, 18 | pool: SimplePool, 19 | callback: (events: Event[]) => void, 20 | queueType: QueueType, 21 | delay?: number 22 | ) { 23 | this.limit = limit; 24 | this.pool = pool; 25 | this.callback = callback; 26 | this.queueType = queueType; 27 | this.delay = delay || 1000; 28 | } 29 | 30 | public addId(pubkey: string) { 31 | if (!this.queue.includes(pubkey)) { 32 | this.queue.push(pubkey); 33 | this.startProcessing(); 34 | } 35 | } 36 | 37 | private startProcessing() { 38 | if (this.intervalId) return; // Already processing 39 | 40 | this.intervalId = setInterval(() => { 41 | this.processQueue(); 42 | }, this.delay); // Process every second 43 | } 44 | 45 | private async processQueue() { 46 | if (this.queue.length === 0) { 47 | clearInterval(this.intervalId!); 48 | this.intervalId = null; 49 | return; 50 | } 51 | let results: Event[] = []; 52 | const IdsToProcess = this.queue.splice(0, this.limit); 53 | if (this.queueType === "profiles") { 54 | results = await fetchUserProfiles(IdsToProcess, this.pool); 55 | } 56 | if (this.queueType === "comments") { 57 | results = await fetchComments(IdsToProcess, this.pool); 58 | } 59 | if (this.queueType === "likes") { 60 | results = await fetchLikes(IdsToProcess, this.pool); 61 | } 62 | 63 | if (this.queueType === "zaps") { 64 | results = await fetchZaps(IdsToProcess, this.pool); 65 | } 66 | 67 | this.callback(results); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Ratings/RateHashtagModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Box, Button, Modal, TextField, Typography } from "@mui/material"; 3 | import HashtagCard from "../Topics/HashtagCard"; 4 | 5 | interface RateHashtagModalProps { 6 | open: boolean; 7 | onClose: () => void; 8 | } 9 | 10 | const RateHashtagModal: React.FC = ({ 11 | open, 12 | onClose, 13 | }) => { 14 | const [hashtagInput, setHashtagInput] = useState(""); 15 | const [selectedTag, setSelectedTag] = useState(null); 16 | 17 | const handleSubmit = () => { 18 | if (!hashtagInput.trim()) return; 19 | setSelectedTag(hashtagInput.trim().toLowerCase()); 20 | }; 21 | 22 | const handleClose = () => { 23 | setHashtagInput(""); 24 | setSelectedTag(null); 25 | onClose(); 26 | }; 27 | 28 | return ( 29 | 30 | 41 | 42 | Rate a Hashtag 43 | 44 | 45 | {!selectedTag ? ( 46 | <> 47 | setHashtagInput(e.target.value)} 53 | sx={{ mb: 2 }} 54 | /> 55 | 58 | 59 | ) : ( 60 | <> 61 | 62 | 70 | 71 | )} 72 | 73 | 74 | ); 75 | }; 76 | 77 | export default RateHashtagModal; 78 | -------------------------------------------------------------------------------- /src/components/FeedbackMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Card, CardContent, Divider } from "@mui/material"; 3 | import Rate from "../Ratings/Rate"; 4 | import CommentTrigger from "../Common/Comments/CommentTrigger"; 5 | import CommentSection from "../Common/Comments/CommentSection"; 6 | import Likes from "../Common/Likes/likes"; 7 | import Zap from "../Common/Zaps/zaps"; 8 | import { Event } from "nostr-tools"; 9 | import RepostButton from "../Common/Repost/reposts"; 10 | 11 | interface FeedbackMenuProps { 12 | event: Event; 13 | } 14 | 15 | export const FeedbackMenu: React.FC = ({ event }) => { 16 | const [showComments, setShowComments] = useState(false); 17 | 18 | const handleToggleComments = () => { 19 | setShowComments(!showComments); 20 | }; 21 | 22 | return ( 23 | <> 24 | 25 | 26 |
33 | 34 |
35 | 36 | {/* Feedback Icons Row */} 37 |
41 |
42 | 47 |
48 |
49 | 50 |
51 |
52 | 53 |
54 |
55 | 56 |
57 |
58 | {/* Comment Section */} 59 | 63 |
64 |
65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/Ratings/RateMovieModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Box, Button, Modal, TextField, Typography } from "@mui/material"; 3 | import MovieCard from "../Movies/MovieCard"; 4 | 5 | interface RateMovieModalProps { 6 | open: boolean; 7 | onClose: () => void; 8 | } 9 | 10 | const RateMovieModal: React.FC = ({ open, onClose }) => { 11 | const [imdbInput, setImdbInput] = useState(""); 12 | const [selectedImdbId, setSelectedImdbId] = useState(null); 13 | 14 | const handleSubmit = () => { 15 | const trimmed = imdbInput.trim(); 16 | if (!/^tt\d{7,}$/.test(trimmed)) return; // very basic IMDb ID validation 17 | setSelectedImdbId(trimmed); 18 | }; 19 | 20 | const handleClose = () => { 21 | setImdbInput(""); 22 | setSelectedImdbId(null); 23 | onClose(); 24 | }; 25 | 26 | return ( 27 | 28 | 39 | 40 | Rate a Movie 41 | 42 | 43 | {!selectedImdbId ? ( 44 | <> 45 | setImdbInput(e.target.value)} 51 | sx={{ mb: 2 }} 52 | /> 53 | 56 | 57 | ) : ( 58 | <> 59 | 60 | 68 | 69 | )} 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default RateMovieModal; 76 | -------------------------------------------------------------------------------- /src/utils/mining-worker.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-globals 2 | import {Event, getEventHash, nip13, UnsignedEvent} from "nostr-tools"; 3 | import {MiningTracker} from "../nostr"; 4 | 5 | // eslint-disable-next-line no-restricted-globals 6 | const ctx: Worker = self as any; 7 | // Respond to message from parent thread 8 | ctx.addEventListener('message', (event) => { 9 | console.log("Received message in worker:", event.data); 10 | 11 | const { event: nostrEvent, difficulty, tracker } = event.data; 12 | 13 | const result = minePow(nostrEvent, difficulty, tracker); 14 | console.log("Mining result:", result); 15 | 16 | }); 17 | 18 | export function minePow( 19 | unsigned: UnsignedEvent, 20 | difficulty: number, 21 | tracker: MiningTracker 22 | ):Omit { 23 | let count = 0; 24 | let numHashes = 0 25 | 26 | const event = unsigned as Omit; 27 | const tag = ["nonce", count.toString(), difficulty.toString()]; 28 | const queryTag = ["W", difficulty.toString()]; 29 | 30 | event.tags.push(tag); 31 | event.tags.push(queryTag); 32 | let lastUpdateSent = Date.now() 33 | 34 | while (true) { 35 | const now = Math.floor(new Date().getTime() / 1000); 36 | if (tracker.cancelled) { 37 | throw new Error("Operation cancelled"); 38 | } 39 | 40 | if (now !== event.created_at) { 41 | count = 0; 42 | event.created_at = now; 43 | } 44 | numHashes++; 45 | tag[1] = (++count).toString(); 46 | event.id = getEventHash(event); 47 | let currentDifficulty = nip13.getPow(event.id); 48 | if (currentDifficulty > tracker.maxDifficultySoFar) { 49 | tracker.maxDifficultySoFar = currentDifficulty; 50 | } 51 | if (nip13.getPow(event.id) >= difficulty) { 52 | ctx.postMessage({status: "completed", difficulty: currentDifficulty, tracker, event}) 53 | break; 54 | } 55 | const timeSinceLastUpdate = Date.now() - lastUpdateSent 56 | if(timeSinceLastUpdate > 500) { 57 | ctx.postMessage({status: "progress", tracker, event, numHashes}) 58 | lastUpdateSent = Date.now() 59 | } 60 | } 61 | 62 | return event; 63 | } 64 | export default ctx -------------------------------------------------------------------------------- /src/hooks/useMiningWorker.ts: -------------------------------------------------------------------------------- 1 | import {Event, UnsignedEvent} from "nostr-tools"; 2 | import {MiningTracker} from "../nostr"; 3 | import React from "react"; 4 | 5 | export const useMiningWorker = ( difficulty: number) => { 6 | const trackerRef = React.useRef(new MiningTracker()) 7 | const [progress, updateProgress] = React.useState({ 8 | maxDifficultyAchieved: 0, 9 | numHashes: 0 10 | }) 11 | const [isCompleted, setIsCompleted] = React.useState(false) 12 | const workerRef = React.useRef(null) 13 | React.useEffect(() => { 14 | return ()=> { 15 | if(workerRef.current) { 16 | workerRef.current.terminate() 17 | } 18 | } 19 | }, []) 20 | const minePow = (event: UnsignedEvent,) => { 21 | if(workerRef.current) { 22 | workerRef.current.terminate() 23 | } 24 | const worker = new Worker(new URL("../utils/mining-worker", import.meta.url)) 25 | trackerRef.current = new MiningTracker() 26 | workerRef.current = worker 27 | const tracker = trackerRef.current 28 | worker.postMessage({ event, difficulty, tracker: tracker }) 29 | return new Promise>((resolve) => { 30 | worker.onmessage = (event) => { 31 | if(event.data.status === 'progress') { 32 | updateProgress({ 33 | maxDifficultyAchieved: event.data.tracker.maxDifficultySoFar, 34 | numHashes: event.data.numHashes 35 | }) 36 | } else if(event.data.status ==='completed') { 37 | trackerRef.current = event.data.tracker 38 | setIsCompleted(true) 39 | worker.terminate() 40 | resolve({...event, ...event.data.event} as Omit) 41 | } 42 | } 43 | }) 44 | } 45 | const cancelMining = () => { 46 | if(workerRef.current) { 47 | workerRef.current.terminate() 48 | } 49 | trackerRef.current.cancel() 50 | updateProgress({numHashes: 0, maxDifficultyAchieved: 0}) 51 | } 52 | return { 53 | minePow, 54 | isCompleted, 55 | cancelMining, 56 | progress 57 | } 58 | } -------------------------------------------------------------------------------- /src/components/PollResponse/PollTimer.tsx: -------------------------------------------------------------------------------- 1 | // PollTimer.tsx 2 | import React, { useEffect, useState } from "react"; 3 | import dayjs from "dayjs"; 4 | import { Typography } from "@mui/material"; 5 | 6 | interface PollTimerProps { 7 | pollExpiration: string | undefined; 8 | } 9 | 10 | const calculateTimeRemaining = (pollExpiration: string) => { 11 | if (!pollExpiration) return null; 12 | const expirationDate = dayjs.unix(Number(pollExpiration)); 13 | return expirationDate.diff(dayjs(), "milliseconds"); 14 | }; 15 | 16 | const PollTimer: React.FC = ({ pollExpiration }) => { 17 | const [timeRemaining, setTimeRemaining] = useState(null); 18 | 19 | useEffect(() => { 20 | if (!pollExpiration) return; 21 | 22 | const updateTimeRemaining = () => { 23 | const remaining = calculateTimeRemaining(pollExpiration); 24 | setTimeRemaining(remaining); 25 | }; 26 | 27 | updateTimeRemaining(); // Initial call to set the time immediately 28 | 29 | const interval = setInterval(updateTimeRemaining, 1000); // Update every second 30 | 31 | return () => clearInterval(interval); 32 | }, [pollExpiration]); 33 | 34 | const isPollConcluded = timeRemaining !== null && timeRemaining <= 0; 35 | 36 | const renderExpirationMessage = () => { 37 | if (isPollConcluded) { 38 | return `Closed at: ${dayjs.unix(Number(pollExpiration)).format("YYYY-MM-DD HH:mm")}`; 39 | } 40 | 41 | if (timeRemaining !== null) { 42 | const isLessThan100Hours = timeRemaining < 100 * 60 * 60 * 1000; 43 | 44 | if (isLessThan100Hours) { 45 | const hoursLeft = Math.floor(timeRemaining / (1000 * 60 * 60)); 46 | const minutesLeft = Math.floor( 47 | (timeRemaining % (1000 * 60 * 60)) / (1000 * 60) 48 | ); 49 | const secondsLeft = Math.floor((timeRemaining % (1000 * 60)) / 1000); 50 | return `Expires in: ${hoursLeft} hours, ${minutesLeft} minutes, and ${secondsLeft} seconds`; 51 | } else { 52 | const daysLeft = Math.floor(timeRemaining / (1000 * 60 * 60 * 24)); 53 | return `${daysLeft} days left to end`; 54 | } 55 | } 56 | 57 | return null; 58 | }; 59 | 60 | return {renderExpirationMessage()}; 61 | }; 62 | 63 | export default PollTimer; 64 | -------------------------------------------------------------------------------- /src/hooks/useRating.ts: -------------------------------------------------------------------------------- 1 | // hooks/useRating.ts 2 | import { useContext, useEffect, useRef } from "react"; 3 | import { signEvent } from "../nostr"; 4 | import { useRelays } from "./useRelays"; 5 | import { RatingContext } from "../contexts/RatingProvider"; 6 | import { pool } from "../singletons"; 7 | 8 | export const useRating = (entityId: string) => { 9 | const { ratings, registerEntityId, getUserRating } = 10 | useContext(RatingContext); 11 | const hasSubmittedRef = useRef(false); 12 | const { relays } = useRelays(); 13 | 14 | // Register entityId with the RatingsProvider 15 | useEffect(() => { 16 | registerEntityId(entityId); 17 | }, [entityId, registerEntityId]); 18 | 19 | const submitRating = async ( 20 | newRating: number, 21 | outOf: number = 5, 22 | entityType: string = "event", 23 | content?: string 24 | ) => { 25 | if (hasSubmittedRef.current) return; // prevent duplicate submission 26 | hasSubmittedRef.current = true; 27 | 28 | const normalizedRating = newRating / outOf; 29 | 30 | const ratingEvent = { 31 | kind: 34259, 32 | created_at: Math.floor(Date.now() / 1000), 33 | tags: [ 34 | ["d", entityId], 35 | ["m", entityType], 36 | ["rating", normalizedRating.toFixed(3)], 37 | ], 38 | content: content || "", 39 | pubkey: "", 40 | id: "", 41 | sig: "", 42 | }; 43 | if (content) ratingEvent.tags.push(["c", "true"]); 44 | 45 | try { 46 | const signed = await signEvent(ratingEvent, undefined); 47 | if (!signed) throw new Error("Signer couldn't sign Event"); 48 | pool.publish(relays, signed).forEach((p: Promise) => { 49 | p.then((message: string) => console.log("Relay Replied: ", message)); 50 | }); 51 | } catch (err) { 52 | console.error("Error publishing rating:", err); 53 | } finally { 54 | hasSubmittedRef.current = false; 55 | } 56 | }; 57 | 58 | const entityRatings = ratings.get(entityId); 59 | const average = 60 | entityRatings && entityRatings.size > 0 61 | ? Array.from(entityRatings.values()).reduce((a, b) => a + b, 0) / 62 | entityRatings.size 63 | : null; 64 | 65 | return { 66 | averageRating: average, 67 | totalRatings: entityRatings?.size || 0, 68 | submitRating, 69 | getUserRating, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/hooks/useReactedNotes.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Event, Filter, SimplePool } from "nostr-tools"; 3 | import { useRelays } from "../../../../hooks/useRelays"; 4 | 5 | export const useReactedNotes = (user: any) => { 6 | const [reactedEvents, setReactedEvents] = useState>(new Map()); 7 | const [reactionEvents, setReactionEvents] = useState>(new Map()); 8 | const [loading, setLoading] = useState(false); 9 | const [lastTimestamp, setLastTimestamp] = useState(undefined); 10 | const { relays } = useRelays(); 11 | 12 | const fetchReactedNotes = async () => { 13 | if (!user?.follows?.length || loading) return; 14 | setLoading(true); 15 | 16 | const pool = new SimplePool(); 17 | 18 | // Step 1: Get reactions 19 | const reactionFilter: Filter = { 20 | kinds: [7], 21 | authors: user.follows, 22 | limit: 20, 23 | }; 24 | if (lastTimestamp) { 25 | reactionFilter.until = lastTimestamp; 26 | } 27 | 28 | const newReactionEvents = await pool.querySync(relays, reactionFilter); 29 | 30 | const updatedReactionEvents = new Map(reactionEvents); 31 | newReactionEvents.forEach((e) => updatedReactionEvents.set(e.id, e)); 32 | setReactionEvents(updatedReactionEvents); 33 | 34 | const reactedNoteIds = newReactionEvents 35 | .map((e) => e.tags.find((tag) => tag[0] === "e")?.[1]) 36 | .filter(Boolean); 37 | 38 | const uniqueNoteIds = Array.from(new Set(reactedNoteIds)); 39 | 40 | // Step 2: Fetch the original notes 41 | const noteFilter: Filter = { 42 | kinds: [1], 43 | ids: uniqueNoteIds.filter((id) => id !== undefined), 44 | }; 45 | 46 | const noteEvents = await pool.querySync(relays, noteFilter); 47 | 48 | const updated = new Map(reactedEvents); 49 | noteEvents.forEach((e) => updated.set(e.id, e)); 50 | setReactedEvents(updated); 51 | 52 | if (newReactionEvents.length > 0) { 53 | const oldest = newReactionEvents.reduce((min, e) => 54 | e.created_at < min.created_at ? e : min 55 | ); 56 | setLastTimestamp(oldest.created_at); 57 | } 58 | 59 | setLoading(false); 60 | }; 61 | 62 | return { 63 | reactedEvents, 64 | reactionEvents, 65 | fetchReactedNotes, 66 | loading, 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/Common/OverlappingAvatars.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Avatar, Box, Typography } from "@mui/material"; 3 | import { useAppContext } from "../../hooks/useAppContext"; 4 | import { DEFAULT_IMAGE_URL } from "../../utils/constants"; 5 | 6 | interface OverlappingAvatarsProps { 7 | ids: string[]; 8 | maxAvatars?: number; 9 | } 10 | 11 | const OverlappingAvatars: React.FC = ({ 12 | ids, 13 | maxAvatars = 5, 14 | }) => { 15 | let { profiles, fetchUserProfileThrottled } = useAppContext(); 16 | 17 | useEffect(() => { 18 | const visibleIds = ids.slice(0, maxAvatars); 19 | visibleIds.forEach((id) => { 20 | if (!profiles?.get(id)) fetchUserProfileThrottled(id); 21 | }); 22 | }, []); 23 | const visibleIds = ids.slice(0, maxAvatars); 24 | let additionalCount = ids.length - visibleIds.length; 25 | const excessIds = additionalCount > 0 ? additionalCount : 0; 26 | return ( 27 | 38 | {visibleIds.map((id, index) => ( 39 | 53 | ))} 54 | {excessIds > 0 ? ( 55 | 68 | +{excessIds} 69 | 70 | ) : null} 71 | 72 | ); 73 | }; 74 | 75 | export default OverlappingAvatars; 76 | -------------------------------------------------------------------------------- /src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import {createTheme} from "@mui/material/styles"; 2 | import {Theme} from "@mui/system/createTheme"; 3 | import {CSSObject} from "@mui/material"; 4 | 5 | export const getColorsWithTheme = (theme: Theme, styles: CSSObject, contrast: CSSObject = {}) => { 6 | const contrastStyles = Object.keys(styles).reduce((map, key) => { 7 | map[key] = contrast[key] || theme.palette.getContrastText(styles[key]) 8 | return map 9 | }, {}) 10 | return { 11 | ...theme.applyStyles('light', styles), 12 | ...theme.applyStyles('dark', contrastStyles) 13 | } 14 | } 15 | 16 | const baseThemeOptions: Parameters[0] = { 17 | typography: { 18 | fontFamily: '"Shantell Sans", sans-serif', 19 | }, 20 | colorSchemes: { 21 | dark: { 22 | palette: { 23 | mode: "dark", 24 | primary: { 25 | main: "#FAD13F", 26 | }, 27 | secondary: { 28 | main: "#bdbdbc", 29 | }, 30 | background: { 31 | default: "#f5f4f1", 32 | }, 33 | }, 34 | }, 35 | light: { 36 | palette: { 37 | primary: { 38 | main: "#FAD13F", 39 | }, 40 | secondary: { 41 | main: "#F5F4F1", 42 | }, 43 | background: { 44 | default: "#FFFFFF", 45 | }, 46 | }, 47 | } 48 | }, 49 | palette: { 50 | primary: { 51 | main: "#FAD13F", 52 | }, 53 | secondary: { 54 | main: "#F5F4F1", 55 | }, 56 | background: { 57 | default: "#000000", 58 | }, 59 | }, 60 | // cssVariables: true, 61 | components: { 62 | MuiCssBaseline: { 63 | styleOverrides: (theme) => { 64 | return { 65 | body: { 66 | backgroundColor: theme.palette.mode === 'dark' ? '#4d4d4d' : "#f5f4f1", 67 | } 68 | } 69 | } 70 | }, 71 | MuiButton: { 72 | styleOverrides: { 73 | root: { 74 | borderRadius: "50px", 75 | textTransform: "none", 76 | }, 77 | }, 78 | }, 79 | MuiModal: { 80 | styleOverrides: { 81 | root: { 82 | // Ensures modal takes full height and scrolls when needed 83 | overflowY: "auto", 84 | }, 85 | }, 86 | } 87 | }, 88 | } 89 | 90 | 91 | const baseTheme = createTheme(baseThemeOptions) 92 | 93 | export { baseTheme}; 94 | -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/components/RepostedNoteCard.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Tooltip, Typography } from "@mui/material"; 2 | import { nip19 } from "nostr-tools"; 3 | import { DEFAULT_IMAGE_URL } from "../../../../utils/constants"; 4 | import { useAppContext } from "../../../../hooks/useAppContext"; 5 | import { Event } from "nostr-tools"; 6 | import { Notes } from "../../../../components/Notes"; 7 | import ReplayIcon from '@mui/icons-material/Replay'; 8 | 9 | interface RepostsCardProps { 10 | note: Event; 11 | reposts: Event[]; 12 | } 13 | 14 | const RepostsCard: React.FC = ({ note, reposts }) => { 15 | const { profiles, fetchUserProfileThrottled } = useAppContext(); 16 | 17 | // Filter reposts that belong to this note by checking tags for 'e' with note.id 18 | const matchingReposts = reposts.filter((r) => { 19 | const taggedNoteId = r.tags.find((tag) => tag[0] === "e")?.[1]; 20 | return taggedNoteId === note.id; 21 | }); 22 | 23 | // Pre-fetch profiles 24 | matchingReposts.forEach((r) => { 25 | if (!profiles?.get(r.pubkey)) { 26 | fetchUserProfileThrottled(r.pubkey); 27 | } 28 | }); 29 | 30 | return ( 31 |
32 | {matchingReposts.length > 0 && ( 33 |
42 | 43 | Reposted by 44 | {matchingReposts.slice(0, 3).map((r) => { 45 | const profile = profiles?.get(r.pubkey); 46 | const displayName = 47 | profile?.name || nip19.npubEncode(r.pubkey).substring(0, 8) + "..."; 48 | 49 | return ( 50 | 51 | 56 | 57 | ); 58 | })} 59 | {matchingReposts.length > 3 && ( 60 | 61 | +{matchingReposts.length - 3} more 62 | 63 | )} 64 |
65 | )} 66 | 67 |
68 | ); 69 | }; 70 | 71 | export default RepostsCard; 72 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/PollResponse/ProofofWorkModal.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Modal, Typography } from "@mui/material"; 2 | 3 | interface ProofofWorkModalInterface { 4 | show: boolean; 5 | targetDifficulty: number; 6 | onCancel:() => void; 7 | progress: { 8 | numHashes: number; 9 | maxDifficultyAchieved: number; 10 | } 11 | } 12 | 13 | export const ProofofWorkModal: React.FC = ({ 14 | show, 15 | targetDifficulty, 16 | onCancel, 17 | progress 18 | }) => { 19 | 20 | const cancelMining = () => { 21 | onCancel() 22 | }; 23 | 24 | return ( 25 | 26 |
27 |

Proof Of Work

28 | 39 | Calculating Proof of Work 40 |

41 | 42 | This poll requires users to attach "Proof of Work" on their 43 | responses.{" "} 44 | 50 | Proof of Work(PoW) 51 | {" "} 52 | is a spam control mechanism that allows for more trust in 53 | anonymous votes. It is achieved by adding a proof of compute from 54 | your device to your response. 55 | 56 |

57 |
58 | target difficulty: {targetDifficulty} 59 | 60 | {" "} 61 | difficulty achieved so far: {progress.maxDifficultyAchieved} 62 | 63 | hashes computed: {progress.numHashes} 64 |
65 | 66 | 73 |
74 |
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/Moderator/ModeratorSelectorDialog.tsx: -------------------------------------------------------------------------------- 1 | // File: components/Moderation/ModeratorSelectorDialog.tsx 2 | 3 | import React, { useState, useEffect } from "react"; 4 | import { 5 | Dialog, 6 | DialogTitle, 7 | DialogContent, 8 | DialogActions, 9 | Button, 10 | List, 11 | ListItem, 12 | ListItemAvatar, 13 | Avatar, 14 | ListItemText, 15 | Checkbox, 16 | } from "@mui/material"; 17 | import { useAppContext } from "../../hooks/useAppContext"; 18 | import { DEFAULT_IMAGE_URL } from "../../utils/constants"; 19 | 20 | interface Props { 21 | open: boolean; 22 | moderators: string[]; 23 | selected: string[]; 24 | onSubmit: (pubkeys: string[]) => void; 25 | onClose: () => void; 26 | } 27 | 28 | const ModeratorSelectorDialog: React.FC = ({ 29 | open, 30 | moderators, 31 | selected, 32 | onSubmit, 33 | onClose, 34 | }) => { 35 | const [temp, setTemp] = useState(selected); 36 | let { profiles, fetchUserProfileThrottled } = useAppContext(); 37 | 38 | useEffect(() => { 39 | setTemp(selected); 40 | moderators.forEach((m) => { 41 | if (!profiles?.get(m)) fetchUserProfileThrottled(m); 42 | }, []); 43 | }, [selected, profiles]); 44 | 45 | const toggle = (pubkey: string) => { 46 | setTemp((prev) => 47 | prev.includes(pubkey) 48 | ? prev.filter((p) => p !== pubkey) 49 | : [...prev, pubkey] 50 | ); 51 | }; 52 | 53 | const handleSubmit = () => { 54 | onSubmit(temp); 55 | onClose(); 56 | }; 57 | 58 | return ( 59 | 60 | Select Moderators 61 | 62 | 63 | {moderators.map((pubkey) => ( 64 | toggle(pubkey)}> 65 | 66 | 69 | 70 | 73 | 74 | 75 | ))} 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 | ); 86 | }; 87 | 88 | export default ModeratorSelectorDialog; 89 | -------------------------------------------------------------------------------- /src/contexts/notification-context.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext, useState } from "react"; 2 | import { Alert, Snackbar } from "@mui/material"; 3 | 4 | export type NotificationSeverity = "success" | "error" | "info" | "warning"; 5 | 6 | interface NotificationState { 7 | open: boolean; 8 | message: string; 9 | severity: NotificationSeverity; 10 | duration: number; 11 | } 12 | 13 | interface NotificationContextType { 14 | showNotification: (message: string, severity?: NotificationSeverity, duration?: number) => void; 15 | } 16 | 17 | const NotificationContext = createContext(undefined); 18 | 19 | export const useNotification = (): NotificationContextType => { 20 | const context = useContext(NotificationContext); 21 | if (!context) { 22 | throw new Error("useNotification must be used within a NotificationProvider"); 23 | } 24 | return context; 25 | }; 26 | 27 | interface NotificationProviderProps { 28 | children: React.ReactNode; 29 | } 30 | 31 | export const NotificationProvider: React.FC = ({ children }) => { 32 | const [notification, setNotification] = useState({ 33 | open: false, 34 | message: "", 35 | severity: "success", 36 | duration: 4000, 37 | }); 38 | 39 | const showNotification = useCallback(( 40 | message: string, 41 | severity: NotificationSeverity = "success", 42 | duration?: number 43 | ) => { 44 | const defaultDuration = severity === "error" ? 6000 : 4000; 45 | 46 | setNotification({ 47 | open: true, 48 | message, 49 | severity, 50 | duration: duration || defaultDuration, 51 | }); 52 | }, []); 53 | 54 | const handleClose = useCallback(() => { 55 | setNotification(prev => ({ ...prev, open: false })); 56 | }, []); 57 | 58 | return ( 59 | 60 | {children} 61 | 69 | 75 | {notification.message} 76 | 77 | 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/components/Login/LoginModal.tsx: -------------------------------------------------------------------------------- 1 | // components/LoginModal.tsx 2 | import React, { useState } from "react"; 3 | import { 4 | Dialog, 5 | DialogTitle, 6 | DialogContent, 7 | DialogActions, 8 | Button, 9 | Stack, 10 | } from "@mui/material"; 11 | import { signerManager } from "../../singletons/Signer/SignerManager"; 12 | import { useUserContext } from "../../hooks/useUserContext"; 13 | import { CreateAccountModal } from "./CreateAccountModal"; 14 | 15 | interface Props { 16 | open: boolean; 17 | onClose: () => void; 18 | } 19 | 20 | export const LoginModal: React.FC = ({ open, onClose }) => { 21 | const { setUser } = useUserContext(); 22 | const [showCreateAccount, setShowCreateAccount] = useState(false); 23 | const handleLoginWithNip07 = async () => { 24 | const unsubscribe = signerManager.onChange(async () => { 25 | setUser(signerManager.getUser()); 26 | unsubscribe(); 27 | }); 28 | try { 29 | await signerManager.loginWithNip07(); 30 | onClose(); 31 | } catch (err) { 32 | alert("NIP-07 login failed"); 33 | console.error(err); 34 | } 35 | }; 36 | 37 | const handleLoginWithNip46 = async () => { 38 | const unsubscribe = signerManager.onChange(async () => { 39 | setUser(signerManager.getUser()); 40 | unsubscribe(); 41 | }); 42 | const bunkerUri = prompt("Enter your Bunker (NIP-46) URI:"); 43 | if (!bunkerUri) return; 44 | 45 | try { 46 | await signerManager.loginWithNip46(bunkerUri); 47 | onClose(); 48 | } catch (err) { 49 | alert("Failed to connect to remote signer."); 50 | console.error(err); 51 | } 52 | }; 53 | 54 | return ( 55 | 56 | Log In 57 | 58 | 59 | 62 | 65 | 72 | 73 | 74 | 75 | 76 | 77 | setShowCreateAccount(false)} 80 | /> 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/components/ReactedNoteCard.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Tooltip, Typography } from "@mui/material"; 2 | import { nip19 } from "nostr-tools"; 3 | import { DEFAULT_IMAGE_URL } from "../../../../utils/constants"; 4 | import { useAppContext } from "../../../..//hooks/useAppContext"; 5 | import { Event } from "nostr-tools" 6 | import { Notes } from "../../../../components/Notes"; 7 | 8 | interface ReactedNoteCardProps { 9 | note: Event; 10 | reactions: Event[]; 11 | } 12 | 13 | const ReactedNoteCard: React.FC = ({ 14 | note, 15 | reactions, 16 | }) => { 17 | const { profiles, fetchUserProfileThrottled } = useAppContext(); 18 | // Filter reactions that belong to this note 19 | const matchingReactions = reactions.filter((r) => { 20 | const taggedNoteId = r.tags.find((tag) => tag[0] === "e")?.[1]; 21 | return taggedNoteId === note.id; 22 | }); 23 | 24 | // Group by emoji 25 | const emojiGroups: Record = {}; 26 | matchingReactions.forEach((r) => { 27 | const emoji = r.content?.trim() || "👍"; 28 | if (!emojiGroups[emoji]) emojiGroups[emoji] = []; 29 | emojiGroups[emoji].push(r.pubkey); 30 | 31 | // Pre-fetch profile if not already fetched 32 | if (!profiles?.get(r.pubkey)) { 33 | fetchUserProfileThrottled(r.pubkey); 34 | } 35 | }); 36 | 37 | return ( 38 |
39 | {Object.entries(emojiGroups).map(([emoji, pubkeys]) => ( 40 |
49 | {emoji} 50 | {pubkeys.slice(0, 3).map((pubkey) => { 51 | const profile = profiles?.get(pubkey); 52 | const displayName = 53 | profile?.name || nip19.npubEncode(pubkey).substring(0, 8) + "..."; 54 | 55 | return ( 56 | 57 | 62 | 63 | ); 64 | })} 65 | {pubkeys.length > 3 && ( 66 | 67 | +{pubkeys.length - 3} more 68 | 69 | )} 70 |
71 | ))} 72 | 73 | {/* Your existing Note display */} 74 | 75 |
76 | ); 77 | }; 78 | 79 | export default ReactedNoteCard; 80 | -------------------------------------------------------------------------------- /src/components/Header/notification-utils.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "nostr-tools"; 2 | 3 | export type ParsedNotification = { 4 | type: "poll-response" | "comment" | "reaction" | "zap" | "unknown"; 5 | pollId?: string; 6 | postId?: string; 7 | fromPubkey: string | null; 8 | content?: string; 9 | reaction?: string; 10 | sats?: number | null; 11 | }; 12 | 13 | export function parseNotification(ev: Event): ParsedNotification { 14 | const getTag = (k: string) => ev.tags.find((t) => t[0] === k)?.[1] ?? null; 15 | 16 | // Check who sent it 17 | const fromPubkey = ev.pubkey ?? null; 18 | 19 | // POLL RESPONSE 20 | if (ev.kind === 1018) { 21 | return { 22 | type: "poll-response", 23 | pollId: getTag("e") || undefined, 24 | fromPubkey, 25 | content: ev.content, 26 | }; 27 | } 28 | 29 | // COMMENT 30 | if (ev.kind === 1 && getTag("p")) { 31 | return { 32 | type: "comment", 33 | fromPubkey, 34 | postId: getTag("e") ?? undefined, 35 | content: ev.content, 36 | }; 37 | } 38 | 39 | // REACTION 40 | if (ev.kind === 7) { 41 | return { 42 | type: "reaction", 43 | fromPubkey, 44 | postId: getTag("e") ?? undefined, 45 | reaction: ev.content, 46 | }; 47 | } 48 | 49 | // ZAP 50 | if (ev.kind === 9735) { 51 | // console.log("Parsing zap event", ev); 52 | let sats: number | null = null; 53 | const requestEvent = ev.tags.find((t) => t[0] === "description")?.[1]; 54 | let reqObj: Event | null = null; 55 | if (requestEvent) { 56 | try { 57 | reqObj = JSON.parse(requestEvent) as Event; 58 | sats = reqObj.tags.find((t) => t[0] === "amount") 59 | ? parseInt(reqObj?.tags.find((t) => t[0] === "amount")![1], 10) / 1000 60 | : null; 61 | return { 62 | type: "zap", 63 | sats, 64 | fromPubkey: reqObj!.pubkey, 65 | } 66 | 67 | } catch (e) { 68 | console.log("Failed to parse zap request event", e, ev); 69 | return { 70 | type: "unknown", 71 | fromPubkey, 72 | }// ignore 73 | } 74 | } 75 | else { 76 | console.log("Failed to parse zap request event", ev); 77 | return { 78 | type: "unknown", 79 | fromPubkey, 80 | }// 81 | } 82 | } 83 | 84 | return { 85 | type: "unknown", 86 | fromPubkey, 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/components/PollResponse/index.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useParams } from "react-router-dom"; 2 | import PollResponseForm from "./PollResponseForm"; 3 | import { useEffect, useState } from "react"; 4 | import { Event } from "nostr-tools/lib/types/core"; 5 | import { Filter } from "nostr-tools/lib/types/filter"; 6 | import { useRelays } from "../../hooks/useRelays"; 7 | import { Box, Button, CircularProgress } from "@mui/material"; 8 | import { useNotification } from "../../contexts/notification-context"; 9 | import { NOTIFICATION_MESSAGES } from "../../constants/notifications"; 10 | import ArrowBackIcon from "@mui/icons-material/ArrowBack"; 11 | import { pool } from "../../singletons"; 12 | import { nip19 } from "nostr-tools"; 13 | import { EventPointer } from "nostr-tools/lib/types/nip19"; 14 | 15 | export const PollResponse = () => { 16 | const { eventId: neventId } = useParams(); 17 | const [pollEvent, setPollEvent] = useState(); 18 | const navigate = useNavigate(); 19 | const { showNotification } = useNotification(); 20 | const { relays } = useRelays(); 21 | useEffect(() => { 22 | if (!neventId) return; 23 | fetchPollEvent(neventId); 24 | // eslint-disable-next-line react-hooks/exhaustive-deps 25 | }, [neventId]); 26 | 27 | if (!neventId) return; 28 | 29 | const fetchPollEvent = async (neventId: string) => { 30 | const decoded = nip19.decode(neventId).data as EventPointer; 31 | const filter: Filter = { 32 | ids: [decoded.id], 33 | }; 34 | const neventRelays = decoded.relays; 35 | const relaysToUse = Array.from( 36 | new Set([...relays, ...(neventRelays || [])]) 37 | ); 38 | try { 39 | const event = await pool.get(relaysToUse, filter); 40 | if (event === null) { 41 | showNotification(NOTIFICATION_MESSAGES.POLL_NOT_FOUND, "error"); 42 | navigate("/"); 43 | return; 44 | } 45 | setPollEvent(event); 46 | } catch (error) { 47 | console.error("Error fetching poll event:", error); 48 | showNotification(NOTIFICATION_MESSAGES.POLL_FETCH_ERROR, "error"); 49 | navigate("/"); 50 | } 51 | }; 52 | 53 | return ( 54 | 55 | 59 | {pollEvent === undefined ? ( 60 | 66 | 67 | 68 | ) : ( 69 | 70 | )} 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/hooks/MetadataProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useRef, useState, useEffect } from "react"; 2 | import { Event, Filter } from "nostr-tools"; 3 | import { useRelays } from "./useRelays"; 4 | import { pool } from "../singletons"; 5 | 6 | type EntityType = "movie" | "hashtag"; 7 | 8 | interface MetadataContextValue { 9 | metadata: Map; 10 | registerEntity: (type: EntityType, id: string) => void; 11 | } 12 | 13 | const MetadataContext = createContext(null); 14 | 15 | export const MetadataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 16 | const [metadata, setMetadata] = useState>(new Map()); 17 | const registered = useRef>(new Set()); 18 | const pending = useRef>(new Set()); 19 | const entityMap = useRef>(new Map()); // dTag => id 20 | const { relays } = useRelays(); 21 | 22 | // Queue flushing effect 23 | useEffect(() => { 24 | const interval = setInterval(() => { 25 | if (pending.current.size === 0) return; 26 | 27 | const tags = Array.from(pending.current); 28 | pending.current.clear(); 29 | 30 | const filter: Filter = { 31 | kinds: [30300], 32 | "#d": tags, 33 | }; 34 | 35 | pool.querySync(relays, filter).then((events: Event[]) => { 36 | const grouped = new Map(); 37 | for (const event of events) { 38 | const dTag = event.tags.find(([k, v]) => k === "d")?.[1]; 39 | if (!dTag) continue; 40 | const id = entityMap.current.get(dTag); 41 | if (!id) continue; 42 | 43 | if (!grouped.has(id)) grouped.set(id, []); 44 | grouped.get(id)!.push(event); 45 | } 46 | 47 | setMetadata((prev) => { 48 | const next = new Map(prev); 49 | for (const [id, evs] of Array.from(grouped.entries())) { 50 | next.set(id, evs); 51 | } 52 | return next; 53 | }); 54 | }); 55 | }, 2000); // Debounce interval 56 | 57 | return () => clearInterval(interval); 58 | }, [relays]); 59 | 60 | const registerEntity = (type: EntityType, id: string) => { 61 | const dTag = `${type}:${id}`; 62 | if (registered.current.has(dTag)) return; 63 | 64 | registered.current.add(dTag); 65 | pending.current.add(dTag); 66 | entityMap.current.set(dTag, id); 67 | }; 68 | 69 | return ( 70 | 71 | {children} 72 | 73 | ); 74 | }; 75 | 76 | export const useMetadata = () => { 77 | const ctx = useContext(MetadataContext); 78 | if (!ctx) throw new Error("useMetadata must be used within MetadataProvider"); 79 | return ctx; 80 | }; 81 | -------------------------------------------------------------------------------- /src/components/PollResults/index.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useParams } from "react-router-dom"; 2 | import { Filter } from "nostr-tools/lib/types/filter"; 3 | import { Event } from "nostr-tools/lib/types/core"; 4 | import { SimplePool } from "nostr-tools"; 5 | import { useRelays } from "../../hooks/useRelays"; 6 | import { useEffect, useState } from "react"; 7 | import { Typography } from "@mui/material"; 8 | import { Analytics } from "./Analytics"; 9 | import { SubCloser } from "nostr-tools/lib/types/abstract-pool"; 10 | import { useNotification } from "../../contexts/notification-context"; 11 | import { NOTIFICATION_MESSAGES } from "../../constants/notifications"; 12 | 13 | export const PollResults = () => { 14 | let { eventId } = useParams(); 15 | const [pollEvent, setPollEvent] = useState(); 16 | const [respones, setResponses] = useState(); 17 | const { showNotification } = useNotification(); 18 | const { relays } = useRelays(); 19 | let navigate = useNavigate(); 20 | 21 | const getUniqueLatestEvents = (events: Event[]) => { 22 | const eventMap = new Map(); 23 | 24 | events.forEach((event) => { 25 | if ( 26 | !eventMap.has(event.pubkey) || 27 | event.created_at > eventMap.get(event.pubkey).created_at 28 | ) { 29 | eventMap.set(event.pubkey, event); 30 | } 31 | }); 32 | 33 | return Array.from(eventMap.values()); 34 | }; 35 | 36 | const handleResultEvent = (event: Event) => { 37 | if (event.kind === 1068) { 38 | setPollEvent(event); 39 | } 40 | if (event.kind === 1070 || event.kind === 1018) { 41 | setResponses((prevResponses) => [...(prevResponses || []), event]); 42 | } 43 | }; 44 | 45 | const fetchPollEvents = async () => { 46 | if (!eventId) { 47 | showNotification(NOTIFICATION_MESSAGES.INVALID_URL, "error"); 48 | navigate("/"); 49 | } 50 | let resultFilter: Filter = { 51 | "#e": [eventId!], 52 | kinds: [1070, 1018], 53 | }; 54 | 55 | let pollFilter: Filter = { 56 | ids: [eventId!], 57 | }; 58 | let pool = new SimplePool(); 59 | let closer = pool.subscribeMany(relays, [resultFilter, pollFilter], { 60 | onevent: handleResultEvent, 61 | }); 62 | return closer; 63 | }; 64 | 65 | useEffect(() => { 66 | let closer: SubCloser | undefined; 67 | if (!pollEvent && !closer) { 68 | fetchPollEvents(); 69 | } 70 | return () => { 71 | if (closer) closer.close(); 72 | }; 73 | // eslint-disable-next-line react-hooks/exhaustive-deps 74 | }, [pollEvent]); 75 | 76 | console.log(pollEvent); 77 | 78 | if (pollEvent === undefined) { 79 | return Loading...; 80 | } 81 | 82 | return ( 83 | <> 84 | 88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/PollResponse/FetchResults.tsx: -------------------------------------------------------------------------------- 1 | import { Filter } from "nostr-tools/lib/types/filter"; 2 | import { Event } from "nostr-tools/lib/types/core"; 3 | import { useRelays } from "../../hooks/useRelays"; 4 | import { useEffect, useState } from "react"; 5 | import { Analytics } from "../PollResults/Analytics"; 6 | import { SubCloser } from "nostr-tools/lib/types/abstract-pool"; 7 | import { nip13 } from "nostr-tools"; 8 | import { pool } from "../../singletons"; 9 | 10 | interface FetchResultsProps { 11 | pollEvent: Event; 12 | filterPubkeys?: string[]; 13 | difficulty?: number; 14 | } 15 | export const FetchResults: React.FC = ({ 16 | pollEvent, 17 | filterPubkeys, 18 | difficulty, 19 | }) => { 20 | const [respones, setResponses] = useState(); 21 | const [closer, setCloser] = useState(); 22 | const relays = pollEvent.tags 23 | .filter((t) => t[0] === "relay") 24 | ?.map((r) => r[1]); 25 | const pollExpiration = pollEvent.tags.filter( 26 | (t) => t[0] === "endsAt" 27 | )?.[0]?.[1]; 28 | const { relays: userRelays } = useRelays(); 29 | const getUniqueLatestEvents = (events: Event[]) => { 30 | const eventMap = new Map(); 31 | 32 | events.forEach((event) => { 33 | if ( 34 | !eventMap.has(event.pubkey) || 35 | event.created_at > eventMap.get(event.pubkey)!.created_at 36 | ) { 37 | if (difficulty && nip13.getPow(event.id) < difficulty) { 38 | return; 39 | } 40 | eventMap.set(event.pubkey, event); 41 | } 42 | }); 43 | 44 | return Array.from(eventMap.values()); 45 | }; 46 | 47 | const handleResultEvent = (event: Event) => { 48 | setResponses((prevResponses) => [...(prevResponses || []), event]); 49 | }; 50 | 51 | const fetchVoteEvents = (filterPubkeys: string[]) => { 52 | if (closer) { 53 | closer.close(); 54 | setResponses(undefined); 55 | } 56 | let resultFilter: Filter = { 57 | "#e": [pollEvent.id], 58 | kinds: [1070, 1018], 59 | }; 60 | if (difficulty) { 61 | resultFilter["#W"] = [difficulty.toString()]; 62 | } 63 | if (filterPubkeys?.length) { 64 | resultFilter.authors = filterPubkeys; 65 | } 66 | if (pollExpiration) { 67 | resultFilter.until = Number(pollExpiration); 68 | } 69 | let pollRelays = pollEvent.tags.filter((t) => t[0] === "relay").map((t) => t[1]) 70 | const useRelays = relays?.length ? relays : userRelays; 71 | const finalRelays = Array.from(new Set([...pollRelays, ...useRelays])) 72 | let newCloser = pool.subscribeMany(finalRelays, [resultFilter], { 73 | onevent: handleResultEvent, 74 | }); 75 | setCloser(newCloser); 76 | }; 77 | 78 | useEffect(() => { 79 | fetchVoteEvents(filterPubkeys || []); 80 | return () => { 81 | if (closer) closer.close(); 82 | }; 83 | // eslint-disable-next-line react-hooks/exhaustive-deps 84 | }, [filterPubkeys]); 85 | 86 | return ( 87 | <> 88 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/components/FollowingFeed.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef } from "react"; 2 | import { Button, CircularProgress } from "@mui/material"; 3 | import { useUserContext } from "../../../../hooks/useUserContext"; 4 | import { Virtuoso } from "react-virtuoso"; 5 | import type { VirtuosoHandle } from "react-virtuoso"; 6 | import useImmersiveScroll from "../../../../hooks/useImmersiveScroll"; 7 | import RepostsCard from "./RepostedNoteCard"; // your new reposts card component 8 | import { useFollowingNotes } from "../hooks/useFollowingNotes"; 9 | 10 | const FollowingFeed = () => { 11 | const { user, requestLogin } = useUserContext(); 12 | const { notes, reposts, fetchNotes, loadingMore, fetchNewerNotes } = 13 | useFollowingNotes(); 14 | const virtuosoRef = useRef(null); 15 | const containerRef = useRef(null); 16 | 17 | useImmersiveScroll(containerRef, virtuosoRef, { smooth: true }); 18 | 19 | // Merge notes and reposts for sorting by created_at 20 | // Each item: { note: Event, reposts: Event[] } 21 | const mergedNotes = useMemo(() => { 22 | return Array.from(notes.values()) 23 | .map((note) => { 24 | const noteReposts = reposts.get(note.id) || []; 25 | // Get the latest repost time, if any 26 | const latestRepostTime = noteReposts.length 27 | ? Math.max(...noteReposts.map((r) => r.created_at)) 28 | : 0; 29 | 30 | // Use the later of the note's created_at or the latest repost's created_at 31 | const latestActivity = Math.max(note.created_at, latestRepostTime); 32 | 33 | return { 34 | note, 35 | reposts: noteReposts, 36 | latestActivity, 37 | }; 38 | }) 39 | .sort((a, b) => b.latestActivity - a.latestActivity); 40 | }, [notes, reposts]); 41 | 42 | useEffect(() => { 43 | if (user) { 44 | fetchNotes(); 45 | } 46 | }, [user]); 47 | 48 | return ( 49 |
50 | {!user ? ( 51 |
59 | 62 |
63 | ) : null} 64 | {loadingMore ? : null} 65 |
66 | { 70 | return ( 71 | 75 | ); 76 | }} 77 | style={{ height: "100%" }} 78 | followOutput={false} 79 | startReached={() => { 80 | fetchNewerNotes(); 81 | }} 82 | endReached={() => { 83 | fetchNotes(); 84 | }} 85 | /> 86 |
87 |
88 | ); 89 | }; 90 | 91 | export default FollowingFeed; 92 | -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/components/DiscoverFeed.tsx: -------------------------------------------------------------------------------- 1 | // src/features/Notes/components/Feeds/DiscoverFeed.tsx 2 | 3 | import { useEffect, useMemo, useRef } from "react"; 4 | import { Button, CircularProgress, Fab } from "@mui/material"; 5 | import { Virtuoso } from "react-virtuoso"; 6 | // add: 7 | import type { VirtuosoHandle } from "react-virtuoso"; 8 | import useImmersiveScroll from "../../../../hooks/useImmersiveScroll"; 9 | import { useUserContext } from "../../../../hooks/useUserContext"; 10 | import RepostsCard from "./RepostedNoteCard"; 11 | import { useDiscoverNotes } from "../hooks/useDiscoverNotes"; 12 | import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; 13 | 14 | const DiscoverFeed = () => { 15 | const { user, requestLogin } = useUserContext(); 16 | const { notes, newNotes, fetchNotes, loadingMore, mergeNewNotes } = 17 | useDiscoverNotes(); 18 | // add: 19 | const virtuosoRef = useRef(null); 20 | const containerRef = useRef(null); 21 | 22 | useImmersiveScroll(containerRef, virtuosoRef, { smooth: true }); 23 | 24 | useEffect(() => { 25 | if ( 26 | (!notes || notes.size === 0) && 27 | user && 28 | user.webOfTrust && 29 | user.webOfTrust.size > 0 30 | ) { 31 | fetchNotes(user.webOfTrust); 32 | } 33 | }, [user]); 34 | 35 | const mergedNotes = useMemo(() => { 36 | return Array.from(notes.values()) 37 | .map((note) => ({ 38 | note, 39 | latestActivity: note.created_at, 40 | })) 41 | .sort((a, b) => b.latestActivity - a.latestActivity); 42 | }, [notes]); 43 | 44 | return ( 45 |
46 | {!user ? ( 47 |
55 | 58 |
59 | ) : null} 60 | 61 | {loadingMore && ( 62 |
65 | 66 |
67 | )} 68 | 69 |
70 | ( 74 | 75 | )} 76 | style={{ height: "100%" }} 77 | followOutput={false} 78 | /> 79 |
80 | 81 | {/* Floating button for new notes */} 82 | {newNotes.size > 0 && ( 83 | 94 | See {newNotes.size} new posts 95 | 96 | )} 97 |
98 | ); 99 | }; 100 | 101 | export default DiscoverFeed; 102 | -------------------------------------------------------------------------------- /src/components/Common/Youtube/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | 3 | declare global { 4 | interface Window { 5 | YT: any; 6 | onYouTubeIframeAPIReady: () => void; 7 | _YTLoading?: boolean; 8 | _YTLoaded?: boolean; 9 | _YTCallbacks?: Array<() => void>; 10 | } 11 | } 12 | 13 | type YouTubePlayerProps = { 14 | url: string; 15 | }; 16 | 17 | export const YouTubePlayer: React.FC = ({ url }) => { 18 | const playerRef = useRef(null); 19 | const ytPlayer = useRef(null); 20 | 21 | function extractVideoId(url: string): string | null { 22 | const regExp = /(?:youtube\.com\/.*v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/; 23 | const match = url.match(regExp); 24 | return match && match[1] ? match[1] : null; 25 | } 26 | 27 | function loadYouTubeAPI(): Promise { 28 | return new Promise((resolve) => { 29 | // Already loaded 30 | if (window.YT && window.YT.Player) { 31 | resolve(); 32 | return; 33 | } 34 | 35 | // Already loading — queue callback 36 | if (window._YTLoading) { 37 | window._YTCallbacks!.push(resolve); 38 | return; 39 | } 40 | 41 | // First load 42 | window._YTLoading = true; 43 | window._YTCallbacks = [resolve]; 44 | 45 | const tag = document.createElement("script"); 46 | tag.src = "https://www.youtube.com/iframe_api"; 47 | document.body.appendChild(tag); 48 | 49 | window.onYouTubeIframeAPIReady = () => { 50 | window._YTLoaded = true; 51 | window._YTLoading = false; 52 | 53 | // run all queued callbacks 54 | window._YTCallbacks!.forEach((cb) => cb()); 55 | window._YTCallbacks = []; 56 | }; 57 | }); 58 | } 59 | 60 | useEffect(() => { 61 | const videoId = extractVideoId(url); 62 | if (!videoId) { 63 | console.error("Invalid YouTube URL:", url); 64 | return; 65 | } 66 | 67 | let cancelled = false; 68 | 69 | loadYouTubeAPI().then(() => { 70 | if (cancelled || !playerRef.current) return; 71 | 72 | // Safe now — Player constructor exists 73 | ytPlayer.current = new window.YT.Player(playerRef.current, { 74 | width: "100%", 75 | height: "100%", 76 | videoId, 77 | events: { 78 | onReady: onPlayerReady, 79 | onStateChange: onPlayerStateChange, 80 | }, 81 | }); 82 | }); 83 | 84 | function onPlayerReady(event: any) { 85 | console.log("Player ready"); 86 | } 87 | 88 | function onPlayerStateChange(event: any) { 89 | console.log("Player state changed to:", event.data); 90 | } 91 | 92 | return () => { 93 | cancelled = true; 94 | if (ytPlayer.current) { 95 | ytPlayer.current.destroy(); 96 | } 97 | }; 98 | }, [url]); 99 | 100 | return ( 101 |
109 |
116 |
117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /Moderation.md: -------------------------------------------------------------------------------- 1 | # Topic-Scoped Moderation Events (Draft NIP) 2 | 3 | This document describes a **topic-scoped moderation system** implemented using a custom Nostr event kind. 4 | It allows clients to express and aggregate moderation opinions **without global censorship**. 5 | 6 | --- 7 | 8 | ## Event Kind 9 | 10 | kind: 1011 11 | 12 | **Purpose:** 13 | Represents moderation actions within a specific hashtag/topic. 14 | 15 | --- 16 | 17 | ## Supported Moderation Actions 18 | 19 | ### 1. Mark Note as Off-Topic 20 | 21 | Marks a specific note as off-topic for a given topic. 22 | 23 | Required Tags: 24 | 25 | - ["t", ""] — The hashtag / topic being moderated 26 | - ["e", ""] — The note being marked off-topic 27 | 28 | Content (human-readable): 29 | 30 | Marked as off-topic 31 | 32 | Semantics: 33 | 34 | - The event does not delete or hide the note globally 35 | - It expresses the publisher’s opinion that the note is off-topic 36 | - Clients may aggregate multiple moderation events 37 | - Clients may allow “show anyway” overrides 38 | 39 | --- 40 | 41 | ### 2. Remove User From Topic 42 | 43 | Marks a user as excluded from a topic. 44 | 45 | Required Tags: 46 | 47 | - ["t", ""] — The hashtag / topic 48 | - ["p", ""] — The user being removed 49 | 50 | Content: 51 | 52 | Removed user from topic 53 | 54 | Semantics: 55 | 56 | - Indicates the publisher believes this user’s posts are not relevant to the topic 57 | - Applies to future and existing posts by that user under the topic 58 | - Clients should treat this as topic-local filtering, not a global block 59 | 60 | --- 61 | 62 | ## Event Schema 63 | 64 | kind: 1011 65 | pubkey: 66 | created_at: 67 | 68 | Tags: 69 | 70 | - ["t", ""] 71 | - Either ["e", ""] OR ["p", ""] 72 | 73 | Atleast one moderation target SHOULD be present. 74 | 75 | --- 76 | 77 | ## Aggregation Model 78 | 79 | - Moderation is additive and opinion-based 80 | - Multiple moderation events may exist for the same target 81 | - Clients should aggregate by: 82 | - (topic, note_id) → off-topic curators 83 | - (topic, pubkey) → user removals 84 | - No quorum or threshold is enforced at protocol level 85 | 86 | --- 87 | 88 | ## Trust & Visibility Model 89 | 90 | Clients may support multiple feed modes: 91 | 92 | Unfiltered: 93 | 94 | - Ignore all moderation events 95 | 96 | Filtered (Global): 97 | 98 | - Hide content if a given threshold of moderators flagged it 99 | 100 | Filtered (Web of Trust) 101 | 102 | - Hide content only if flagged by a users Web of Trust 103 | 104 | Clients may allow users to: 105 | 106 | - Select which moderators they trust 107 | - Override hidden content 108 | - Persist moderator preferences locally 109 | 110 | --- 111 | 112 | ## UI Expectations (Recommended) 113 | 114 | Clients supporting this spec should: 115 | 116 | - Indicate why content is hidden 117 | - Show who moderated it 118 | - Allow temporary overrides 119 | - Never hard-delete content based on moderation events 120 | 121 | --- 122 | 123 | ## Design Philosophy 124 | 125 | This system avoids: 126 | 127 | - Global censorship 128 | - Centralized moderator lists 129 | 130 | It provides: 131 | 132 | - Topic-scoped moderation 133 | - Social-graph-based trust 134 | - User-controlled filtering 135 | -------------------------------------------------------------------------------- /src/components/Feed/Feed.tsx: -------------------------------------------------------------------------------- 1 | import { Event } from "nostr-tools/lib/types/core"; 2 | import React, { useEffect, useState } from "react"; 3 | import PollResponseForm from "../PollResponse/PollResponseForm"; 4 | import { makeStyles } from "@mui/styles"; 5 | import { Notes } from "../Notes"; 6 | import { nip19 } from "nostr-tools"; 7 | import ReplayIcon from "@mui/icons-material/Replay"; 8 | import Typography from "@mui/material/Typography"; 9 | import OverlappingAvatars from "../Common/OverlappingAvatars"; 10 | 11 | const useStyles = makeStyles((theme) => ({ 12 | root: { 13 | margin: "20px auto", 14 | width: "100%", 15 | maxWidth: "600px", 16 | }, 17 | repostText: { 18 | fontSize: "0.75rem", 19 | color: "#4caf50", 20 | marginLeft: "10px", 21 | marginRight: 10, 22 | display: "flex", 23 | flexDirection: "row", 24 | }, 25 | })); 26 | 27 | interface FeedProps { 28 | events: Event[]; // original events (polls, notes) 29 | reposts?: Map; // kind: 16 reposts 30 | userResponses: Map; 31 | } 32 | 33 | export const Feed: React.FC = ({ 34 | events, 35 | reposts, 36 | userResponses, 37 | }) => { 38 | const classes = useStyles(); 39 | const [mergedEvents, setMergedEvents] = useState< 40 | { event: Event; repostedBy?: Array; timestamp: number }[] 41 | >([]); 42 | 43 | useEffect(() => { 44 | const eventMap: { [id: string]: Event } = {}; 45 | events.forEach((e) => { 46 | eventMap[e.id] = e; 47 | }); 48 | 49 | const merged: { event: Event; repostedBy?: string[]; timestamp: number }[] = 50 | []; 51 | 52 | // Original events 53 | events.forEach((e) => { 54 | const repostsForEvent = reposts?.get(e.id); 55 | (repostsForEvent || []).sort( 56 | (a: Event, b: Event) => b.created_at - a.created_at 57 | ); 58 | 59 | merged.push({ 60 | event: e, 61 | timestamp: Math.max( 62 | repostsForEvent?.[0]?.created_at || 0, 63 | e.created_at 64 | ), 65 | repostedBy: repostsForEvent?.map((e) => e.pubkey) || [], 66 | }); 67 | }); 68 | 69 | // Sort newest first 70 | merged.sort((a, b) => b.timestamp - a.timestamp); 71 | setMergedEvents(merged); 72 | }, [events, reposts]); 73 | 74 | return ( 75 |
76 | {mergedEvents.map(({ event, repostedBy }) => { 77 | const key = `${event.id}-${repostedBy || "original"}`; 78 | return ( 79 |
80 | {repostedBy?.length !== 0 ? ( 81 |
82 | 83 | 84 | {" "} 85 | Reposted by 86 | 87 | 88 |
89 | ) : null} 90 | {event.kind === 1 ? ( 91 | 92 | ) : event.kind === 1068 ? ( 93 | 97 | ) : null} 98 |
99 | ); 100 | })} 101 |
102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/Ratings/RateProfileModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Box, 4 | Button, 5 | Modal, 6 | TextField, 7 | Typography, 8 | CircularProgress, 9 | } from "@mui/material"; 10 | import { nip19, Event, SimplePool } from "nostr-tools"; 11 | import { useRelays } from "../../hooks/useRelays"; 12 | import ProfileCard from "../Profile/ProfileCard"; 13 | 14 | interface RateProfileModalProps { 15 | open: boolean; 16 | onClose: () => void; 17 | } 18 | 19 | const RateProfileModal: React.FC = ({ 20 | open, 21 | onClose, 22 | }) => { 23 | const [npubInput, setNpubInput] = useState(""); 24 | const [loading, setLoading] = useState(false); 25 | const [profile, setProfile] = useState(null); 26 | const { relays } = useRelays(); 27 | 28 | const handleNpubSubmit = async () => { 29 | setLoading(true); 30 | try { 31 | const { data: pubkey } = nip19.decode(npubInput); 32 | const pool = new SimplePool(); 33 | 34 | const sub = pool.subscribeMany( 35 | relays, 36 | [ 37 | { 38 | kinds: [0], 39 | authors: [pubkey as string], 40 | limit: 1, 41 | }, 42 | ], 43 | { 44 | onevent: (event) => { 45 | setProfile(event); 46 | setLoading(false); 47 | sub.close(); 48 | }, 49 | oneose: () => { 50 | setLoading(false); 51 | sub.close(); 52 | }, 53 | } 54 | ); 55 | } catch (e) { 56 | alert("Invalid npub."); 57 | setLoading(false); 58 | } 59 | }; 60 | 61 | const handleClose = () => { 62 | setProfile(null); 63 | setNpubInput(""); 64 | onClose(); 65 | }; 66 | 67 | return ( 68 | 69 | 80 | 81 | Rate a Profile 82 | 83 | 84 | {!profile ? ( 85 | <> 86 | setNpubInput(e.target.value)} 92 | sx={{ mb: 2 }} 93 | disabled={loading} 94 | /> 95 | 103 | 104 | ) : ( 105 | <> 106 | 107 | 115 | 116 | )} 117 | 118 | 119 | ); 120 | }; 121 | 122 | export default RateProfileModal; 123 | -------------------------------------------------------------------------------- /src/components/Movies/MoviePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useCallback } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { 4 | Typography, 5 | Box, 6 | CircularProgress, 7 | FormControl, 8 | InputLabel, 9 | Select, 10 | MenuItem, 11 | SelectChangeEvent, 12 | } from "@mui/material"; 13 | import { SimplePool, Event, Filter } from "nostr-tools"; 14 | import { useRelays } from "../../hooks/useRelays"; 15 | import MovieCard from "./MovieCard"; 16 | import ReviewCard from "../Ratings/ReviewCard"; 17 | import { useUserContext } from "../../hooks/useUserContext"; 18 | 19 | const MoviePage = () => { 20 | const { imdbId } = useParams<{ imdbId: string }>(); 21 | const [loading, setLoading] = useState(true); 22 | const [reviewMap, setReviewMap] = useState>(new Map()); 23 | const [filterMode, setFilterMode] = useState<"everyone" | "following">( 24 | "everyone" 25 | ); 26 | const { user } = useUserContext(); 27 | const { relays } = useRelays(); 28 | 29 | const fetchReviews = useCallback(() => { 30 | if (!imdbId) return; 31 | 32 | const pool = new SimplePool(); 33 | const newReviewMap = new Map(); 34 | 35 | const filters: Filter[] = [ 36 | { 37 | kinds: [34259], 38 | "#d": [`movie:${imdbId}`], 39 | "#c": ["true"], 40 | ...(filterMode === "following" && user?.follows?.length 41 | ? { authors: user.follows } 42 | : {}), 43 | }, 44 | ]; 45 | 46 | const sub = pool.subscribeMany(relays, filters, { 47 | onevent(e) { 48 | if (newReviewMap.has(e.pubkey)) { 49 | if (newReviewMap.get(e.pubkey)!.created_at < e.created_at) 50 | newReviewMap.set(e.pubkey, e); 51 | } else { 52 | newReviewMap.set(e.pubkey, e); 53 | } 54 | }, 55 | oneose() { 56 | setReviewMap(newReviewMap); 57 | setLoading(false); 58 | }, 59 | }); 60 | 61 | return () => sub.close(); 62 | }, [imdbId, filterMode, user?.follows]); 63 | 64 | useEffect(() => { 65 | setLoading(true); 66 | fetchReviews(); 67 | }, [fetchReviews]); 68 | 69 | const handleFilterChange = ( 70 | event: SelectChangeEvent<"everyone" | "following"> 71 | ) => { 72 | setFilterMode(event.target.value as "everyone" | "following"); 73 | }; 74 | 75 | if (loading) return ; 76 | 77 | return ( 78 | 79 | 80 | 81 | Reviews 82 | 83 | 84 | Filter 85 | 96 | 97 | 98 | 99 | {reviewMap.size ? ( 100 | Array.from(reviewMap.values()).map((review) => ( 101 | 102 | )) 103 | ) : ( 104 | No reviews found. 105 | )} 106 | 107 | ); 108 | }; 109 | 110 | export default MoviePage; 111 | -------------------------------------------------------------------------------- /src/components/PollResults/Analytics.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Paper, 3 | Table, 4 | TableBody, 5 | TableCell, 6 | TableContainer, 7 | TableRow, 8 | } from "@mui/material"; 9 | import { Event } from "nostr-tools/lib/types/core"; 10 | import { useEffect } from "react"; 11 | import { useAppContext } from "../../hooks/useAppContext"; 12 | import OverlappingAvatars from "../Common/OverlappingAvatars"; 13 | import { TextWithImages } from "../Common/Parsers/TextWithImages"; 14 | 15 | interface AnalyticsProps { 16 | pollEvent: Event; 17 | responses: Event[]; 18 | } 19 | 20 | export const Analytics: React.FC = ({ 21 | pollEvent, 22 | responses, 23 | }) => { 24 | const label = 25 | pollEvent.tags.find((t) => t[0] === "label")?.[1] || pollEvent.content; 26 | const options = pollEvent.tags.filter((t) => t[0] === "option"); 27 | 28 | const { profiles, fetchUserProfileThrottled } = useAppContext(); 29 | 30 | useEffect(() => { 31 | responses.forEach((event) => { 32 | const responderId = event.pubkey; 33 | if (!profiles?.get(responderId)) { 34 | fetchUserProfileThrottled(responderId); 35 | } 36 | }); 37 | // eslint-disable-next-line react-hooks/exhaustive-deps 38 | }, []); 39 | 40 | const calculateResults = () => { 41 | const results: { count: number; responders: Set }[] = options.map( 42 | () => ({ count: 0, responders: new Set() }) 43 | ); 44 | // Count responses from events 45 | responses.forEach((event) => { 46 | const responderId = event.pubkey; // Assuming event.pubkey holds the user ID 47 | event.tags.forEach((tag: string[]) => { 48 | if (tag[0] === "response") { 49 | const optionId = tag[1]; 50 | const responseIndex = options.findIndex( 51 | (optionTag) => optionTag[1] === optionId 52 | ); 53 | if (responseIndex !== -1) { 54 | if (!results[responseIndex].responders.has(responderId)) { 55 | results[responseIndex].count++; 56 | results[responseIndex].responders.add(responderId); 57 | } 58 | } 59 | } 60 | }); 61 | }); 62 | return results; 63 | }; 64 | const results = calculateResults(); 65 | 66 | const calculatePercentages = (counts: number[]) => { 67 | const total = counts.reduce((acc, count) => acc + count, 0); 68 | if (total === 0) { 69 | return counts.map(() => "0.00"); 70 | } 71 | return counts.map((count) => ((count / total) * 100).toFixed(2)); 72 | }; 73 | 74 | return ( 75 | <> 76 | {/* {label} */} 77 | 78 | 79 | 80 | {options.map((option, index) => { 81 | const responders = Array.from(results[index].responders); 82 | return ( 83 | 84 | 85 | {} 86 | 87 | 88 | {calculatePercentages(results.map((r) => r.count))[index]}% 89 | 90 | 91 | 92 | 93 | 94 | ); 95 | })} 96 | 97 |
98 |
99 | 100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /src/components/Common/Repost/reposts.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Tooltip } from "@mui/material"; 3 | import RepeatIcon from "@mui/icons-material/Repeat"; 4 | import { Event, EventTemplate } from "nostr-tools"; 5 | import { useUserContext } from "../../../hooks/useUserContext"; 6 | import { useNotification } from "../../../contexts/notification-context"; 7 | import { NOTIFICATION_MESSAGES } from "../../../constants/notifications"; 8 | import { useAppContext } from "../../../hooks/useAppContext"; 9 | import { useRelays } from "../../../hooks/useRelays"; 10 | import { pool } from "../../../singletons"; 11 | import { signEvent } from "../../../nostr"; 12 | 13 | interface RepostButtonProps { 14 | event: Event; 15 | } 16 | 17 | const RepostButton: React.FC = ({ event }) => { 18 | const { user } = useUserContext(); 19 | const { showNotification } = useNotification(); 20 | const { relays } = useRelays(); 21 | const { repostsMap, fetchRepostsThrottled, addEventToMap } = useAppContext(); 22 | 23 | const [reposted, setReposted] = useState(false); 24 | 25 | useEffect(() => { 26 | const checkAndFetch = async () => { 27 | if (!repostsMap?.get(event.id)) { 28 | await fetchRepostsThrottled(event.id); 29 | } else if (user) { 30 | const repostedByUser = repostsMap 31 | .get(event.id) 32 | ?.some((e: Event) => e.pubkey === user.pubkey); 33 | setReposted(!!repostedByUser); 34 | } 35 | }; 36 | 37 | checkAndFetch(); 38 | }, [event.id, repostsMap, fetchRepostsThrottled, user]); 39 | 40 | const handleRepost = async () => { 41 | if (!user) { 42 | showNotification(NOTIFICATION_MESSAGES.LOGIN_TO_REPOST, "warning"); 43 | return; 44 | } 45 | 46 | if (reposted) return; 47 | 48 | const isKind1 = event.kind === 1; 49 | 50 | const repostTemplate: EventTemplate = { 51 | kind: isKind1 ? 6 : 16, 52 | created_at: Math.floor(Date.now() / 1000), 53 | tags: [ 54 | ["e", event.id, relays[0], event.pubkey], 55 | ["p", event.pubkey], 56 | ], 57 | content: isKind1 ? JSON.stringify(event) : "", 58 | }; 59 | 60 | if (!isKind1) { 61 | repostTemplate.tags.push(["k", event.kind.toString()]); 62 | } 63 | 64 | try { 65 | const signedEvent = await signEvent(repostTemplate, user.privateKey); 66 | pool.publish(relays, signedEvent); 67 | addEventToMap(signedEvent); 68 | setReposted(true); 69 | } catch (error) { 70 | console.error("Repost failed:", error); 71 | showNotification("Failed to repost event", "error"); 72 | } 73 | }; 74 | 75 | return ( 76 |
77 | 82 | 90 | 106 | 107 | 108 |
109 | ); 110 | }; 111 | 112 | export default RepostButton; 113 | -------------------------------------------------------------------------------- /src/hooks/useTopicExplorerScroll.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import type { VirtuosoHandle } from "react-virtuoso"; 3 | 4 | type Options = { 5 | smooth?: boolean; 6 | debounceMs?: number; 7 | }; 8 | 9 | export default function useTopicExplorerScroll( 10 | containerRef: React.RefObject, 11 | virtuosoRef: React.RefObject, 12 | scrollContainerRef: React.RefObject, 13 | options: Options = {} 14 | ) { 15 | const { smooth = true, debounceMs = 400 } = options; 16 | const lastActionRef = useRef(0); 17 | 18 | useEffect(() => { 19 | const container = containerRef.current; 20 | const scroller = scrollContainerRef.current; 21 | if (!container || !scroller) return; 22 | 23 | const now = () => Date.now(); 24 | 25 | const bringContainerToTop = () => { 26 | // calculate distance from scroller top to container top 27 | const containerOffsetTop = container.offsetTop; 28 | const scrollerScrollTop = scroller.scrollTop; 29 | 30 | // scroll to position where container aligns with scroller top 31 | scroller.scrollTo({ 32 | top: containerOffsetTop, 33 | behavior: smooth ? "smooth" : "auto", 34 | }); 35 | }; 36 | 37 | const onContainerWheel = (e: WheelEvent) => { 38 | // scroll down inside virtuoso -> bring container to top 39 | if (e.deltaY > 10) { 40 | const containerOffsetTop = container.offsetTop; 41 | const scrollerScrollTop = scroller.scrollTop; 42 | 43 | // if container is not already scrolled to top, bring it there 44 | if (scrollerScrollTop < containerOffsetTop) { 45 | bringContainerToTop(); 46 | } 47 | } 48 | 49 | // scroll up inside virtuoso -> scroll outer container to top 50 | if (e.deltaY < -10) { 51 | if (now() - lastActionRef.current > debounceMs) { 52 | lastActionRef.current = now(); 53 | scroller.scrollTo({ 54 | top: 0, 55 | behavior: smooth ? "smooth" : "auto", 56 | }); 57 | } 58 | } 59 | }; 60 | 61 | let touchStartY = 0; 62 | const onContainerTouchStart = (e: TouchEvent) => { 63 | touchStartY = e.touches?.[0]?.clientY ?? 0; 64 | }; 65 | const onContainerTouchMove = (e: TouchEvent) => { 66 | const t = e.touches?.[0]; 67 | if (!t) return; 68 | 69 | const delta = touchStartY - t.clientY; // positive when swiped up (scroll down) 70 | 71 | if (delta > 10) { 72 | const containerOffsetTop = container.offsetTop; 73 | const scrollerScrollTop = scroller.scrollTop; 74 | if (scrollerScrollTop < containerOffsetTop) { 75 | bringContainerToTop(); 76 | } 77 | } 78 | 79 | const pullDown = t.clientY - touchStartY; // positive when swiped down (scroll up) 80 | if (pullDown > 10) { 81 | if (now() - lastActionRef.current > debounceMs) { 82 | lastActionRef.current = now(); 83 | scroller.scrollTo({ 84 | top: 0, 85 | behavior: smooth ? "smooth" : "auto", 86 | }); 87 | } 88 | } 89 | }; 90 | 91 | container.addEventListener("wheel", onContainerWheel, { passive: true }); 92 | container.addEventListener("touchstart", onContainerTouchStart, { 93 | passive: true, 94 | }); 95 | container.addEventListener("touchmove", onContainerTouchMove, { 96 | passive: true, 97 | }); 98 | 99 | return () => { 100 | container.removeEventListener("wheel", onContainerWheel); 101 | container.removeEventListener("touchstart", onContainerTouchStart); 102 | container.removeEventListener("touchmove", onContainerTouchMove); 103 | }; 104 | }, [containerRef, scrollContainerRef, smooth, debounceMs]); 105 | } -------------------------------------------------------------------------------- /src/components/Feed/NotesFeed/hooks/useDiscoverNotes.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useCallback } from "react"; 2 | import { pool } from "../../../../singletons"; 3 | import { useRelays } from "../../../../hooks/useRelays"; 4 | import { Filter } from "nostr-tools/lib/types"; 5 | import { useUserContext } from "../../../../hooks/useUserContext"; 6 | 7 | const CHUNK_SIZE = 1000; 8 | const LOAD_TIMEOUT_MS = 5000; 9 | 10 | function chunkArray(arr: T[], size: number): T[][] { 11 | const res = []; 12 | for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size)); 13 | return res; 14 | } 15 | 16 | export const useDiscoverNotes = () => { 17 | const { relays } = useRelays(); 18 | const { user } = useUserContext(); 19 | const [notes, setNotes] = useState>(new Map()); 20 | const [newNotes, setNewNotes] = useState>(new Map()); 21 | const [loadingMore, setLoadingMore] = useState(false); 22 | const [initialLoadComplete, setInitialLoadComplete] = useState(false); 23 | const subscriptionsRef = useRef([]); 24 | const fetchedRef = useRef(false); 25 | 26 | const mergeNewNotes = useCallback(() => { 27 | setNotes((prev) => { 28 | const merged = new Map(prev); 29 | newNotes.forEach((note, id) => { 30 | merged.set(id, note); 31 | }); 32 | return merged; 33 | }); 34 | setNewNotes(new Map()); 35 | }, [newNotes]); 36 | 37 | const fetchNotes = useCallback((webOfTrust: Set) => { 38 | if (!webOfTrust?.size || !relays?.length || fetchedRef.current) return; 39 | 40 | fetchedRef.current = true; 41 | subscriptionsRef.current.forEach((c) => c?.close?.()); 42 | subscriptionsRef.current = []; 43 | 44 | const followsSet = new Set(user?.follows); 45 | const filteredAuthors = Array.from(webOfTrust) 46 | 47 | setLoadingMore(true); 48 | const chunks = chunkArray(filteredAuthors, CHUNK_SIZE); 49 | let completedChunks = 0; 50 | 51 | chunks.forEach((chunk) => { 52 | const filter: Filter = { kinds: [1], authors: chunk, limit: 20 }; 53 | 54 | const closer = pool.subscribeMany(relays, [filter], { 55 | onevent: (event) => { 56 | // Before initial load complete, add to main notes 57 | // After initial load, add to newNotes 58 | const targetSetter = initialLoadComplete ? setNewNotes : setNotes; 59 | targetSetter((prev) => { 60 | const updated = new Map(prev); 61 | const existing = updated.get(event.id); 62 | if (!existing || existing.created_at < event.created_at) { 63 | updated.set(event.id, event); 64 | } 65 | return updated; 66 | }); 67 | }, 68 | oneose: () => { 69 | completedChunks++; 70 | if (completedChunks === chunks.length) { 71 | setLoadingMore(false); 72 | setInitialLoadComplete(true); 73 | } 74 | closer.close(); 75 | }, 76 | }); 77 | 78 | subscriptionsRef.current.push(closer); 79 | }); 80 | 81 | const timeout = setTimeout(() => { 82 | setLoadingMore(false); 83 | setInitialLoadComplete(true); 84 | }, LOAD_TIMEOUT_MS); 85 | 86 | return () => { 87 | clearTimeout(timeout); 88 | subscriptionsRef.current.forEach((c) => c?.close?.()); 89 | subscriptionsRef.current = []; 90 | }; 91 | }, [relays, user?.follows, initialLoadComplete]); 92 | 93 | return { notes, newNotes, loadingMore, fetchNotes, mergeNewNotes }; 94 | }; 95 | -------------------------------------------------------------------------------- /src/utils/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; 2 | import { generateSecretKey } from "nostr-tools"; 3 | import { User } from "../contexts/user-context"; 4 | import { USER_DATA_TTL_HOURS } from "./constants"; 5 | 6 | const LOCAL_STORAGE_KEYS = "pollerama:keys"; 7 | const LOCAL_BUNKER_URI = "pollerama:bunkerUri"; 8 | const LOCAL_APP_SECRET_KEY = "bunker:clientSecretKey"; 9 | const LOCAL_USER_DATA = "pollerama:userData"; 10 | 11 | type Keys = { pubkey: string; secret?: string }; 12 | type BunkerUri = { bunkerUri: string }; 13 | 14 | export const getKeysFromLocalStorage = () => { 15 | return JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEYS) || "{}") as Keys; 16 | }; 17 | 18 | export const getBunkerUriInLocalStorage = () => { 19 | return JSON.parse( 20 | localStorage.getItem(LOCAL_BUNKER_URI) || "{}" 21 | ) as BunkerUri; 22 | }; 23 | 24 | export const getAppSecretKeyFromLocalStorage = () => { 25 | const hexSecretKey = localStorage.getItem(LOCAL_APP_SECRET_KEY); 26 | if (!hexSecretKey) { 27 | const newSecret = generateSecretKey(); 28 | localStorage.setItem(LOCAL_APP_SECRET_KEY, bytesToHex(newSecret)); 29 | return newSecret; 30 | } 31 | return hexToBytes(hexSecretKey); 32 | }; 33 | 34 | export const setAppSecretInLocalStorage = (secret: Uint8Array) => { 35 | localStorage.setItem(LOCAL_STORAGE_KEYS, bytesToHex(secret)); 36 | }; 37 | 38 | export const setKeysInLocalStorage = (pubkey: string, secret?: string) => { 39 | localStorage.setItem(LOCAL_STORAGE_KEYS, JSON.stringify({ pubkey, secret })); 40 | }; 41 | 42 | export const setBunkerUriInLocalStorage = (bunkerUri: string) => { 43 | localStorage.setItem(LOCAL_BUNKER_URI, JSON.stringify({ bunkerUri })); 44 | }; 45 | 46 | export const removeKeysFromLocalStorage = () => { 47 | localStorage.removeItem(LOCAL_STORAGE_KEYS); 48 | }; 49 | 50 | export const removeBunkerUriFromLocalStorage = () => { 51 | localStorage.removeItem(LOCAL_BUNKER_URI); 52 | }; 53 | 54 | export const removeAppSecretFromLocalStorage = () => { 55 | localStorage.removeItem(LOCAL_APP_SECRET_KEY); 56 | }; 57 | 58 | type UserData = { 59 | user: User; 60 | expiresAt: number; 61 | }; 62 | 63 | export const setUserDataInLocalStorage = ( 64 | user: User, 65 | ttlInHours = USER_DATA_TTL_HOURS 66 | ) => { 67 | const now = new Date(); 68 | const expiresAt = now.setHours(now.getHours() + ttlInHours); 69 | 70 | const userData: UserData = { 71 | user, 72 | expiresAt, 73 | }; 74 | 75 | localStorage.setItem(LOCAL_USER_DATA, JSON.stringify(userData)); 76 | }; 77 | 78 | export const getUserDataFromLocalStorage = (): { user: User } | null => { 79 | const data = localStorage.getItem(LOCAL_USER_DATA); 80 | if (!data) return null; 81 | 82 | try { 83 | const { user, expiresAt } = JSON.parse(data) as UserData; 84 | const isExpired = Date.now() > expiresAt; 85 | 86 | // Remove expired data 87 | if (isExpired) { 88 | localStorage.removeItem(LOCAL_USER_DATA); 89 | return null; 90 | } 91 | 92 | return { user }; 93 | } catch (error) { 94 | console.error("Failed to parse user data from localStorage", error); 95 | return null; 96 | } 97 | }; 98 | 99 | const MODERATOR_PREF_KEY = (tag: string) => `moderatorPrefs:${tag}`; 100 | 101 | export const loadModeratorPrefs = ( 102 | tag: string, 103 | allModerators: string[] 104 | ): string[] => { 105 | const json = localStorage.getItem(MODERATOR_PREF_KEY(tag)); 106 | if (!json) return allModerators; 107 | try { 108 | const saved = JSON.parse(json); 109 | if (Array.isArray(saved)) return saved; 110 | } catch {} 111 | return allModerators; 112 | }; 113 | 114 | export const saveModeratorPrefs = (tag: string, selected: string[]) => { 115 | localStorage.setItem(MODERATOR_PREF_KEY(tag), JSON.stringify(selected)); 116 | }; 117 | 118 | export const removeUserDataFromLocalStorage = () => { 119 | localStorage.removeItem(LOCAL_USER_DATA); 120 | }; 121 | -------------------------------------------------------------------------------- /src/nostr/index.ts: -------------------------------------------------------------------------------- 1 | import { Event, EventTemplate, Filter, finalizeEvent, SimplePool } from "nostr-tools"; 2 | import { hexToBytes } from "@noble/hashes/utils"; 3 | import { pool } from "../singletons"; 4 | import { signerManager } from "../singletons/Signer/SignerManager"; 5 | 6 | export const defaultRelays = [ 7 | "wss://relay.damus.io/", 8 | "wss://relay.primal.net/", 9 | "wss://nos.lol", 10 | "wss://relay.nostr.wirednet.jp/", 11 | "wss://nostr-01.yakihonne.com", 12 | "wss://relay.snort.social", 13 | "wss://relay.nostr.band", 14 | "wss://nostr21.com", 15 | ]; 16 | 17 | export const fetchUserProfile = async ( 18 | pubkey: string, 19 | relays: string[] = defaultRelays 20 | ) => { 21 | let result = await pool.get(relays, { kinds: [0], authors: [pubkey] }); 22 | return result; 23 | }; 24 | 25 | export async function parseContacts(contactList: Event) { 26 | if (contactList) { 27 | return contactList.tags.reduce>((result, [name, value]) => { 28 | if (name === "p") { 29 | result.add(value); 30 | } 31 | return result; 32 | }, new Set()); 33 | } 34 | return new Set(); 35 | } 36 | 37 | export const fetchUserProfiles = async ( 38 | pubkeys: string[], 39 | pool: SimplePool, 40 | relays: string[] = defaultRelays 41 | ) => { 42 | let result = await pool.querySync(relays, { 43 | kinds: [0], 44 | authors: pubkeys, 45 | }); 46 | return result; 47 | }; 48 | 49 | export const fetchReposts = async ( 50 | ids: string[], 51 | pool: SimplePool, 52 | relays: string[] 53 | ): Promise => { 54 | const filters: Filter = { 55 | kinds: [6, 16], 56 | "#e": ids, 57 | } 58 | 59 | try { 60 | const events = await pool.querySync(relays, filters); 61 | return events; 62 | } catch (err) { 63 | console.error("Error fetching reposts", err); 64 | return []; 65 | } 66 | }; 67 | 68 | export const fetchComments = async ( 69 | eventIds: string[], 70 | pool: SimplePool, 71 | relays: string[] = defaultRelays 72 | ) => { 73 | let result = await pool.querySync(relays, { 74 | kinds: [1], 75 | "#e": eventIds, 76 | }); 77 | return result; 78 | }; 79 | 80 | export const fetchLikes = async ( 81 | eventIds: string[], 82 | pool: SimplePool, 83 | relays: string[] = defaultRelays 84 | ) => { 85 | let result = await pool.querySync(relays, { 86 | kinds: [7], 87 | "#e": eventIds, 88 | }); 89 | return result; 90 | }; 91 | 92 | export const fetchZaps = async ( 93 | eventIds: string[], 94 | pool: SimplePool, 95 | relays: string[] = defaultRelays 96 | ) => { 97 | let result = await pool.querySync(relays, { 98 | kinds: [9735], 99 | "#e": eventIds, 100 | }); 101 | return result; 102 | }; 103 | 104 | export function openProfileTab(npub: `npub1${string}`) { 105 | let url = `https://njump.me/${npub}`; 106 | window?.open(url, "_blank")?.focus(); 107 | } 108 | 109 | export const getATagFromEvent = (event: Event) => { 110 | let d_tag = event.tags.find((tag) => tag[0] === "d")?.[1]; 111 | let a_tag = d_tag 112 | ? `${event.kind}:${event.pubkey}:${d_tag}` 113 | : `${event.kind}:${event.pubkey}:`; 114 | return a_tag; 115 | }; 116 | 117 | export const signEvent = async (event: EventTemplate, secret?: string) => { 118 | let signedEvent; 119 | let secretKey; 120 | if (secret) { 121 | secretKey = hexToBytes(secret); 122 | signedEvent = finalizeEvent(event, secretKey); 123 | return signedEvent; 124 | } 125 | const signer = await signerManager.getSigner(); 126 | if (!signer) { 127 | throw Error("Login Method Not Provided"); 128 | } 129 | signedEvent = await signer.signEvent(event); 130 | return signedEvent; 131 | }; 132 | 133 | export class MiningTracker { 134 | public cancelled: boolean; 135 | public maxDifficultySoFar: number; 136 | public hashesComputed: number; 137 | constructor() { 138 | this.cancelled = false; 139 | this.maxDifficultySoFar = 0; 140 | this.hashesComputed = 0; 141 | } 142 | 143 | cancel() { 144 | this.cancelled = true; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/components/Ratings/Rate.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | Box, 4 | Typography, 5 | Button, 6 | TextField, 7 | Rating as MuiRating, 8 | Alert, 9 | } from "@mui/material"; 10 | import { useRating } from "../../hooks/useRating"; 11 | 12 | interface Props { 13 | entityId: string; 14 | entityType?: string; // 'event', 'profile', etc. 15 | } 16 | 17 | const Rate: React.FC = ({ entityId, entityType = "event" }) => { 18 | const ratingKey = `${entityType}:${entityId}`; 19 | const { averageRating, totalRatings, submitRating, getUserRating } = 20 | useRating(ratingKey); 21 | const [ratingValue, setRatingValue] = useState(null); 22 | const [content, setContent] = useState(""); 23 | const [showContentInput, setShowContentInput] = useState(false); 24 | const [error, setError] = useState(""); 25 | const userRating = getUserRating(ratingKey); 26 | 27 | useEffect(() => { 28 | if (userRating) { 29 | setRatingValue(userRating * 5); 30 | } 31 | }, [userRating]); 32 | 33 | const handleSubmit = () => { 34 | if (ratingValue === null) { 35 | setError("Please give a rating before submitting a review."); 36 | return; 37 | } 38 | setError(""); 39 | submitRating(ratingValue, 5, entityType, content); 40 | setShowContentInput(false); 41 | }; 42 | 43 | const handleRatingChange = ( 44 | _: React.SyntheticEvent, 45 | newValue: number | null 46 | ) => { 47 | if (newValue != null) { 48 | setRatingValue(newValue); 49 | setError(""); 50 | //If Review is being added we should not submit rating on rating change 51 | if (!showContentInput) submitRating(newValue, 5, entityType); 52 | } 53 | }; 54 | 55 | const handleAddReviewClick = () => { 56 | setShowContentInput(true); 57 | }; 58 | return ( 59 | { 61 | e.stopPropagation(); 62 | }} 63 | > 64 | 65 | { 73 | e.stopPropagation(); 74 | handleRatingChange(e, newValue); 75 | }} 76 | /> 77 | 78 | {totalRatings ? ( 79 | 80 | Rated: {(averageRating! * 5).toFixed(2)} from {totalRatings} rating 81 | {totalRatings !== 1 ? "s" : ""} 82 | 83 | ) : null} 84 | 85 | 86 | {!showContentInput && ( 87 | 97 | )} 98 | 99 | {showContentInput && ( 100 | <> 101 | { 107 | e.stopPropagation(); 108 | }} 109 | value={content} 110 | onChange={(e) => { 111 | e.stopPropagation(); 112 | setContent(e.target.value); 113 | }} 114 | sx={{ mt: 2 }} 115 | /> 116 | 126 | 127 | )} 128 | {error && ( 129 | 130 | {error} 131 | 132 | )} 133 | 134 | ); 135 | }; 136 | 137 | export default Rate; 138 | -------------------------------------------------------------------------------- /src/components/Ratings/RateEventModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | Box, 4 | Button, 5 | Modal, 6 | TextField, 7 | Typography, 8 | CircularProgress, 9 | } from "@mui/material"; 10 | import { nip19, SimplePool, Event } from "nostr-tools"; 11 | import EventJsonCard from "../Event/EventJSONCard"; 12 | import { useRelays } from "../../hooks/useRelays"; 13 | import { Notes } from "../Notes"; 14 | 15 | interface Props { 16 | open: boolean; 17 | onClose: () => void; 18 | initialEventId?: string | null; 19 | } 20 | 21 | const RateEventModal: React.FC = ({ open, onClose, initialEventId }) => { 22 | const [input, setInput] = useState(""); 23 | const [event, setEvent] = useState(null); 24 | const [loading, setLoading] = useState(false); 25 | const [error, setError] = useState(null); 26 | const { relays } = useRelays(); 27 | 28 | useEffect(() => { 29 | if (open && initialEventId) { 30 | setInput(initialEventId); 31 | fetchEvent(initialEventId); 32 | } 33 | // eslint-disable-next-line react-hooks/exhaustive-deps 34 | }, [open, initialEventId]); 35 | 36 | const handleClose = () => { 37 | setInput(""); 38 | setEvent(null); 39 | setLoading(false); 40 | setError(null); 41 | onClose(); 42 | }; 43 | 44 | const fetchEvent = async (hexIdInput?: string) => { 45 | let eventId = ""; 46 | if (hexIdInput) eventId = hexIdInput; 47 | else { 48 | try { 49 | setError(null); 50 | setLoading(true); 51 | const decoded = nip19.decode(input.trim()); 52 | 53 | if (decoded.type !== "nevent" || !decoded.data?.id) { 54 | setError("Invalid nevent format."); 55 | setLoading(false); 56 | return; 57 | } 58 | eventId = decoded.data.id; 59 | } catch (err: any) { 60 | console.error(err); 61 | setError("Failed to decode or fetch event."); 62 | setLoading(false); 63 | } 64 | } 65 | 66 | const pool = new SimplePool(); 67 | const ev = await pool.get(relays, { 68 | ids: [eventId], 69 | }); 70 | 71 | if (!ev) { 72 | setError("Event not found."); 73 | } else { 74 | setEvent(ev); 75 | } 76 | 77 | setLoading(false); 78 | }; 79 | 80 | return ( 81 | 82 | 93 | 94 | Rate a Nostr Event 95 | 96 | 97 | {!event && ( 98 | <> 99 | setInput(e.target.value)} 105 | placeholder="nevent1..." 106 | sx={{ mb: 2 }} 107 | /> 108 | 116 | {error && ( 117 | 118 | {error} 119 | 120 | )} 121 | 122 | )} 123 | 124 | {event && ( 125 | <> 126 | {event.kind === 1 ? ( 127 | 128 | ) : ( 129 | 130 | )} 131 | 139 | 140 | )} 141 | 142 | 143 | ); 144 | }; 145 | 146 | export default RateEventModal; 147 | -------------------------------------------------------------------------------- /src/components/Header/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Avatar, Badge, Menu, MenuItem, Tooltip } from "@mui/material"; 3 | import { useUserContext } from "../../hooks/useUserContext"; 4 | import { ColorSchemeToggle } from "../ColorScheme"; 5 | import { styled } from "@mui/system"; 6 | import { LoginModal } from "../Login/LoginModal"; 7 | import { SettingsModal } from "./SettingsModal"; 8 | import { signerManager } from "../../singletons/Signer/SignerManager"; 9 | import { WarningAmber } from "@mui/icons-material"; 10 | import { ViewKeysModal } from "../User/ViewKeysModal"; 11 | 12 | const ListItem = styled("li")(() => ({ 13 | padding: "0 16px", 14 | })); 15 | 16 | export const UserMenu: React.FC = () => { 17 | const [anchorEl, setAnchorEl] = React.useState(null); 18 | const [showLoginModal, setShowLoginModal] = React.useState(false); 19 | const [showSettings, setShowSettings] = React.useState(false); 20 | const [showKeysModal, setShowKeysModal] = React.useState(false); 21 | const { user } = useUserContext(); 22 | 23 | const handleLogOut = () => { 24 | signerManager.logout(); 25 | setAnchorEl(null); 26 | }; 27 | 28 | return ( 29 |
30 | 33 | } 40 | > 41 | setAnchorEl(e.currentTarget)} 44 | sx={{ cursor: "pointer" }} 45 | > 46 | {!user?.picture && user?.name?.[0]} 47 | 48 | 49 | 50 | setAnchorEl(null)} 54 | > 55 | {user 56 | ? [ 57 | user?.privateKey && ( 58 | { 61 | setShowKeysModal(true); 62 | setAnchorEl(null); 63 | }} 64 | > 65 | View Keys 66 | 67 | ), 68 | 69 | setShowSettings(true)}> 70 | Settings 71 | , 72 | Log Out, 73 | 74 | 75 | , 76 | user?.privateKey && ( 77 | { 80 | const confirmed = window.confirm( 81 | "Are you sure you want to delete your keys? This action is irreversible." 82 | ); 83 | if (confirmed) { 84 | localStorage.removeItem("pollerama:keys"); 85 | signerManager.logout(); 86 | window.location.reload(); 87 | } 88 | }} 89 | style={{ color: "red" }} 90 | > 91 | Delete Keys 92 | 93 | ), 94 | ] 95 | : [ 96 | setShowLoginModal(true)}> 97 | Log In 98 | , 99 | 100 | 101 | , 102 | ]} 103 | 104 | setShowSettings(false)} 107 | /> 108 | setShowLoginModal(false)} 111 | /> 112 | setShowKeysModal(false)} 115 | pubkey={user?.pubkey || ""} 116 | privkey={user?.privateKey || ""} 117 | /> 118 |
119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /src/components/Feed/TopicsFeed/TopicMetadataModal.tsx: -------------------------------------------------------------------------------- 1 | // components/Topics/TopicMetadataModal.tsx 2 | import React, { useEffect, useState } from "react"; 3 | import { 4 | Box, 5 | Button, 6 | Modal, 7 | TextField, 8 | Typography, 9 | Tabs, 10 | Tab, 11 | Divider, 12 | } from "@mui/material"; 13 | import { signEvent } from "../../../nostr"; 14 | import { useRelays } from "../../../hooks/useRelays"; 15 | import { Event } from "nostr-tools"; 16 | import TopicsCard from "./TopicsCard"; 17 | import { pool } from "../../../singletons"; 18 | 19 | interface Props { 20 | open: boolean; 21 | onClose: () => void; 22 | topic: string; 23 | } 24 | 25 | const TopicMetadataModal: React.FC = ({ open, onClose, topic }) => { 26 | const [thumb, setThumb] = useState(""); 27 | const [description, setDescription] = useState(""); 28 | const [tab, setTab] = useState(0); 29 | const [previewEvent, setPreviewEvent] = useState(); 30 | const { relays } = useRelays(); 31 | 32 | useEffect(() => { 33 | if (open) { 34 | buildPreviewEvent().then(setPreviewEvent); 35 | } 36 | }, [ thumb, description, open]); 37 | 38 | const buildTags = () => [ 39 | ["m", "hashtag"], 40 | ["d", `hashtag:${topic}`], 41 | ...(thumb ? [["image", thumb]] : []), 42 | ...(description ? [["description", description]] : []), 43 | ]; 44 | 45 | const buildPreviewEvent = async (): Promise => ({ 46 | id: "temp", 47 | kind: 34259, 48 | content: topic, 49 | tags: buildTags(), 50 | created_at: Math.floor(Date.now() / 1000), 51 | pubkey: "preview_pubkey", 52 | sig: "preview_sig", 53 | }); 54 | 55 | const handlePublish = async () => { 56 | console.log("Publishing") 57 | const event = { 58 | kind: 30300, 59 | content: topic, 60 | tags: buildTags(), 61 | created_at: Math.floor(Date.now() / 1000), 62 | }; 63 | 64 | const signed = await signEvent(event); 65 | console.log("Signed Event is", signed) 66 | if (!signed) return; 67 | 68 | pool.publish(relays, signed); 69 | onClose(); 70 | }; 71 | 72 | return ( 73 | 74 | e.stopPropagation()} 85 | > 86 | Add Topic Metadata 87 | 88 | Topic: {topic} 89 | 90 | 91 | setTab(val)} sx={{ mb: 2 }}> 92 | 93 | 94 | 95 | 96 | {tab === 0 ? ( 97 | <> 98 | setThumb(e.target.value)} 103 | sx={{ mb: 2 }} 104 | /> 105 | setDescription(e.target.value)} 112 | sx={{ mb: 2 }} 113 | /> 114 | 115 | 118 | 121 | 122 | 123 | ) : ( 124 | <> 125 | 126 | Preview: 127 | 128 | 129 | 130 | 138 | 139 | )} 140 | 141 | 142 | ); 143 | }; 144 | 145 | export default TopicMetadataModal; 146 | -------------------------------------------------------------------------------- /src/components/Header/AISettings.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | TextField, 4 | MenuItem, 5 | CircularProgress, 6 | Typography, 7 | Button, 8 | Link, 9 | } from "@mui/material"; 10 | import { useEffect, useState } from "react"; 11 | import { useAppContext } from "../../hooks/useAppContext"; 12 | 13 | const LOCAL_STORAGE_KEY = "ai-settings"; 14 | const EXTENSION_LINK = "https://github.com/ashu01304/Ollama_Web"; 15 | 16 | export const AISettings: React.FC = () => { 17 | const { aiSettings, setAISettings } = useAppContext(); 18 | 19 | const [localModel, setLocalModel] = useState(aiSettings.model || ""); 20 | const [availableModels, setAvailableModels] = useState([]); 21 | const [loading, setLoading] = useState(false); 22 | const [extensionMissing, setExtensionMissing] = useState(false); 23 | const [error, setError] = useState(null); 24 | const [saved, setSaved] = useState(false); 25 | 26 | useEffect(() => { 27 | const fetchModels = async () => { 28 | if (!window.ollama || typeof window.ollama.getModels !== "function") { 29 | setExtensionMissing(true); 30 | return; 31 | } 32 | 33 | setLoading(true); 34 | setError(null); 35 | 36 | try { 37 | const response = await window.ollama.getModels(); 38 | if (response.success && Array.isArray(response.data.models)) { 39 | const models = response.data.models.map((m: any) => m.name); 40 | setAvailableModels(models); 41 | } else { 42 | setError( 43 | response.error || "⚠️ Unexpected response from Ollama extension." 44 | ); 45 | } 46 | } catch (err) { 47 | setError("⚠️ Failed to communicate with the Ollama extension."); 48 | } finally { 49 | setLoading(false); 50 | } 51 | }; 52 | 53 | fetchModels(); 54 | }, []); 55 | 56 | const handleSave = () => { 57 | const newSettings = { model: localModel }; 58 | setAISettings(newSettings); 59 | localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newSettings)); 60 | setSaved(true); 61 | setTimeout(() => setSaved(false), 2000); 62 | }; 63 | 64 | return ( 65 | 66 | AI Settings 67 | 68 | {extensionMissing ? ( 69 | 70 | 71 | ⚠️ Ollama browser extension not found. 72 | 73 | 74 | To use AI features, please install the Ollama Web Extension: 75 | 76 | 82 | 👉 Install from GitHub 83 | 84 | 85 | ) : loading ? ( 86 | 87 | 88 | 89 | Loading models… 90 | 91 | 92 | ) : availableModels.length > 0 ? ( 93 | { 99 | setLocalModel(e.target.value); 100 | setSaved(false); 101 | }} 102 | margin="normal" 103 | > 104 | {availableModels.map((m) => ( 105 | 106 | {m} 107 | 108 | ))} 109 | 110 | ) : ( 111 | 112 | No models available. 113 | 114 | )} 115 | 116 | {error && ( 117 | 118 | {error} 119 | 120 | )} 121 | 122 | 123 | 130 | {saved && ( 131 | 132 | ✅ Settings saved 133 | 134 | )} 135 | 136 | 137 | ); 138 | }; 139 | -------------------------------------------------------------------------------- /src/contexts/RatingProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useEffect, useRef, useState } from "react"; 2 | import { Event } from "nostr-tools"; 3 | import { useRelays } from "../hooks/useRelays"; 4 | import { useUserContext } from "../hooks/useUserContext"; 5 | import { pool } from "../singletons"; 6 | 7 | type RatingMap = Map>; // entityId -> pubkey -> rating 8 | type UserRatingMap = Map; // entityId -> Event 9 | 10 | interface RatingContextType { 11 | registerEntityId: (id: string) => void; 12 | getAverageRating: (id: string) => { avg: number; count: number } | null; 13 | getUserRating: (id: string) => number | null; 14 | ratings: RatingMap; 15 | userRatingEvents: UserRatingMap; 16 | } 17 | 18 | export const RatingContext = createContext({ 19 | registerEntityId: () => null, 20 | getAverageRating: () => ({ avg: -1, count: -1 }), 21 | getUserRating: () => null, 22 | ratings: new Map(), 23 | userRatingEvents: new Map(), 24 | }); 25 | 26 | export const RatingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 27 | const [ratings, setRatings] = useState(new Map()); 28 | const [userRatingEvents, setUserRatingEvents] = useState(new Map()); 29 | const trackedIdsRef = useRef>(new Set()); 30 | const lastTrackedIds = useRef([]); 31 | const subscriptionRef = useRef | null>(null); 32 | 33 | const { user } = useUserContext(); 34 | const { relays } = useRelays(); 35 | 36 | const registerEntityId = (id: string) => { 37 | trackedIdsRef.current.add(id); 38 | }; 39 | 40 | const getAverageRating = (entityId: string) => { 41 | const entityRatings = ratings.get(entityId); 42 | if (!entityRatings) return null; 43 | 44 | const values = Array.from(entityRatings.values()); 45 | const avg = values.reduce((sum, r) => sum + r, 0) / values.length; 46 | return { avg, count: values.length }; 47 | }; 48 | 49 | const getUserRating = (entityId: string): number | null => { 50 | const event = userRatingEvents.get(entityId); 51 | const ratingTag = event?.tags.find((t) => t[0] === "rating")?.[1]; 52 | const value = ratingTag ? parseFloat(ratingTag) : NaN; 53 | return !isNaN(value) ? value : null; 54 | }; 55 | 56 | const handleEvent = (ev: Event) => { 57 | const dTag = ev.tags.find((t) => t[0] === "d")?.[1]; 58 | const ratingTag = ev.tags.find((t) => t[0] === "rating")?.[1]; 59 | const pubkey = ev.pubkey; 60 | 61 | if (!dTag || !ratingTag || !pubkey) return; 62 | 63 | const value = parseFloat(ratingTag); 64 | if (isNaN(value) || value < 0 || value > 1) return; 65 | 66 | // Update global ratings 67 | setRatings((prev) => { 68 | const next = new Map(prev); 69 | const entityMap = new Map(next.get(dTag) || []); 70 | entityMap.set(pubkey, value); 71 | next.set(dTag, entityMap); 72 | return next; 73 | }); 74 | 75 | // Update user-specific ratings 76 | if (user && user.pubkey === pubkey) { 77 | setUserRatingEvents((prev) => { 78 | const existing = prev.get(dTag); 79 | if (!existing || existing.created_at < ev.created_at) { 80 | const updated = new Map(prev); 81 | updated.set(dTag, ev); 82 | return updated; 83 | } 84 | return prev; 85 | }); 86 | } 87 | }; 88 | 89 | useEffect(() => { 90 | const interval = setInterval(() => { 91 | const ids = Array.from(trackedIdsRef.current); 92 | const hasChanged = 93 | ids.length !== lastTrackedIds.current.length || 94 | ids.some((id, i) => id !== lastTrackedIds.current[i]); 95 | if (!hasChanged) return; 96 | 97 | lastTrackedIds.current = ids; 98 | 99 | if (subscriptionRef.current) { 100 | subscriptionRef.current.close(); 101 | } 102 | 103 | if (ids.length === 0) return; 104 | 105 | const filters = [ 106 | { 107 | kinds: [34259], 108 | "#d": ids, 109 | }, 110 | ]; 111 | 112 | subscriptionRef.current = pool.subscribeMany(relays, filters, { 113 | onevent: handleEvent, 114 | }); 115 | }, 3000); 116 | 117 | return () => { 118 | clearInterval(interval); 119 | if (subscriptionRef.current) subscriptionRef.current.close(); 120 | }; 121 | }, [user]); 122 | 123 | return ( 124 | 133 | {children} 134 | 135 | ); 136 | }; 137 | -------------------------------------------------------------------------------- /src/components/Feed/MoviesFeed.tsx: -------------------------------------------------------------------------------- 1 | // components/Feed/MoviesFeed.tsx 2 | import React, { useEffect, useRef, useState } from "react"; 3 | import { Filter, SimplePool } from "nostr-tools"; 4 | import { useRelays } from "../../hooks/useRelays"; 5 | import MovieCard from "../Movies/MovieCard"; 6 | import RateMovieModal from "../Ratings/RateMovieModal"; 7 | import { Card, CardContent, Typography, CircularProgress, Box, Button } from "@mui/material"; 8 | import { useUserContext } from "../../hooks/useUserContext"; 9 | 10 | const BATCH_SIZE = 10; 11 | 12 | const MoviesFeed: React.FC = () => { 13 | const [movieIds, setMovieIds] = useState>(new Set()); 14 | const [modalOpen, setModalOpen] = useState(false); 15 | const [initialLoadComplete, setInitialLoadComplete] = useState(false); 16 | const [loading, setLoading] = useState(false); 17 | const [cursor, setCursor] = useState(undefined); 18 | const { user } = useUserContext(); 19 | const { relays } = useRelays(); 20 | const seen = useRef>(new Set()); 21 | 22 | const fetchBatch = () => { 23 | if (loading) return; 24 | setLoading(true); 25 | 26 | const pool = new SimplePool(); 27 | const currentCursor = cursor; // Capture cursor at start 28 | const now = Math.floor(Date.now() / 1000); 29 | const newIds: Set = new Set(); 30 | let oldestTimestamp: number | undefined; 31 | 32 | const filter: Filter = { 33 | kinds: [34259], 34 | "#m": ["movie"], 35 | limit: BATCH_SIZE, 36 | until: currentCursor || now, 37 | }; 38 | 39 | if (user?.follows?.length) { 40 | filter.authors = user.follows; 41 | } 42 | 43 | const sub = pool.subscribeMany(relays, [filter], { 44 | onevent: (event) => { 45 | const dTag = event.tags.find((t) => t[0] === "d"); 46 | if (dTag && dTag[1].startsWith("movie:")) { 47 | const imdbId = dTag[1].split(":")[1]; 48 | if (!seen.current.has(imdbId)) { 49 | seen.current.add(imdbId); 50 | newIds.add(imdbId); 51 | } 52 | } 53 | 54 | // Track oldest timestamp for next cursor 55 | if (!oldestTimestamp || event.created_at < oldestTimestamp) { 56 | oldestTimestamp = event.created_at; 57 | } 58 | }, 59 | oneose: () => { 60 | setMovieIds( 61 | (prev) => new Set([...Array.from(prev), ...Array.from(newIds)]) 62 | ); 63 | 64 | // Only update cursor if we got results 65 | if (oldestTimestamp) { 66 | setCursor(oldestTimestamp - 1); 67 | } 68 | 69 | setInitialLoadComplete(true); 70 | setLoading(false); 71 | sub.close(); 72 | }, 73 | }); 74 | 75 | setTimeout(() => { 76 | setMovieIds( 77 | (prev) => new Set([...Array.from(prev), ...Array.from(newIds)]) 78 | ); 79 | 80 | // Only update cursor if we got results 81 | if (oldestTimestamp) { 82 | setCursor(oldestTimestamp - 1); 83 | } 84 | 85 | setInitialLoadComplete(true); 86 | setLoading(false); 87 | sub.close(); 88 | }, 3000); 89 | }; 90 | 91 | useEffect(() => { 92 | fetchBatch(); 93 | }, []); 94 | 95 | return ( 96 | <> 97 | setModalOpen(true)} 101 | > 102 | 103 | Rate Any Movie 104 | 105 | Click to enter an IMDb ID and submit a rating. 106 | 107 | 108 | 109 | 110 | {loading && movieIds.size === 0 ? ( 111 | 112 | 113 | 114 | ) : ( 115 | 116 | Recently Rated 117 | {Array.from(movieIds).map((id) => ( 118 |
121 | 122 |
123 | ))} 124 |
125 | )} 126 | 127 | {initialLoadComplete && ( 128 | 129 | 137 | 138 | )} 139 | 140 | setModalOpen(false)} /> 141 | 142 | ); 143 | }; 144 | 145 | export default MoviesFeed; 146 | -------------------------------------------------------------------------------- /src/components/PollResponse/Filter.tsx: -------------------------------------------------------------------------------- 1 | import {Divider, Icon, Menu, MenuItem, useTheme} from "@mui/material"; 2 | import {FilterIcon} from "../../Images/FilterIcon"; 3 | import React from "react"; 4 | import { Event } from "nostr-tools"; 5 | import { useListContext } from "../../hooks/useListContext"; 6 | import { useUserContext } from "../../hooks/useUserContext"; 7 | 8 | interface FilterProps { 9 | onChange: (pubkeys: string[]) => void; 10 | } 11 | export const Filters: React.FC = ({ onChange }) => { 12 | const [anchorEl, setAnchorEl] = React.useState(null); 13 | const { lists, handleListSelected, selectedList } = useListContext(); 14 | const { user } = useUserContext(); 15 | const theme = useTheme() 16 | 17 | const handleMenuOpen = (event: React.MouseEvent) => { 18 | setAnchorEl(event.currentTarget); 19 | }; 20 | 21 | const handleMenuClose = () => { 22 | setAnchorEl(null); 23 | }; 24 | 25 | const handleAllPosts = () => { 26 | handleListSelected(null); 27 | onChange([]); 28 | handleMenuClose(); 29 | }; 30 | 31 | const handleFilterChange = (value: string) => { 32 | handleListSelected(value); 33 | const selectedList = lists?.get(value); 34 | const pubkeys = 35 | selectedList?.tags.filter((t) => t[0] === "p").map((t) => t[1]) || []; 36 | onChange(pubkeys); 37 | handleMenuClose(); 38 | }; 39 | 40 | return ( 41 |
42 | 52 | 53 | 54 | 59 | { 62 | handleAllPosts(); 63 | }} 64 | key="All Votes" 65 | sx={{ 66 | "&.Mui-selected": { 67 | opacity: 1, 68 | backgroundColor: "#FAD13F", 69 | }, 70 | }} 71 | > 72 | all votes 73 | 74 | {lists ? ( 75 |
76 | 77 | {lists.has(`3:${user?.pubkey}`) ? ( 78 |
79 | { 82 | handleFilterChange(`3:${user?.pubkey}`); 83 | }} 84 | key="Contact List" 85 | sx={{ 86 | "&.Mui-selected": { 87 | opacity: 1, 88 | backgroundColor: "#FAD13F", 89 | }, 90 | }} 91 | > 92 | people you follow 93 | 94 | 95 |
96 | ) : null} 97 | {Array.from(lists?.entries() || []).map( 98 | (value: [string, Event]) => { 99 | if (value[1].kind === 3) return null; 100 | const listName = 101 | value[1].tags 102 | .filter((tag) => tag[0] === "d") 103 | .map((tag) => tag[1])[0] || `kind:${value[1].kind}`; 104 | return ( 105 | { 108 | handleFilterChange(value[0]); 109 | }} 110 | sx={{ 111 | "&.Mui-selected": { 112 | opacity: 1, // Override the default opacity 113 | backgroundColor: "#FAD13F", // Optional: Adjust background color for visibility 114 | }, 115 | }} 116 | key={value[0]} 117 | > 118 | {listName} 119 | 120 | ); 121 | } 122 | )} 123 | 124 |
125 | ) : null} 126 | { 128 | window.open("https://listr.lol", "_blank"); 129 | }} 130 | key="Create List" 131 | sx={{ 132 | "&.Mui-selected": { 133 | opacity: 1, 134 | backgroundColor: "#FAD13F", 135 | }, 136 | }} 137 | > 138 | + create a new list 139 | 140 |
141 |
142 | ); 143 | }; 144 | -------------------------------------------------------------------------------- /src/components/Movies/MovieMetadataModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | Box, 4 | Button, 5 | Modal, 6 | TextField, 7 | Typography, 8 | Tabs, 9 | Tab, 10 | Divider, 11 | } from "@mui/material"; 12 | import { signEvent } from "../../nostr"; 13 | import { useRelays } from "../../hooks/useRelays"; 14 | import { SimplePool, Event } from "nostr-tools"; 15 | import MovieCard from "./MovieCard"; 16 | 17 | interface MovieMetadataModalProps { 18 | open: boolean; 19 | onClose: () => void; 20 | imdbId: string; 21 | } 22 | 23 | const MovieMetadataModal: React.FC = ({ 24 | open, 25 | onClose, 26 | imdbId, 27 | }) => { 28 | const [title, setTitle] = useState(""); 29 | const [poster, setPoster] = useState(""); 30 | const [year, setYear] = useState(""); 31 | const [summary, setSummary] = useState(""); 32 | const [tab, setTab] = useState(0); 33 | const [previewEvent, setPreviewEvent] = useState(); 34 | const { relays } = useRelays(); 35 | 36 | useEffect(() => { 37 | const initialize = async () => { 38 | if (!open) return; // Only initialize when modal is actually open 39 | else { 40 | setPreviewEvent(await buildPreviewEvent()); 41 | } 42 | }; 43 | initialize(); 44 | }, [title, poster, year, summary, open]); 45 | 46 | const buildTags = () => [ 47 | ["d", `movie:${imdbId}`], 48 | ...(poster ? [["poster", poster]] : []), 49 | ...(year ? [["year", year]] : []), 50 | ...(summary ? [["summary", summary]] : []), 51 | ]; 52 | 53 | const buildPreviewEvent = async (): Promise => { 54 | return { 55 | id: "Random", 56 | kind: 30300, 57 | content: title || "Untitled", 58 | tags: buildTags(), 59 | created_at: Math.floor(Date.now() / 1000), 60 | pubkey: "placeholder_pubkey", 61 | sig: "placeholder_signature", 62 | }; 63 | }; 64 | 65 | const handlePublish = async () => { 66 | const event = { 67 | kind: 30300, 68 | content: title || "Untitled", 69 | tags: buildTags(), 70 | created_at: Math.floor(Date.now() / 1000), 71 | }; 72 | 73 | const signed = await signEvent(event); 74 | if (!signed) throw new Error("Signing failed"); 75 | 76 | const pool = new SimplePool(); 77 | pool.publish(relays, signed); 78 | onClose(); 79 | }; 80 | 81 | const renderEditTab = () => ( 82 | <> 83 | setTitle(e.target.value)} 88 | sx={{ mb: 2 }} 89 | /> 90 | setPoster(e.target.value)} 95 | sx={{ mb: 2 }} 96 | /> 97 | setYear(e.target.value)} 102 | sx={{ mb: 2 }} 103 | /> 104 | setSummary(e.target.value)} 111 | sx={{ mb: 2 }} 112 | /> 113 | 114 | 117 | 120 | 121 | 122 | ); 123 | 124 | const renderPreviewTab = () => ( 125 | <> 126 | 127 | Preview: 128 | 129 | 130 | 131 | 139 | 140 | ); 141 | 142 | return ( 143 | 144 | e.stopPropagation()} 155 | > 156 | Add Movie Metadata 157 | 158 | IMDb ID: {imdbId} 159 | 160 | 161 | setTab(val)} sx={{ mb: 2 }}> 162 | 163 | 164 | 165 | 166 | {tab === 0 ? renderEditTab() : renderPreviewTab()} 167 | 168 | 169 | ); 170 | }; 171 | 172 | export default MovieMetadataModal; 173 | -------------------------------------------------------------------------------- /src/components/Common/Likes/likes.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Tooltip, Box, IconButton, useTheme, Modal } from "@mui/material"; 3 | import FavoriteBorder from "@mui/icons-material/FavoriteBorder"; 4 | import EmojiPicker, { Theme } from "emoji-picker-react"; 5 | import { useAppContext } from "../../../hooks/useAppContext"; 6 | import { Event, EventTemplate } from "nostr-tools/lib/types/core"; 7 | import { signEvent } from "../../../nostr"; 8 | import { useRelays } from "../../../hooks/useRelays"; 9 | import { useUserContext } from "../../../hooks/useUserContext"; 10 | import { useNotification } from "../../../contexts/notification-context"; 11 | import { NOTIFICATION_MESSAGES } from "../../../constants/notifications"; 12 | import { pool } from "../../../singletons"; 13 | 14 | interface LikesProps { 15 | pollEvent: Event; 16 | } 17 | 18 | const Likes: React.FC = ({ pollEvent }) => { 19 | const { likesMap, fetchLikesThrottled, addEventToMap } = useAppContext(); 20 | const { showNotification } = useNotification(); 21 | const { user } = useUserContext(); 22 | const { relays } = useRelays(); 23 | const [showPicker, setShowPicker] = useState(false); 24 | const theme = useTheme(); 25 | 26 | const userReaction = () => { 27 | if (!user) return null; 28 | return likesMap?.get(pollEvent.id)?.find((r) => r.pubkey === user.pubkey) 29 | ?.content; 30 | }; 31 | 32 | const addReaction = async (emoji: string) => { 33 | if (!user) { 34 | showNotification(NOTIFICATION_MESSAGES.LOGIN_TO_LIKE, "warning"); 35 | return; 36 | } 37 | 38 | const event: EventTemplate = { 39 | content: emoji, 40 | kind: 7, 41 | tags: [["e", pollEvent.id, relays[0]]], 42 | created_at: Math.floor(Date.now() / 1000), 43 | }; 44 | 45 | const finalEvent = await signEvent(event, user.privateKey); 46 | pool.publish(relays, finalEvent!); 47 | addEventToMap(finalEvent!); 48 | setShowPicker(false); 49 | }; 50 | 51 | useEffect(() => { 52 | if (!likesMap?.get(pollEvent.id)) { 53 | fetchLikesThrottled(pollEvent.id); 54 | } 55 | }, [pollEvent.id, likesMap, fetchLikesThrottled, user]); 56 | 57 | // Compute top 2 emojis + count 58 | const getTopEmojis = () => { 59 | const reactions = likesMap?.get(pollEvent.id) || []; 60 | const counts: Record = {}; 61 | reactions.forEach((r) => { 62 | counts[r.content] = (counts[r.content] || 0) + 1; 63 | }); 64 | const sorted = Object.entries(counts) 65 | .sort((a, b) => b[1] - a[1]) 66 | .map(([emoji, count]) => ({ emoji, count })); 67 | return sorted; 68 | }; 69 | 70 | const topEmojis = getTopEmojis(); 71 | const remainingCount = Math.max(0, topEmojis.length - 2); 72 | 73 | return ( 74 | 81 | {/* Heart / User emoji */} 82 | setShowPicker(true)} 85 | > 86 | 87 | {userReaction() || } 88 | 89 | 90 | 91 | {/* Top 2 emojis next to button */} 92 | 93 | {topEmojis.slice(0, 2).map((r) => ( 94 | 95 | {r.emoji} 96 | 97 | ))} 98 | {topEmojis.length > 2 && ( 99 | 112 | +{topEmojis.length - 2} 113 | 114 | )} 115 | 116 | 117 | {/* Emoji picker modal */} 118 | setShowPicker(false)} 121 | disableScrollLock 122 | > 123 | 135 | addReaction(emojiData.emoji)} 142 | /> 143 | 144 | 145 | 146 | ); 147 | }; 148 | 149 | export default Likes; 150 | --------------------------------------------------------------------------------