Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92baa626d6 | |||
| cd7ef8dccc | |||
| 58efc66ca7 | |||
| 1e139fb86e | |||
| eeb713f03d | |||
| 837b5a326e |
@@ -147,6 +147,17 @@ async function createWindow(): Promise<BrowserWindow> {
|
||||
attachWindowState(win, WINDOW_STATE_FILE);
|
||||
|
||||
if (!app.isPackaged) {
|
||||
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
|
||||
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
|
||||
// separate inspector window for easy debugging.
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
// Forward renderer console messages to the main-process stdout so
|
||||
// errors during local dev are visible in the terminal too (helps when
|
||||
// the inspector isn't focused).
|
||||
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
|
||||
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
|
||||
});
|
||||
await win.loadURL(DEV_URL);
|
||||
} else {
|
||||
await win.loadFile(resolveRendererIndex());
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
// (<1ms per op for the current workload).
|
||||
//
|
||||
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
||||
// bindings as an array. SQLite natively accepts $N so existing queries
|
||||
// keep working unmodified.
|
||||
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
|
||||
// (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
|
||||
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
|
||||
// array-shape from callers and convert to the named-object on the way in.
|
||||
|
||||
import { app, ipcMain } from 'electron';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Convert a positional bindings array `[v1, v2]` to the named-params object
|
||||
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
|
||||
// `$1`/`$2` named placeholders. Returns the original array (spread later)
|
||||
// when it's empty.
|
||||
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
|
||||
const arr = bindings ?? [];
|
||||
if (arr.length === 0) return [];
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
obj[String(i + 1)] = arr[i];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function register(): void {
|
||||
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
||||
const rawName = stripPrefix(args.name);
|
||||
@@ -59,7 +75,8 @@ export function register(): void {
|
||||
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
||||
const entry = requireHandle(args.handle);
|
||||
const stmt = entry.db.prepare(args.query);
|
||||
const info = stmt.run(...((args.bindings ?? []) as unknown[]));
|
||||
const params = bindParams(args.bindings);
|
||||
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
|
||||
return {
|
||||
rowsAffected: info.changes,
|
||||
lastInsertId:
|
||||
@@ -75,7 +92,11 @@ export function register(): void {
|
||||
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
||||
const entry = requireHandle(args.handle);
|
||||
const stmt = entry.db.prepare(args.query);
|
||||
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[];
|
||||
const params = bindParams(args.bindings);
|
||||
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
return rows;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
downloadAndDecryptAttachment,
|
||||
downloadAndDecryptAttachmentThumb,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
@@ -166,11 +166,21 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
// skipped the eager full-blob download above). Resolves into the same
|
||||
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
|
||||
// backing the bubble until the lightbox actually mounts.
|
||||
//
|
||||
// CRITICAL: do NOT revoke the just-created blob URL in this effect's
|
||||
// cleanup. Setting `fullUrl` re-triggers the effect (state change → re-
|
||||
// run → previous cleanup fires → URL revoked → Lightbox renders
|
||||
// referenced-but-revoked URL → "ERR_FILE_NOT_FOUND"). The dedicated
|
||||
// unmount-only effect below tracks the current URL via ref and revokes
|
||||
// it once when the component truly leaves the tree.
|
||||
//
|
||||
// Deps locked to `handle.id` (not `handle`) — handles are immutable per
|
||||
// attachment id, so object-identity churn from parent re-renders must
|
||||
// not re-trigger the fetch.
|
||||
useEffect(() => {
|
||||
if (!lightboxOpen) return;
|
||||
if (fullUrl) return;
|
||||
let cancelled = false;
|
||||
const created: string[] = [];
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
@@ -187,14 +197,27 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
}
|
||||
if (cancelled) return;
|
||||
const u = URL.createObjectURL(blob);
|
||||
created.push(u);
|
||||
setFullUrl(u);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const u of created) URL.revokeObjectURL(u);
|
||||
};
|
||||
}, [lightboxOpen, fullUrl, handle]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lightboxOpen, handle.id]);
|
||||
|
||||
// Track the currently-published fullUrl in a ref so the unmount-only
|
||||
// cleanup below can revoke whatever URL is live at teardown time
|
||||
// without subscribing to fullUrl changes (which would re-trigger and
|
||||
// revoke prematurely — see the comment above the fetch effect).
|
||||
const fullUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
fullUrlRef.current = fullUrl;
|
||||
}, [fullUrl]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fullUrlRef.current) URL.revokeObjectURL(fullUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const blobUrl = thumbUrl ?? fullUrl;
|
||||
|
||||
|
||||
@@ -41,20 +41,42 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||
const [draftTick, setDraftTick] = useState(0);
|
||||
|
||||
// Hold a stable ref to onCancel so the image-load effect doesn't depend
|
||||
// on its identity. Without this, parents that pass an inline `() => …`
|
||||
// re-render the modal on every keystroke / state change, the effect re-
|
||||
// runs, the previous URL.createObjectURL gets revoked WHILE the new img
|
||||
// is still decoding → img.onerror fires ("file not found") → onCancel →
|
||||
// modal flashes open + closes instantly.
|
||||
const onCancelRef = useRef(onCancel);
|
||||
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
// React 18 strict mode in dev double-mounts effects to test idempotency.
|
||||
// The first run creates a blob URL, sets img.src, returns a cleanup
|
||||
// that revokes — and the cleanup fires BEFORE the (still-in-flight)
|
||||
// image fetch completes. The browser then emits ERR_FILE_NOT_FOUND for
|
||||
// the revoked URL → img.onerror → modal closes instantly. The
|
||||
// `cancelled` flag guards every callback so a torn-down run can't
|
||||
// close the modal that the second mount just opened.
|
||||
let cancelled = false;
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
console.error('ImageAnnotator: failed to decode source image');
|
||||
onCancel();
|
||||
onCancelRef.current();
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, onCancel]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
|
||||
@@ -434,6 +434,22 @@ export function ConversationPage() {
|
||||
return out;
|
||||
}, [messages, pending, displayCount]);
|
||||
|
||||
// Snapshot of the saved position for this conversation, captured once on
|
||||
// mount. Used to derive the `initialTopMostItemIndex` we hand to the
|
||||
// Virtuoso instance below — Virtuoso applies that index synchronously
|
||||
// before its first paint, so re-entering a chat shows the saved row in
|
||||
// one frame rather than a "starts at top, jumps" flicker.
|
||||
//
|
||||
// Declared HERE (above `initialTopMostIndex`) rather than further down
|
||||
// because the useMemo that consumes it would otherwise hit a TDZ on
|
||||
// first render — `const` refs aren't hoisted.
|
||||
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
|
||||
null,
|
||||
);
|
||||
if (savedPositionRef.current === null && id) {
|
||||
savedPositionRef.current = scrollPositions.get(id) ?? null;
|
||||
}
|
||||
|
||||
// Initial scroll position for the freshly-mounted Virtuoso instance.
|
||||
// Default = bottom (newest message). If we have a saved position from a
|
||||
// previous visit to this chat AND the user wasn't sticking to the
|
||||
@@ -648,17 +664,8 @@ export function ConversationPage() {
|
||||
// The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
|
||||
// longer needed: Virtuoso owns scroll positioning now.
|
||||
|
||||
// Snapshot of the saved position for this conversation, captured once on
|
||||
// mount. Used to derive the `initialTopMostItemIndex` we hand to the
|
||||
// Virtuoso instance below — Virtuoso applies that index synchronously
|
||||
// before its first paint, so re-entering a chat shows the saved row in
|
||||
// one frame rather than a "starts at top, jumps" flicker.
|
||||
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
|
||||
null,
|
||||
);
|
||||
if (savedPositionRef.current === null && id) {
|
||||
savedPositionRef.current = scrollPositions.get(id) ?? null;
|
||||
}
|
||||
// (savedPositionRef declared earlier — see TDZ note above the
|
||||
// initialTopMostIndex useMemo.)
|
||||
|
||||
// Track whether the user is currently scrolled to the bottom. Virtuoso
|
||||
// calls this whenever the bottom-state changes; we feed it into
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Phase 7 — Composer Redesign (Hybrid)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Reduce composer toolbar from 9 cluttered icons to 5 hierarchically-organized buttons. Move "creative activities" (Whiteboard, Watch-Together, Mini-Games) into a `+` popover. Move View-Once from global composer toggle to per-attachment flag in the upload preview.
|
||||
|
||||
**Rollback anchor:** tag `pre-phase7-composer` (set in T1).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rollback anchor
|
||||
|
||||
- [ ] Run:
|
||||
```bash
|
||||
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||
git tag -a pre-phase7-composer -m "Rollback anchor before Phase 7 composer redesign"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `<ComposerActionsMenu>` popover component
|
||||
|
||||
**Files:** Create `apps/desktop/src/components/ComposerActionsMenu.tsx`
|
||||
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAttachFile: () => void;
|
||||
onCreatePoll: () => void;
|
||||
onCreateWhiteboard: () => void;
|
||||
onStartWatchTogether: () => void;
|
||||
onStartGame: () => void;
|
||||
canStartGame?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Layout (floating panel anchored above `anchorRef`):
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ 📎 Bild / Datei │
|
||||
│ 📊 Umfrage │
|
||||
├─────────────────────────────┤
|
||||
│ AKTIVITÄTEN │
|
||||
│ ✏ Whiteboard │
|
||||
│ 📺 Watch Together │
|
||||
│ 🎮 Spiel starten │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
- Use existing icons from `apps/desktop/src/components/icons.tsx` (grep for `PaperclipIcon`/`PlusIcon`, `PollIcon`, `MonitorShareIcon`, `PlayBoxIcon`, `GameIcon`).
|
||||
- Click outside or `Esc` → `onClose`.
|
||||
- Disabled items: `opacity-50 cursor-not-allowed` + `title` hint (e.g. "Spiele nur in 1:1-Chats").
|
||||
- Each row ≥ 44px tall, `role="menu"`/`role="menuitem"`, arrow-up/down keyboard nav.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Refactor ConversationPage composer
|
||||
|
||||
**Files:** Modify `apps/desktop/src/pages/ConversationPage.tsx`
|
||||
|
||||
**Target layout:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ [+] [😊] [GIF] [🎤] Nachricht schreiben… [→] │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Changes:
|
||||
1. **Remove** inline buttons for: file-attach, poll, whiteboard, watch-together, game-picker.
|
||||
2. **Add** a `+` button at position 1 with a `useRef` anchor.
|
||||
3. **State:** `const [menuOpen, setMenuOpen] = useState(false);` + render `<ComposerActionsMenu>` with the existing handlers wired (`handleCreateWhiteboard`, `handleStartWatchTogether`, `handleStartGame`, `() => setPollDialogOpen(true)`, `() => fileInputRef.current?.click()`).
|
||||
4. **Remove** the standalone View-Once toggle button (moves to T4 per-attachment).
|
||||
5. **Keep inline:** Emoji picker, GIF picker, voice mic, send arrow.
|
||||
6. **Auto-close menu** after any item action.
|
||||
7. Pass `canStartGame={conversation?.members?.length === 2}` so the dropdown reflects the DM-only constraint.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: View-Once per-attachment in `AttachmentPreview`
|
||||
|
||||
**Files:**
|
||||
- Modify `apps/desktop/src/pages/ConversationPage.tsx` (`AttachmentPreview` component + the `attachments[]` state shape).
|
||||
- Modify `apps/desktop/src/hooks/useConversationMessages.ts` (`send()` signature + per-attachment handling).
|
||||
- Possibly extend the per-attachment encrypt/upload helper if it still treats `viewOnce` as a per-message flag.
|
||||
|
||||
**Behavior:**
|
||||
|
||||
Add a third hover-button on each image preview next to `✏` and `✕`: a `👁` icon that toggles `viewOnce` per attachment.
|
||||
- Active: icon switches (e.g. crossed-eye) + small `1×` badge in lower-right corner of the thumb.
|
||||
- Image-only (`file.type.startsWith('image/')`). Hidden on non-image previews.
|
||||
|
||||
**State refactor:**
|
||||
|
||||
Change `attachments: File[]` → `attachments: Array<{ file: File; viewOnce: boolean }>`. Every consumer site updated:
|
||||
- `setAttachments((prev) => [...prev, ...newOnes.map((f) => ({ file: f, viewOnce: false }))])`
|
||||
- `attachments.map((a, idx) => <AttachmentPreview file={a.file} ... onToggleViewOnce={() => setAttachments(prev => prev.map((x, i) => i === idx ? { ...x, viewOnce: !x.viewOnce } : x))} />)`
|
||||
- `setAttachments((prev) => prev.filter((_, i) => i !== idx))` — unchanged shape
|
||||
|
||||
**Send path:**
|
||||
|
||||
The `send()` currently accepts a `viewOnce` option that applies globally. Refactor so the per-attachment flag flows through:
|
||||
- Either change `send(payload, attachments, replyTo, { viewOnce })` → `send(payload, attachmentsWithFlags, replyTo)` where each entry carries its own `viewOnce`
|
||||
- OR pass a parallel `viewOnceFlags: boolean[]` array aligned with attachments
|
||||
|
||||
The encrypt/upload helper already supports per-attachment `view_once` (P2.T14 column `message_attachments.view_once`). The renderer just needs to pass the right flag per row.
|
||||
|
||||
**Grep first** to find the existing wiring: `Grep -rn "view_once\|viewOnce" apps/desktop/src/ packages/shared/src/chat/` — adapt to what's actually there.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Cleanup + Final gate
|
||||
|
||||
- [ ] `pnpm --filter @chat-app/desktop typecheck` — green
|
||||
- [ ] `pnpm --filter @chat-app/shared test -- --run` — green (71 tests)
|
||||
- [ ] `pnpm --filter @chat-app/desktop test -- --run` — green
|
||||
- [ ] `git status` — clean
|
||||
- [ ] Tag `phase7-done`
|
||||
- [ ] Report smoke-test points:
|
||||
1. Composer shows 5 inline buttons (was 9)
|
||||
2. Click `+` → popover opens; click anywhere outside or `Esc` closes it
|
||||
3. Attach image → preview shows ✏/👁/✕ on hover
|
||||
4. Toggle 👁 on attachment-1 only → recipient sees attachment-1 as view-once, attachment-2 normally
|
||||
5. Everything else unchanged (emoji, GIF, voice, send, edit-message, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No emoji-as-icon (uses existing SVG icons).
|
||||
- No slash-commands (deferred to potential Phase 7B).
|
||||
- No reordering of inline buttons beyond the spec.
|
||||
- No per-attachment poll-attach (polls remain message-level).
|
||||
Reference in New Issue
Block a user