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
2 changes: 2 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { treasuryRouter } from './routes/treasury.js';
import { filesRouter } from './routes/files.js';
import { pushRouter } from './routes/push.js';
import { syncRouter } from './routes/sync.js';
import { userDevicesRouter } from './routes/userDevices.js';
import { requireAuth, type AuthRequest } from './middleware/auth.js';

const packageJson = JSON.parse(
Expand Down Expand Up @@ -57,6 +58,7 @@ app.use('/treasury', treasuryRouter);
app.use('/files', filesRouter);
app.use('/push', pushRouter);
app.use('/sync', syncRouter);
app.use('/user-devices', userDevicesRouter);

app.get('/me', requireAuth, (req, res) => {
res.json({ user: (req as AuthRequest).auth });
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/src/routes/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ syncRouter.get('/', async (req: AuthRequest, res) => {
createdAt: messageEnvelopes.createdAt,
sequenceNumber: messages.sequenceNumber,
conversationId: messages.conversationId,
senderId: messages.senderId,
senderDeviceId: messages.senderDeviceId,
contentType: messages.contentType,
messageCreatedAt: messages.createdAt,
})
.from(messageEnvelopes)
.innerJoin(messages, eq(messageEnvelopes.messageId, messages.id))
Expand Down Expand Up @@ -118,8 +122,12 @@ syncRouter.get('/', async (req: AuthRequest, res) => {
conversationId: r.conversationId,
ciphertext: r.ciphertext,
sequenceNumber: r.sequenceNumber,
senderId: r.senderId,
senderDeviceId: r.senderDeviceId,
contentType: r.contentType,
deliveredAt: r.deliveredAt,
createdAt: r.createdAt,
messageCreatedAt: r.messageCreatedAt,
})),
nextCursor,
hasMore,
Expand Down
68 changes: 68 additions & 0 deletions apps/backend/src/routes/userDevices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* User-device routes — public key lookup for inbound decryption (#122).
*
* GET /user-devices/:id/public-key
* Returns the identity public key for a sender device when the caller shares
* a conversation with the device owner.
*/

import { Router, type Router as RouterType } from 'express';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { db } from '../db/index.js';
import { conversationMembers, userDevices } from '../db/schema.js';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';

export const userDevicesRouter: RouterType = Router();

userDevicesRouter.use(requireAuth);

userDevicesRouter.get('/:id/public-key', async (req: AuthRequest, res) => {
const senderDeviceId = req.params['id'] as string;
const requesterId = req.auth!.userId;

try {
const device = await db.query.userDevices.findFirst({
where: and(eq(userDevices.id, senderDeviceId), isNull(userDevices.revokedAt)),
columns: {
id: true,
userId: true,
identityPublicKey: true,
},
});

if (!device) {
res.status(404).json({ error: 'Device not found or revoked' });
return;
}

// Caller must share at least one conversation with the device owner.
const ownerConversations = db
.select({ conversationId: conversationMembers.conversationId })
.from(conversationMembers)
.where(eq(conversationMembers.userId, device.userId));

const [coMember] = await db
.select({ conversationId: conversationMembers.conversationId })
.from(conversationMembers)
.where(
and(
eq(conversationMembers.userId, requesterId),
inArray(conversationMembers.conversationId, ownerConversations),
),
)
.limit(1);

if (!coMember) {
res.status(403).json({ error: 'No shared conversation with device owner' });
return;
}

res.json({
id: device.id,
userId: device.userId,
identityPublicKey: device.identityPublicKey,
});
} catch {
res.status(500).json({ error: 'Failed to fetch device public key' });
}
});
112 changes: 32 additions & 80 deletions apps/web/src/app/conversations/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,12 @@
'use client';

import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import Image from 'next/image';
import { useParams } from 'next/navigation';
import { useSocket } from '@/hooks/useSocket';

interface Sender {
id: string;
username: string | null;
avatarUrl: string | null;
}

interface Message {
id: string;
conversationId: string;
senderId: string;
content: string;
createdAt: string;
sender: Sender;
}

// ── Helpers ──────────────────────────────────────────────────────────────────

function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
import { useInboundPipeline } from '@/hooks/useInboundPipeline';
import { InboundMessageRow } from '@/components/messaging/InboundMessageRow';
import { parseJwtPayload } from '@/lib/jwt';

function formatDateLabel(iso: string) {
const d = new Date(iso);
Expand Down Expand Up @@ -60,22 +42,22 @@ function Avatar({ src, name }: { src: string | null; name: string }) {
);
}

// ── Component ─────────────────────────────────────────────────────────────────

export default function ConversationPage() {
const { id } = useParams<{ id: string }>();

// TODO: replace with real auth token from your auth context/store
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
// TODO: replace with real current user id from your auth context/store
const currentUserId = typeof window !== 'undefined' ? localStorage.getItem('userId') : null;
const token =
typeof window !== 'undefined'
? (localStorage.getItem('clicked.jwt') ?? localStorage.getItem('token'))
: null;
const currentUserId =
typeof window !== 'undefined' && token ? (parseJwtPayload(token)?.userId ?? null) : null;

const socket = useSocket(token);
const [messages, setMessages] = useState<Message[]>([]);
const { messages, syncing } = useInboundPipeline({ socket, token, conversationId: id });

const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

// Scroll to bottom only when user is already near the bottom
const scrollToBottom = useCallback((force = false) => {
const el = containerRef.current;
if (!el) return;
Expand All @@ -89,35 +71,21 @@ export default function ConversationPage() {
if (!socket) return;

socket.emit('join_room', { conversationId: id });
socket.emit('message_history', { conversationId: id });

socket.on('message_history', (data: { conversationId: string; messages: Message[] }) => {
if (data.conversationId === id) {
setMessages(data.messages);
// Force scroll on initial load
setTimeout(() => scrollToBottom(true), 50);
}
});

socket.on('new_message', (msg: Message) => {
if (msg.conversationId === id) {
setMessages((prev) => [...prev, msg]);
scrollToBottom();
}
});

return () => {
socket.off('message_history');
socket.off('new_message');
socket.emit('leave_room', { conversationId: id });
};
}, [socket, id, scrollToBottom]);
}, [socket, id]);

// ── Group messages by day ──────────────────────────────────────────────────
const grouped: { label: string; messages: Message[] }[] = [];
useEffect(() => {
scrollToBottom(true);
}, [messages.length, scrollToBottom]);

const grouped: { label: string; messages: typeof messages }[] = [];
for (const msg of messages) {
const key = dayKey(msg.createdAt);
const last = grouped[grouped.length - 1];
if (last && dayKey(last.messages[0].createdAt) === key) {
if (last && dayKey(last.messages[0]!.createdAt) === key) {
last.messages.push(msg);
} else {
grouped.push({ label: formatDateLabel(msg.createdAt), messages: [msg] });
Expand All @@ -126,16 +94,20 @@ export default function ConversationPage() {

return (
<div className="flex flex-col h-screen bg-[var(--background)]">
{/* Header */}
<header className="flex-shrink-0 border-b border-[var(--border)] px-4 py-3 bg-[var(--card)]">
<h1 className="text-sm font-semibold text-[var(--foreground)]">Conversation</h1>
{syncing && (
<p className="text-xs text-[var(--muted)] mt-0.5">Syncing encrypted messages…</p>
)}
</header>

{/* Message thread */}
<div ref={containerRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
{messages.length === 0 && !syncing && (
<p className="text-center text-sm text-[var(--muted)] py-8">No messages yet.</p>
)}

{grouped.map((group) => (
<div key={group.label}>
{/* Date separator */}
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-[var(--border)]" />
<span className="text-xs text-[var(--muted)] font-medium px-2">{group.label}</span>
Expand All @@ -145,36 +117,16 @@ export default function ConversationPage() {
<div className="space-y-3">
{group.messages.map((msg) => {
const isSelf = msg.senderId === currentUserId;
const name = msg.sender.username ?? 'Unknown';
const name = isSelf ? 'You' : 'Contact';

return (
<div
key={msg.id}
key={msg.messageId}
className={`flex items-end gap-2 ${isSelf ? 'flex-row-reverse' : 'flex-row'}`}
>
{!isSelf && <Avatar src={msg.sender.avatarUrl} name={name} />}

<div
className={`flex flex-col max-w-[70%] ${isSelf ? 'items-end' : 'items-start'}`}
>
{!isSelf && (
<span className="text-xs text-[var(--muted)] mb-1 px-1">{name}</span>
)}
<div
className={`px-3 py-2 rounded-2xl text-sm leading-relaxed break-words ${
isSelf
? 'bg-[var(--accent)] text-white rounded-br-sm'
: 'bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded-bl-sm'
}`}
>
{msg.content}
</div>
<span className="text-[10px] text-[var(--muted)] mt-1 px-1">
{formatTime(msg.createdAt)}
</span>
</div>

{isSelf && <Avatar src={msg.sender.avatarUrl} name={name} />}
{!isSelf && <Avatar src={null} name={name} />}
<InboundMessageRow message={msg} isSelf={isSelf} senderName={name} />
{isSelf && <Avatar src={null} name={name} />}
</div>
);
})}
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/messaging/InboundMessageRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { InboundMessage } from '@/lib/crypto/types';
import { UnavailableMessagePlaceholder } from './UnavailableMessagePlaceholder';

interface InboundMessageRowProps {
message: InboundMessage;
isSelf: boolean;
senderName?: string;
}

function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

export function InboundMessageRow({ message, isSelf, senderName }: InboundMessageRowProps) {
return (
<div className={`flex flex-col max-w-[70%] ${isSelf ? 'items-end' : 'items-start'}`}>
{!isSelf && senderName && (
<span className="text-xs text-[var(--muted)] mb-1 px-1">{senderName}</span>
)}

{message.status === 'decrypted' && message.plaintext ? (
<div
className={`px-3 py-2 rounded-2xl text-sm leading-relaxed break-words ${
isSelf
? 'bg-[var(--accent)] text-white rounded-br-sm'
: 'bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded-bl-sm'
}`}
>
{message.plaintext}
</div>
) : message.status === 'unavailable' && message.unavailableReason ? (
<UnavailableMessagePlaceholder reason={message.unavailableReason} />
) : (
<div className="px-3 py-2 rounded-2xl text-xs text-[var(--muted)] italic">
Decrypting…
</div>
)}

<span className="text-[10px] text-[var(--muted)] mt-1 px-1">{formatTime(message.createdAt)}</span>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { UnavailableReason } from '@/lib/crypto/types';

const REASON_COPY: Record<UnavailableReason, string> = {
'pre-link': 'Waiting for secure session — message from before this device was linked.',
undecryptable: 'Unable to decrypt this message.',
'verification-failed': 'Message could not be verified.',
};

interface UnavailableMessagePlaceholderProps {
reason: UnavailableReason;
}

/** Graceful placeholder for undecryptable / pre-link messages (#127). */
export function UnavailableMessagePlaceholder({ reason }: UnavailableMessagePlaceholderProps) {
return (
<div
role="note"
aria-label="Encrypted message unavailable"
className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--card)]/60 px-3 py-2 text-sm italic text-[var(--muted)]"
>
<span aria-hidden="true" className="mr-1.5">
🔒
</span>
{REASON_COPY[reason]}
</div>
);
}
Loading