diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index e1f2e37..727d552 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -91,6 +91,8 @@ type Stream struct { Members []string `json:"members,omitempty"` Particles []*StreamParticle `json:"particles"` UnseenCount int `json:"unseen_count"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` } type StreamStatus string @@ -381,6 +383,8 @@ func (h *Handler) StartupData(w http.ResponseWriter, r *http.Request) { Status: status, Members: h.getStreamMembers(r.Context(), sp, streamMembersMap), UnseenCount: unseenCounts[sp.ID], + UpdatedAt: sp.UpdatedAt, + CreatedAt: sp.CreatedAt, } // Fetch child particles for this stream @@ -534,6 +538,8 @@ func (h *Handler) CreateStream(w http.ResponseWriter, r *http.Request) { Members: h.getStreamMembers(r.Context(), created, membersMap), Particles: []*StreamParticle{}, UnseenCount: 0, + UpdatedAt: created.UpdatedAt, + CreatedAt: created.CreatedAt, } w.Header().Set("Content-Type", "application/json") @@ -603,6 +609,8 @@ func (h *Handler) GetStream(w http.ResponseWriter, r *http.Request) { Status: parseStreamStatus(streamData.Status), Members: h.getStreamMembers(r.Context(), sp, membersMap), UnseenCount: unseenCounts[sp.ID], + UpdatedAt: sp.UpdatedAt, + CreatedAt: sp.CreatedAt, } // Fetch child particles @@ -760,6 +768,8 @@ func (h *Handler) UpdateStream(w http.ResponseWriter, r *http.Request) { Members: h.getStreamMembers(r.Context(), updated, membersMap), Particles: []*StreamParticle{}, UnseenCount: unseenCounts[updated.ID], + UpdatedAt: updated.UpdatedAt, + CreatedAt: updated.CreatedAt, } w.Header().Set("Content-Type", "application/json") diff --git a/js/assets/flowy.icns b/js/assets/flowy.icns new file mode 100644 index 0000000..310b5ef Binary files /dev/null and b/js/assets/flowy.icns differ diff --git a/js/assets/flowy.iconset/icon_128x128.png b/js/assets/flowy.iconset/icon_128x128.png new file mode 100644 index 0000000..48bed99 Binary files /dev/null and b/js/assets/flowy.iconset/icon_128x128.png differ diff --git a/js/assets/flowy.iconset/icon_128x128@2x.png b/js/assets/flowy.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..4af3689 Binary files /dev/null and b/js/assets/flowy.iconset/icon_128x128@2x.png differ diff --git a/js/assets/flowy.iconset/icon_16x16.png b/js/assets/flowy.iconset/icon_16x16.png new file mode 100644 index 0000000..a084a02 Binary files /dev/null and b/js/assets/flowy.iconset/icon_16x16.png differ diff --git a/js/assets/flowy.iconset/icon_16x16@2x.png b/js/assets/flowy.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..67ed0d3 Binary files /dev/null and b/js/assets/flowy.iconset/icon_16x16@2x.png differ diff --git a/js/assets/flowy.iconset/icon_256x256.png b/js/assets/flowy.iconset/icon_256x256.png new file mode 100644 index 0000000..4af3689 Binary files /dev/null and b/js/assets/flowy.iconset/icon_256x256.png differ diff --git a/js/assets/flowy.iconset/icon_256x256@2x.png b/js/assets/flowy.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..6b338f5 Binary files /dev/null and b/js/assets/flowy.iconset/icon_256x256@2x.png differ diff --git a/js/assets/flowy.iconset/icon_32x32.png b/js/assets/flowy.iconset/icon_32x32.png new file mode 100644 index 0000000..67ed0d3 Binary files /dev/null and b/js/assets/flowy.iconset/icon_32x32.png differ diff --git a/js/assets/flowy.iconset/icon_32x32@2x.png b/js/assets/flowy.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..d05c053 Binary files /dev/null and b/js/assets/flowy.iconset/icon_32x32@2x.png differ diff --git a/js/assets/flowy.iconset/icon_512x512.png b/js/assets/flowy.iconset/icon_512x512.png new file mode 100644 index 0000000..6b338f5 Binary files /dev/null and b/js/assets/flowy.iconset/icon_512x512.png differ diff --git a/js/assets/icon.png b/js/assets/icon.png new file mode 100644 index 0000000..a4e96a5 Binary files /dev/null and b/js/assets/icon.png differ diff --git a/js/assets/icon_green.png b/js/assets/icon_green.png new file mode 100644 index 0000000..009ff0c Binary files /dev/null and b/js/assets/icon_green.png differ diff --git a/js/assets/icon_red.png b/js/assets/icon_red.png new file mode 100644 index 0000000..da43505 Binary files /dev/null and b/js/assets/icon_red.png differ diff --git a/js/assets/images/FLOWY-4.svg b/js/assets/images/FLOWY-4.svg new file mode 100644 index 0000000..044b26e --- /dev/null +++ b/js/assets/images/FLOWY-4.svg @@ -0,0 +1,3 @@ + + + diff --git a/js/src/api/types.ts b/js/src/api/types.ts index e3e9a93..96a2ad5 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -94,6 +94,8 @@ export interface Stream { members?: string[]; particles: StreamParticle[]; unseen_count: number; + updated_at: string; + created_at: string; } export interface Network { diff --git a/js/src/components/window-controls.tsx b/js/src/components/window-controls.tsx new file mode 100644 index 0000000..f5b6f83 --- /dev/null +++ b/js/src/components/window-controls.tsx @@ -0,0 +1,33 @@ +export function WindowControls() { + return ( +
+ + + +
+ ); +} diff --git a/js/src/electron.d.ts b/js/src/electron.d.ts new file mode 100644 index 0000000..7511eef --- /dev/null +++ b/js/src/electron.d.ts @@ -0,0 +1,7 @@ +interface Window { + electronWindow: { + minimize: () => void; + maximize: () => void; + close: () => void; + }; +} diff --git a/js/src/features/recording/recording-overlay.tsx b/js/src/features/recording/recording-overlay.tsx index 79829d8..2a8d954 100644 --- a/js/src/features/recording/recording-overlay.tsx +++ b/js/src/features/recording/recording-overlay.tsx @@ -14,13 +14,14 @@ function AudioLevelBars({ mediaStream }: { mediaStream: MediaStream }) { useEffect(() => { const ctx = new AudioContext(); + ctx.resume(); const source = ctx.createMediaStreamSource(mediaStream); const analyser = ctx.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); audioRef.current = { analyser, ctx }; - const dataArray = new Uint8Array(analyser.fftSize); + const dataArray = new Uint8Array(analyser.frequencyBinCount); function tick() { analyser.getByteTimeDomainData(dataArray); diff --git a/js/src/features/streams/particle-preview.tsx b/js/src/features/streams/particle-preview.tsx new file mode 100644 index 0000000..46f77ab --- /dev/null +++ b/js/src/features/streams/particle-preview.tsx @@ -0,0 +1,93 @@ +import type { StreamParticle } from "@/api/types"; +import { getParticleData } from "@/api/types"; +import { + MessageSquare, + Video, + Mic, + ScrollText, + BookOpen, + FileIcon, + FolderIcon, +} from "lucide-react"; + +interface ParticlePreviewProps { + particle: StreamParticle; +} + +export function ParticlePreview({ particle }: ParticlePreviewProps) { + switch (particle.type) { + case "text": { + const data = getParticleData(particle, "text"); + return ( +
+ +

+ {data.content} +

+
+ ); + } + case "media": { + const data = getParticleData(particle, "media"); + const isVideo = data.mime_type.startsWith("video"); + const Icon = isVideo ? Video : Mic; + const label = isVideo ? "Video" : "Audio"; + const seconds = Math.round(data.duration_ms / 1000); + return ( +
+ + + {label} · {seconds}s + +
+ ); + } + case "quest": { + const data = getParticleData(particle, "quest"); + return ( +
+ + + {data.title} + {data.status && ` · ${data.status}`} + +
+ ); + } + case "paper": { + const data = getParticleData(particle, "paper"); + return ( +
+ + + {data.title} + +
+ ); + } + case "file": { + const data = getParticleData(particle, "file"); + return ( +
+ + + {data.filename} + +
+ ); + } + case "folder": { + const data = getParticleData(particle, "folder"); + return ( +
+ + + {data.name} + +
+ ); + } + default: + return null; + } +} diff --git a/js/src/features/streams/stream-list.tsx b/js/src/features/streams/stream-list.tsx index a14300d..a79fb0c 100644 --- a/js/src/features/streams/stream-list.tsx +++ b/js/src/features/streams/stream-list.tsx @@ -1,8 +1,10 @@ import { useNavigate } from "react-router-dom"; import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; import { useAppStore } from "@/stores/app-store"; import { flattenStreams } from "@/lib/stream-utils"; import { formatDistanceToNow } from "@/lib/time-utils"; +import { ParticlePreview } from "./particle-preview"; export function StreamList() { const networks = useAppStore((s) => s.networks); @@ -20,22 +22,35 @@ export function StreamList() { } return ( -
+
{streams.map((stream) => { const lastParticle = stream.particles.length > 0 ? stream.particles[stream.particles.length - 1] : null; + const timeSource = lastParticle?.created_at ?? stream.updated_at; + return ( - + + + +

+ {lastParticle && ( + <> + {lastParticle.created_by_email.split("@")[0]} + {" · "} + + )} + {timeSource && formatDistanceToNow(timeSource)} +

+
+ ); })}
diff --git a/js/src/lib/stream-utils.ts b/js/src/lib/stream-utils.ts index a8b63f6..fb95abf 100644 --- a/js/src/lib/stream-utils.ts +++ b/js/src/lib/stream-utils.ts @@ -29,7 +29,9 @@ export function flattenStreams( } function getLatestParticleTime(stream: Stream): number { - if (stream.particles.length === 0) return 0; + if (stream.particles.length === 0) { + return new Date(stream.updated_at).getTime() || 0; + } const last = stream.particles[stream.particles.length - 1]; return new Date(last.created_at).getTime(); } diff --git a/js/src/main.ts b/js/src/main.ts index fa7855e..2b81809 100644 --- a/js/src/main.ts +++ b/js/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, session } from 'electron'; +import { app, BrowserWindow, ipcMain, session } from 'electron'; import path from 'node:path'; import started from 'electron-squirrel-startup'; @@ -12,6 +12,8 @@ const createWindow = () => { const mainWindow = new BrowserWindow({ width: 800, height: 600, + resizable: false, + frame: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), }, @@ -30,6 +32,22 @@ const createWindow = () => { mainWindow.webContents.openDevTools(); }; +// Window control IPC handlers +ipcMain.on('window:minimize', (event) => { + BrowserWindow.fromWebContents(event.sender)?.minimize(); +}); +ipcMain.on('window:maximize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (win?.isMaximized()) { + win.unmaximize(); + } else { + win?.maximize(); + } +}); +ipcMain.on('window:close', (event) => { + BrowserWindow.fromWebContents(event.sender)?.close(); +}); + // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. diff --git a/js/src/pages/streams-page.tsx b/js/src/pages/streams-page.tsx index 0f34940..ee2493a 100644 --- a/js/src/pages/streams-page.tsx +++ b/js/src/pages/streams-page.tsx @@ -14,6 +14,7 @@ import { useAppStore } from "@/stores/app-store"; import { useAuthStore } from "@/stores/auth-store"; import { StreamList } from "@/features/streams/stream-list"; import { CreateStreamDialog } from "@/features/streams/create-stream-dialog"; +import { WindowControls } from "@/components/window-controls"; export function StreamsPage() { const networks = useAppStore((s) => s.networks); @@ -28,13 +29,15 @@ export function StreamsPage() { return (
- {/* Top bar */} -
+ {/* Top bar — draggable for frameless window */} +
+ +