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 (
+
+ );
+ }
+ 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 */}
+
+
+