Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,557 changes: 1,056 additions & 3,501 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"nostr-tools": "^2.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.6.0",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
Expand All @@ -36,4 +37,4 @@
"typescript": "^5.5.3",
"vite": "^6.3.5"
}
}
}
9 changes: 8 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AutoNostrProvider } from '@/components/AutoNostrProvider';
import { ViewModeProvider } from '@/contexts/ViewModeContext';
import { HomePage } from '@/pages/HomePage';
import { RoomPage } from '@/pages/RoomPage';

const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -18,7 +20,12 @@ function App() {
<QueryClientProvider client={queryClient}>
<AutoNostrProvider>
<ViewModeProvider>
<HomePage />
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/:room" element={<RoomPage />} />
</Routes>
</BrowserRouter>
</ViewModeProvider>
</AutoNostrProvider>
</QueryClientProvider>
Expand Down
24 changes: 18 additions & 6 deletions src/components/RecordButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ if (!window.MediaRecorder) {
console.log('MediaRecorder polyfill applied');
}

export function RecordButton() {
interface RecordButtonProps {
room?: string;
}

export function RecordButton({ room }: RecordButtonProps) {
const [isRecording, setIsRecording] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
Expand Down Expand Up @@ -111,20 +115,28 @@ export function RecordButton() {
const tags = await uploadFile(audioFile);
const audioUrl = tags[0][1]; // First tag contains the URL

// Build tags for the event
const eventTags = [
['url', audioUrl],
...tags // Include all file metadata tags
];

// Add room hashtag if in a room
if (room) {
eventTags.push(['t', room]);
}

// Publish voice note event
await publishEvent({
kind: 1069,
content: '',
tags: [
['url', audioUrl],
...tags // Include all file metadata tags
]
tags: eventTags
});

// Small delay to allow relay propagation
setTimeout(async () => {
// Immediately refresh voice notes
await queryClient.invalidateQueries({ queryKey: ['voice-notes'] });
await queryClient.invalidateQueries({ queryKey: ['voice-notes', room] });
}, 500);

setIsUploading(false);
Expand Down
2 changes: 1 addition & 1 deletion src/components/VoiceNote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const VoiceNote = memo(function VoiceNote({ voiceNote }: VoiceNoteProps)
const [position, setPosition] = useState({ x: voiceNote.x || 50, y: voiceNote.y || 50 });
const audioRef = useRef<HTMLAudioElement>(null);
const orbRef = useRef<HTMLDivElement>(null);
const tooltipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationRef = useRef<number | null>(null);
const author = useAuthor(voiceNote.author);
const { user } = useAutoAccount();
Expand Down
12 changes: 9 additions & 3 deletions src/hooks/useVoiceNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ export interface VoiceNote {
// Store positions in memory to prevent jumping
const positionCache = new Map<string, { x: number; y: number }>();

export function useVoiceNotes() {
export function useVoiceNotes(room?: string) {
const { nostr } = useNostr();

return useQuery({
queryKey: ['voice-notes'],
queryKey: ['voice-notes', room],
queryFn: async (c) => {
try {
// Create abort signal - use fallback for Safari compatibility
Expand All @@ -40,7 +40,13 @@ export function useVoiceNotes() {
}
}

const events = await nostr.query([{ kinds: [1069], limit: 100 }], { signal });
// Build filter with optional room hashtag
const filter: any = { kinds: [1069], limit: 100 };
if (room) {
filter['#t'] = [room];
}

const events = await nostr.query([filter], { signal });

// Get current timestamp and 10 minutes ago
const now = Math.floor(Date.now() / 1000);
Expand Down
3 changes: 2 additions & 1 deletion src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ export function HomePage() {

{/* Instructions */}
<div className={`absolute bottom-8 left-8 text-xs ${viewMode === 'matrix' ? 'text-green-700 font-mono' : 'text-gray-700'}`}>
{viewMode === 'matrix' ? 'hover bars to decode • click + to transmit' : 'click to listen • click + to speak'}
<div>{viewMode === 'matrix' ? 'hover bars to decode • click + to transmit' : 'click to listen • click + to speak'}</div>
<div className="mt-1 opacity-60">visit /roomname to join a room</div>
</div>
</div>
);
Expand Down
203 changes: 203 additions & 0 deletions src/pages/RoomPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { useParams } from 'react-router-dom';
import { useEffect, useMemo } from 'react';
import { useVoiceNotes } from '@/hooks/useVoiceNotes';
import { useAutoAccount } from '@/hooks/useAutoAccount';
import { usePublishProfile } from '@/hooks/usePublishProfile';
import { VoiceNote } from '@/components/VoiceNote';
import { MatrixVoiceBar } from '@/components/MatrixVoiceBar';
import { RecordButton } from '@/components/RecordButton';
import { useViewMode } from '@/contexts/ViewModeContext';

export function RoomPage() {
const { room } = useParams<{ room: string }>();
const { user, isReady } = useAutoAccount();
const { data: voiceNotes, isLoading } = useVoiceNotes(room);
const { viewMode, setViewMode } = useViewMode();

// Publish profile metadata
usePublishProfile();

// Memoize voice notes to prevent unnecessary re-renders
const memoizedVoiceNotes = useMemo(() => voiceNotes || [], [voiceNotes]);

useEffect(() => {
// Add animation styles once
const style = document.createElement('style');
style.textContent = `
@keyframes fadeIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

@keyframes toastFadeIn {
0% {
opacity: 0;
transform: translate(-50%, 20px) scale(0.9);
filter: blur(4px);
}
100% {
opacity: 1;
transform: translate(-50%, 0) scale(1);
filter: blur(0);
}
}

@keyframes breathe {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
opacity: inherit;
}
50% {
transform: translate(-50%, -50%) scale(1.15);
opacity: calc(inherit * 1.3);
}
}

@keyframes slowPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

@keyframes matrixFall {
0% {
transform: translateX(-50%) translateY(-100%);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateX(-50%) translateY(100vh);
opacity: 0;
}
}
`;
document.head.appendChild(style);

return () => {
document.head.removeChild(style);
};
}, []);

if (!isReady) {
return (
<div className="fixed inset-0 bg-black flex items-center justify-center">
<div className="text-gray-600 text-sm animate-pulse">
initializing cosmic connection...
</div>
</div>
);
}

return (
<div className={`fixed inset-0 overflow-hidden ${viewMode === 'matrix' ? 'bg-black' : 'bg-black'}`}>
{/* Mode selector */}
<div className="absolute top-8 left-8 z-50">
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('cosmos')}
className={`px-3 py-1 text-xs rounded transition-all ${
viewMode === 'cosmos'
? 'bg-white/20 text-white'
: 'bg-white/5 text-gray-600 hover:bg-white/10 hover:text-gray-400'
}`}
>
cosmos
</button>
<button
onClick={() => setViewMode('matrix')}
className={`px-3 py-1 text-xs rounded transition-all ${
viewMode === 'matrix'
? 'bg-green-900/50 text-green-400'
: 'bg-white/5 text-gray-600 hover:bg-green-900/30 hover:text-green-600'
}`}
>
matrix
</button>
</div>
</div>

{/* Room indicator */}
<div className="absolute top-8 left-1/2 transform -translate-x-1/2 z-50">
<div className={`text-xs ${viewMode === 'matrix' ? 'text-green-600' : 'text-gray-600'}`}>room</div>
<div className={`text-sm ${viewMode === 'matrix' ? 'text-green-400 font-mono' : 'text-gray-400'}`}>#{room}</div>
</div>

{/* User info */}
<div className="absolute top-8 right-8 text-right z-50">
<div className={`text-xs ${viewMode === 'matrix' ? 'text-green-600' : 'text-gray-600'}`}>you are</div>
<div className={`text-sm ${viewMode === 'matrix' ? 'text-green-400 font-mono' : 'text-gray-400'}`}>{user?.name}</div>
</div>

{/* Voice notes canvas */}
<div className="relative w-full h-full">
{viewMode === 'cosmos' ? (
// Cosmos mode - floating orbs
<>
{memoizedVoiceNotes.map((voiceNote) => (
<VoiceNote
key={voiceNote.id}
voiceNote={voiceNote}
/>
))}
</>
) : (
// Matrix mode - vertical bars
<div className="relative w-full h-full">
{/* Matrix background effect */}
<div className="absolute inset-0 opacity-10">
<div className="h-full w-full bg-gradient-to-b from-green-400/20 to-transparent" />
</div>

{memoizedVoiceNotes.map((voiceNote, index) => (
<MatrixVoiceBar
key={voiceNote.id}
voiceNote={voiceNote}
index={index}
totalBars={memoizedVoiceNotes.length || 1}
/>
))}
</div>
)}

{isLoading && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className={`text-xs ${viewMode === 'matrix' ? 'text-green-700 font-mono' : 'text-gray-700'}`}>
{viewMode === 'matrix' ? 'connecting to matrix...' : 'listening to the void...'}
</div>
</div>
)}

{!isLoading && memoizedVoiceNotes.length === 0 && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className={`text-xs text-center ${viewMode === 'matrix' ? 'text-green-700 font-mono' : 'text-gray-700'}`}>
<div>{viewMode === 'matrix' ? 'no signals detected' : 'the cosmos is silent'}</div>
<div className="mt-2">{viewMode === 'matrix' ? 'upload your voice' : 'be the first voice'}</div>
</div>
</div>
)}
</div>

{/* Record button */}
<RecordButton room={room} />

{/* Instructions */}
<div className={`absolute bottom-8 left-8 text-xs ${viewMode === 'matrix' ? 'text-green-700 font-mono' : 'text-gray-700'}`}>
{viewMode === 'matrix' ? 'hover bars to decode • click + to transmit' : 'click to listen • click + to speak'}
</div>
</div>
);
}