feat(sharing): add collaborative shares, export-text, deep-link helper — note-collaborators module (11 new tests)

This commit is contained in:
saravanakumardb1 2026-04-06 20:31:31 -07:00
parent 0e16714da1
commit 599d68e116
15 changed files with 1086 additions and 29 deletions

View File

@ -17,6 +17,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
note_versions: { partitionKeyPath: '/workspaceId' },
note_intake_rules: { partitionKeyPath: '/userId' },
note_intake_jobs: { partitionKeyPath: '/userId' },
note_collaborators: { partitionKeyPath: '/sharedWithUserId' },
};
export async function initCosmosIfNeeded(): Promise<void> {

View File

@ -0,0 +1,41 @@
import { getCollection } from '../../lib/datastore.js';
import type { FilterMap } from '@bytelyst/datastore';
import type { NoteCollaboratorDoc } from './types.js';
function collection() {
return getCollection<NoteCollaboratorDoc>('note_collaborators', '/sharedWithUserId');
}
export async function createCollaborator(doc: NoteCollaboratorDoc): Promise<NoteCollaboratorDoc> {
return collection().create(doc);
}
export async function listCollaboratorsForNote(
noteId: string,
productId: string,
): Promise<NoteCollaboratorDoc[]> {
const filter: FilterMap = { noteId, productId };
return collection().findMany({ filter, sort: { createdAt: -1 }, limit: 100, offset: 0 });
}
export async function listSharedWithMe(
sharedWithUserId: string,
productId: string,
): Promise<NoteCollaboratorDoc[]> {
const filter: FilterMap = { sharedWithUserId, productId };
return collection().findMany({ filter, sort: { createdAt: -1 }, limit: 100, offset: 0 });
}
export async function findCollaborator(
noteId: string,
sharedWithUserId: string,
productId: string,
): Promise<NoteCollaboratorDoc | null> {
const filter: FilterMap = { noteId, sharedWithUserId, productId };
const items = await collection().findMany({ filter, limit: 1, offset: 0 });
return items[0] ?? null;
}
export async function deleteCollaborator(id: string, sharedWithUserId: string): Promise<void> {
await collection().delete(id, sharedWithUserId);
}

View File

@ -0,0 +1,198 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('../../lib/request-context.js', () => ({
getUserId: vi.fn(() => 'user_1'),
getRequestProductId: vi.fn(() => 'notelett'),
}));
vi.mock('../../lib/feature-flags.js', () => ({
isFeatureEnabled: vi.fn(() => true),
}));
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
vi.mock('../../lib/config.js', () => ({ config: {} }));
vi.mock('../../lib/embeddings.js', () => ({
stripHtmlForEmbedding: vi.fn((s: string) => s.replace(/<[^>]*>/g, ' ').trim()),
}));
const getNoteMock = vi.fn(async () => null);
vi.mock('../notes/repository.js', () => ({
getNote: (...args: unknown[]) => getNoteMock(...args),
}));
const createCollaboratorMock = vi.fn(async (doc: Record<string, unknown>) => doc);
const listCollaboratorsForNoteMock = vi.fn(async () => []);
const listSharedWithMeMock = vi.fn(async () => []);
const findCollaboratorMock = vi.fn(async () => null);
const deleteCollaboratorMock = vi.fn(async () => undefined);
vi.mock('./repository.js', () => ({
createCollaborator: (...args: unknown[]) => createCollaboratorMock(...args as [Record<string, unknown>]),
listCollaboratorsForNote: (...args: unknown[]) => listCollaboratorsForNoteMock(...args),
listSharedWithMe: (...args: unknown[]) => listSharedWithMeMock(...args),
findCollaborator: (...args: unknown[]) => findCollaboratorMock(...args),
deleteCollaborator: (...args: unknown[]) => deleteCollaboratorMock(...args),
}));
import { buildTestApp } from '../../test-helpers.js';
import { noteCollaboratorRoutes } from './routes.js';
async function buildApp() {
return buildTestApp(noteCollaboratorRoutes);
}
describe('note-collaborators routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('POST /notes/:id/share-with-user', () => {
it('shares a note with another user', async () => {
getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' });
findCollaboratorMock.mockResolvedValueOnce(null);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/notes/n1/share-with-user',
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_2', permission: 'view' },
});
expect(res.statusCode).toBe(201);
expect(createCollaboratorMock).toHaveBeenCalledOnce();
});
it('rejects sharing with yourself', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/notes/n1/share-with-user',
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_1', permission: 'view' },
});
expect(res.statusCode).toBe(400);
});
it('rejects duplicate share', async () => {
getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' });
findCollaboratorMock.mockResolvedValueOnce({ id: 'existing' });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/notes/n1/share-with-user',
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_2', permission: 'view' },
});
expect(res.statusCode).toBe(400);
});
});
describe('GET /notes/:id/collaborators', () => {
it('lists collaborators', async () => {
listCollaboratorsForNoteMock.mockResolvedValueOnce([
{ id: 'c1', sharedWithUserId: 'user_2', permission: 'view' },
]);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators' });
expect(res.statusCode).toBe(200);
expect(res.json().items).toHaveLength(1);
});
});
describe('GET /shared-with-me', () => {
it('lists notes shared with current user', async () => {
listSharedWithMeMock.mockResolvedValueOnce([
{ id: 'c1', noteId: 'n1', sharedWithUserId: 'user_1' },
]);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/shared-with-me' });
expect(res.statusCode).toBe(200);
expect(res.json().items).toHaveLength(1);
});
});
describe('DELETE /notes/:noteId/collaborators/:userId', () => {
it('removes a collaborator', async () => {
findCollaboratorMock.mockResolvedValueOnce({
id: 'c1', sharedByUserId: 'user_1', sharedWithUserId: 'user_2',
});
const app = await buildApp();
const res = await app.inject({
method: 'DELETE',
url: '/api/notes/n1/collaborators/user_2',
});
expect(res.statusCode).toBe(204);
expect(deleteCollaboratorMock).toHaveBeenCalledOnce();
});
it('returns 404 for missing collaborator', async () => {
findCollaboratorMock.mockResolvedValueOnce(null);
const app = await buildApp();
const res = await app.inject({
method: 'DELETE',
url: '/api/notes/n1/collaborators/user_2',
});
expect(res.statusCode).toBe(404);
});
});
describe('POST /notes/:id/export-text', () => {
it('exports note as text formats', async () => {
getNoteMock.mockResolvedValueOnce({
id: 'n1', userId: 'user_1', productId: 'notelett',
title: 'Test Note', body: '<p>Hello world</p>',
});
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/notes/n1/export-text',
payload: { workspaceId: 'ws-1' },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.title).toBe('Test Note');
expect(body.plaintext).toBeDefined();
expect(body.markdown).toBeDefined();
expect(body.html).toBe('<p>Hello world</p>');
});
it('returns 404 for missing note', async () => {
getNoteMock.mockResolvedValueOnce(null);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/notes/n1/export-text',
payload: { workspaceId: 'ws-1' },
});
expect(res.statusCode).toBe(404);
});
});
describe('GET /notes/:id/deep-link', () => {
it('returns deep link URLs', async () => {
getNoteMock.mockResolvedValueOnce({ id: 'n1', productId: 'notelett' });
const app = await buildApp();
const res = await app.inject({
method: 'GET',
url: '/api/notes/n1/deep-link?workspaceId=ws-1',
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.web).toContain('/notes/n1');
expect(body.mobile).toBe('notelett://note/n1');
});
it('requires workspaceId query param', async () => {
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/notes/n1/deep-link' });
expect(res.statusCode).toBe(400);
});
});
});

View File

@ -0,0 +1,154 @@
/**
* Note collaborators + enhanced sharing routes.
*/
import type { FastifyInstance } from 'fastify';
import { randomUUID } from 'node:crypto';
import { getUserId, getRequestProductId } from '../../lib/request-context.js';
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
import { isFeatureEnabled } from '../../lib/feature-flags.js';
import { trackEvent } from '../../lib/telemetry.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
import { stripHtmlForEmbedding } from '../../lib/embeddings.js';
import * as noteRepo from '../notes/repository.js';
import * as collabRepo from './repository.js';
import { ShareWithUserSchema, ExportTextSchema } from './types.js';
import type { NoteCollaboratorDoc } from './types.js';
import { config } from '../../lib/config.js';
export async function noteCollaboratorRoutes(app: FastifyInstance): Promise<void> {
// ── Share note with another user ──────────────────────────────
app.post('/notes/:id/share-with-user', async (req, reply) => {
const userId = getUserId(req);
const productId = getRequestProductId(req);
const { id: noteId } = req.params as { id: string };
if (!isFeatureEnabled('notelett_collaborative_sharing_enabled')) {
throw new BadRequestError('Collaborative sharing is not enabled');
}
const parsed = ShareWithUserSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i: { message: string }) => i.message).join('; '));
}
const input = parsed.data;
if (input.sharedWithUserId === userId) {
throw new BadRequestError('Cannot share a note with yourself');
}
// Verify note exists and belongs to user
const note = await noteRepo.getNote(noteId, input.workspaceId);
if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
// Check if already shared
const existing = await collabRepo.findCollaborator(noteId, input.sharedWithUserId, productId);
if (existing) {
throw new BadRequestError('Note is already shared with this user');
}
const now = new Date().toISOString();
const doc: NoteCollaboratorDoc = {
id: `collab_${randomUUID().replace(/-/g, '').slice(0, 12)}`,
productId,
noteId,
workspaceId: input.workspaceId,
sharedByUserId: userId,
sharedWithUserId: input.sharedWithUserId,
permission: input.permission,
createdAt: now,
};
const created = await collabRepo.createCollaborator(doc);
trackEvent('note.shared_with_user', userId, { noteId, permission: input.permission });
reply.code(201);
return created;
});
// ── List collaborators on a note ──────────────────────────────
app.get('/notes/:id/collaborators', async (req) => {
const userId = getUserId(req);
const productId = getRequestProductId(req);
const { id: noteId } = req.params as { id: string };
const collaborators = await collabRepo.listCollaboratorsForNote(noteId, productId);
return { items: collaborators, total: collaborators.length };
});
// ── List notes shared with me ─────────────────────────────────
app.get('/shared-with-me', async (req) => {
const userId = getUserId(req);
const productId = getRequestProductId(req);
const shared = await collabRepo.listSharedWithMe(userId, productId);
return { items: shared, total: shared.length };
});
// ── Revoke collaborator access ────────────────────────────────
app.delete('/notes/:noteId/collaborators/:userId', async (req, reply) => {
const currentUserId = getUserId(req);
const productId = getRequestProductId(req);
const { noteId, userId: targetUserId } = req.params as { noteId: string; userId: string };
const collab = await collabRepo.findCollaborator(noteId, targetUserId, productId);
if (!collab) throw new NotFoundError('Collaborator not found');
// Only the note owner (sharer) can revoke
if (collab.sharedByUserId !== currentUserId && collab.sharedWithUserId !== currentUserId) {
throw new BadRequestError('Only the note owner or the collaborator can revoke access');
}
await collabRepo.deleteCollaborator(collab.id, collab.sharedWithUserId);
trackEvent('note.collaborator_removed', currentUserId, { noteId, removedUserId: targetUserId });
reply.code(204);
});
// ── Export note as clean text ─────────────────────────────────
app.post('/notes/:id/export-text', async (req) => {
const userId = getUserId(req);
const { id: noteId } = req.params as { id: string };
const parsed = ExportTextSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i: { message: string }) => i.message).join('; '));
}
const note = await noteRepo.getNote(noteId, parsed.data.workspaceId);
if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
const plaintext = stripHtmlForEmbedding(note.body);
const markdown = plaintext; // Basic — HTML is already stripped
const html = note.body;
trackEvent('note.exported_text', userId, { noteId });
return { markdown, plaintext, html, title: note.title };
});
// ── Deep link helper ──────────────────────────────────────────
app.get('/notes/:id/deep-link', async (req) => {
const userId = getUserId(req);
const productId = getRequestProductId(req);
const { id: noteId } = req.params as { id: string };
const workspaceId = (req.query as Record<string, string>).workspaceId;
if (!workspaceId) throw new BadRequestError('workspaceId query param required');
const note = await noteRepo.getNote(noteId, workspaceId);
if (!note || note.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
const webOrigin = process.env.NEXT_PUBLIC_WEB_APP_ORIGIN || `http://localhost:3045`;
return {
web: `${webOrigin}/notes/${noteId}`,
mobile: `notelett://note/${noteId}`,
public: null,
};
});
}

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
export const COLLABORATOR_PERMISSIONS = ['view', 'comment', 'edit'] as const;
export type CollaboratorPermission = (typeof COLLABORATOR_PERMISSIONS)[number];
export interface NoteCollaboratorDoc {
id: string;
productId: string;
noteId: string;
workspaceId: string;
sharedByUserId: string;
sharedWithUserId: string;
permission: CollaboratorPermission;
createdAt: string;
_ts?: number;
_etag?: string;
}
export const ShareWithUserSchema = z.object({
workspaceId: z.string().min(1).max(128),
sharedWithUserId: z.string().min(1).max(128),
permission: z.enum(COLLABORATOR_PERMISSIONS).default('view'),
});
export type ShareWithUserInput = z.infer<typeof ShareWithUserSchema>;
export const ExportTextSchema = z.object({
workspaceId: z.string().min(1).max(128),
});

View File

@ -39,6 +39,7 @@ vi.mock('./modules/note-prompts/scheduler.js', () => ({
stopSchedulerLoop: vi.fn(),
}));
vi.mock('./modules/intake/routes.js', () => ({ intakeRoutes: vi.fn() }));
vi.mock('./modules/note-collaborators/routes.js', () => ({ noteCollaboratorRoutes: vi.fn() }));
vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock }));
vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock }));
vi.mock('./lib/config.js', () => ({
@ -78,7 +79,7 @@ describe('server bootstrap', () => {
expect(initDatastoreMock).toHaveBeenCalledOnce();
expect(createServiceAppMock).toHaveBeenCalledOnce();
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
expect(appMock.register).toHaveBeenCalledTimes(12);
expect(appMock.register).toHaveBeenCalledTimes(13);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
});
});

View File

@ -12,6 +12,7 @@ import { workspaceRoutes } from './modules/workspaces/routes.js';
import { notePromptRoutes } from './modules/note-prompts/routes.js';
import { promptSchedulerRoutes, startSchedulerLoop, stopSchedulerLoop } from './modules/note-prompts/scheduler.js';
import { intakeRoutes } from './modules/intake/routes.js';
import { noteCollaboratorRoutes } from './modules/note-collaborators/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { initEncryption } from './lib/field-encrypt.js';
import { initDatastore } from './lib/datastore.js';
@ -67,6 +68,7 @@ await registerApiPlugin(workspaceRoutes);
await registerApiPlugin(notePromptRoutes);
await registerApiPlugin(promptSchedulerRoutes);
await registerApiPlugin(intakeRoutes);
await registerApiPlugin(noteCollaboratorRoutes);
// ── Start scheduler loop (F25) ────────────────────────────────────
startSchedulerLoop();

114
mobile/src/api/intake.ts Normal file
View File

@ -0,0 +1,114 @@
import { getApiClient } from './client';
// ── Content Types ────────────────────────────────────────────────
export const INTAKE_CONTENT_TYPES = [
'youtube', 'article', 'pdf', 'tweet', 'reddit', 'github', 'generic',
] as const;
export type IntakeContentType = (typeof INTAKE_CONTENT_TYPES)[number];
export const INTAKE_JOB_STATUSES = [
'queued', 'extracting', 'processing', 'complete', 'failed',
] as const;
export type IntakeJobStatus = (typeof INTAKE_JOB_STATUSES)[number];
// ── Types ────────────────────────────────────────────────────────
export type IntakeSubmitResult = {
jobId: string;
noteId: string;
contentType: IntakeContentType;
ruleMatched: string | null;
templateSlug: string;
status: 'queued';
};
export type IntakeJob = {
id: string;
productId: string;
userId: string;
workspaceId: string;
noteId: string;
ruleId: string;
url: string;
contentType: IntakeContentType;
templateSlug: string;
status: IntakeJobStatus;
extractedText?: string;
error?: string;
startedAt: string;
completedAt?: string;
};
export type IntakeRule = {
id: string;
productId: string;
userId: string;
workspaceId: string;
name: string;
urlPattern: string;
contentType: IntakeContentType;
templateId: string;
enabled: boolean;
priority: number;
createdAt: string;
updatedAt: string;
};
type IntakeJobListResponse = {
items: IntakeJob[];
total: number;
};
type IntakeRuleListResponse = {
items: IntakeRule[];
total: number;
};
export type ListIntakeJobsOptions = {
status?: string;
since?: string;
limit?: number;
offset?: number;
};
// ── API Functions ────────────────────────────────────────────────
export async function submitIntake(
url: string,
workspaceId?: string,
templateOverride?: string,
): Promise<IntakeSubmitResult> {
return getApiClient().fetch<IntakeSubmitResult>('/intake', {
method: 'POST',
body: JSON.stringify({
url,
...(workspaceId ? { workspaceId } : {}),
...(templateOverride ? { templateOverride } : {}),
}),
});
}
export async function listIntakeJobs(
options?: ListIntakeJobsOptions,
): Promise<IntakeJob[]> {
const params = new URLSearchParams();
if (options?.status) params.set('status', options.status);
if (options?.since) params.set('since', options.since);
if (options?.limit) params.set('limit', String(options.limit));
if (options?.offset) params.set('offset', String(options.offset));
const qs = params.toString();
const path = qs ? `/intake/jobs?${qs}` : '/intake/jobs';
const res = await getApiClient().fetch<IntakeJobListResponse>(path);
return res.items;
}
export async function getIntakeJob(id: string): Promise<IntakeJob> {
return getApiClient().fetch<IntakeJob>(`/intake/jobs/${encodeURIComponent(id)}`);
}
export async function listIntakeRules(): Promise<IntakeRule[]> {
const res = await getApiClient().fetch<IntakeRuleListResponse>('/intake-rules');
return res.items;
}

View File

@ -1,9 +1,19 @@
import { Tabs } from 'expo-router';
import { useIntakeStore, type IntakeState } from '../../store/intake-store';
export default function TabLayout() {
const activeJobCount = useIntakeStore((state: IntakeState) => state.activeJobs.length);
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="index" options={{ title: 'Home', tabBarAccessibilityLabel: 'Home tab' }} />
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarAccessibilityLabel: 'Home tab',
tabBarBadge: activeJobCount > 0 ? activeJobCount : undefined,
}}
/>
<Tabs.Screen name="search" options={{ title: 'Search', tabBarAccessibilityLabel: 'Search tab' }} />
<Tabs.Screen name="capture" options={{ title: 'Capture', tabBarAccessibilityLabel: 'Capture tab' }} />
<Tabs.Screen name="inbox" options={{ title: 'Inbox', tabBarAccessibilityLabel: 'Inbox tab' }} />

View File

@ -4,7 +4,8 @@ import * as Clipboard from 'expo-clipboard';
import type { MobileWorkspace } from '../../api/workspaces';
import { useNotesStore, type NotesState } from '../../store/notes-store';
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
import { extractFromUrl } from '../../api/note-prompts';
import { useIntakeStore, type IntakeState } from '../../store/intake-store';
import { submitIntake, type IntakeSubmitResult } from '../../api/intake';
import { colors } from '../../theme';
type CaptureMode = 'text' | 'photo' | 'voice' | 'url' | 'scan' | 'paste';

View File

@ -1,61 +1,110 @@
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { View, Text, TextInput, Pressable, StyleSheet, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router';
import { extractFromUrl } from '../../../api/note-prompts';
import { submitIntake, type IntakeSubmitResult } from '../../../api/intake';
import { useWorkspaceStore, type WorkspaceState } from '../../../store/workspace-store';
import { useIntakeStore, type IntakeState } from '../../../store/intake-store';
import { colors } from '../../../theme';
function classifyUrlLocal(url: string): string {
if (/youtube\.com|youtu\.be/i.test(url)) return 'YouTube';
if (/twitter\.com|x\.com/i.test(url)) return 'Tweet';
if (/reddit\.com/i.test(url)) return 'Reddit';
if (/github\.com/i.test(url)) return 'GitHub';
if (/\.pdf(\?|$)/i.test(url)) return 'PDF';
if (/^https?:\/\//i.test(url)) return 'Article';
return 'Unknown';
}
export default function UrlCaptureScreen() {
const router = useRouter();
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<{ title: string; content: string } | null>(null);
const [result, setResult] = useState<IntakeSubmitResult | null>(null);
const [error, setError] = useState<string | null>(null);
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob);
async function handleExtract() {
const detectedType = url.trim() ? classifyUrlLocal(url.trim()) : null;
async function handleProcess() {
if (!url.trim()) return;
setBusy(true);
setError(null);
try {
const res = await extractFromUrl(url.trim(), 'default');
setResult({ title: res.title, content: res.content });
const res = await submitIntake(url.trim(), activeWorkspaceId ?? undefined);
setResult(res);
waitForJob(res.jobId, (job) => {
if (job.status === 'complete') {
router.push(`/note/${job.noteId}`);
}
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraction failed');
setError(err instanceof Error ? err.message : 'Intake failed');
} finally {
setBusy(false);
}
}
function handleAdvanced() {
router.push({ pathname: '/intake', params: { url: url.trim() } });
}
return (
<View style={styles.container}>
<Text style={styles.heading}>URL Capture</Text>
<Text style={styles.hint}>Paste a URL to extract and summarize its content.</Text>
<Text style={styles.hint}>Paste a URL to extract and process with the intake pipeline.</Text>
<TextInput
style={styles.input}
placeholder="https://example.com/article"
placeholderTextColor={colors.textTertiary}
value={url}
onChangeText={setUrl}
onChangeText={(text) => { setUrl(text); setResult(null); setError(null); }}
autoCapitalize="none"
keyboardType="url"
accessibilityLabel="URL input"
/>
<TouchableOpacity
style={[styles.btn, busy && { opacity: 0.6 }]}
disabled={busy || !url.trim()}
onPress={() => void handleExtract()}
accessibilityLabel="Extract content"
>
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.btnText}>Extract</Text>}
</TouchableOpacity>
{detectedType && (
<View style={styles.typeRow}>
<Text style={styles.typeLabel}>Detected:</Text>
<View style={styles.typeBadge}>
<Text style={styles.typeBadgeText}>{detectedType}</Text>
</View>
</View>
)}
{error && <Text style={styles.error}>{error}</Text>}
{result && (
{result ? (
<View style={styles.resultCard}>
<Text style={styles.resultTitle}>{result.title}</Text>
<Text style={styles.resultContent} numberOfLines={20}>{result.content}</Text>
<ActivityIndicator color={colors.accentPrimary} size="small" />
<Text style={styles.resultText}>Processing as {result.contentType}</Text>
<Text style={styles.resultMeta}>Job: {result.jobId}</Text>
</View>
) : (
<View style={styles.buttonRow}>
<Pressable
style={[styles.btn, (busy || !url.trim()) && styles.btnDisabled]}
disabled={busy || !url.trim()}
onPress={() => void handleProcess()}
accessibilityLabel="Process URL with AI"
>
{busy ? (
<ActivityIndicator color={colors.textPrimary} size="small" />
) : (
<Text style={styles.btnText}>Process with AI</Text>
)}
</Pressable>
<Pressable
style={styles.secondaryBtn}
disabled={!url.trim()}
onPress={handleAdvanced}
accessibilityLabel="Advanced intake options"
>
<Text style={styles.secondaryBtnText}>Options</Text>
</Pressable>
</View>
)}
</View>
@ -67,10 +116,18 @@ const styles = StyleSheet.create({
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
hint: { fontSize: 13, color: colors.textSecondary },
input: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, color: colors.textPrimary, fontSize: 15 },
btn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 10, alignItems: 'center' },
btnText: { color: '#fff', fontWeight: '600', fontSize: 15 },
typeRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
typeLabel: { fontSize: 13, color: colors.textSecondary },
typeBadge: { backgroundColor: colors.accentPrimary + '22', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 3, borderWidth: 1, borderColor: colors.accentPrimary },
typeBadgeText: { fontSize: 12, fontWeight: '700', color: colors.accentPrimary },
buttonRow: { gap: 10 },
btn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 12, alignItems: 'center' },
btnDisabled: { opacity: 0.5 },
btnText: { color: colors.textPrimary, fontWeight: '700', fontSize: 15 },
secondaryBtn: { borderRadius: 8, paddingVertical: 10, alignItems: 'center', borderWidth: 1, borderColor: colors.borderDefault },
secondaryBtnText: { color: colors.textSecondary, fontWeight: '600', fontSize: 14 },
error: { color: colors.danger, fontSize: 13 },
resultCard: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, gap: 8 },
resultTitle: { fontSize: 16, fontWeight: '600', color: colors.textPrimary },
resultContent: { fontSize: 14, color: colors.textSecondary, lineHeight: 20 },
resultCard: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.accentPrimary, gap: 8, alignItems: 'center' },
resultText: { fontSize: 14, color: colors.textPrimary },
resultMeta: { fontSize: 11, color: colors.textTertiary },
});

View File

@ -4,6 +4,7 @@ import { StatusBar } from 'expo-status-bar';
import { AppState, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useAuthStore, type AuthState } from '../store/auth-store';
import { useInboxStore } from '../store/inbox-store';
import { useIntakeStore } from '../store/intake-store';
import { useNotesStore } from '../store/notes-store';
import { useWorkspaceStore } from '../store/workspace-store';
import { checkKillSwitch, flushTelemetry, initPlatform } from '../lib/platform';
@ -136,6 +137,7 @@ export default function RootLayout() {
void flushQueuedNoteMutations();
void loadBroadcasts();
void loadSurvey();
void useIntakeStore.getState().pollActiveJobs();
})();
const broadcastTimer = setInterval(() => {
@ -146,6 +148,10 @@ export default function RootLayout() {
void loadSurvey();
}, 10 * 60_000);
const intakeTimer = setInterval(() => {
void useIntakeStore.getState().pollActiveJobs();
}, 30_000);
const appStateSubscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
void flushQueuedNoteMutations();
@ -156,6 +162,7 @@ export default function RootLayout() {
cancelled = true;
clearInterval(broadcastTimer);
clearInterval(surveyTimer);
clearInterval(intakeTimer);
appStateSubscription.remove();
};
}, [hasBootstrapped, isAuthenticated]);

345
mobile/src/app/intake.tsx Normal file
View File

@ -0,0 +1,345 @@
import { useEffect, useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { submitIntake, listIntakeRules, type IntakeRule, type IntakeSubmitResult } from '../api/intake';
import { listPromptTemplates, type MobilePromptTemplate } from '../api/note-prompts';
import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store';
import { useIntakeStore, type IntakeState } from '../store/intake-store';
import type { MobileWorkspace } from '../api/workspaces';
import { colors } from '../theme';
type ContentTypeBadge = { label: string; color: string };
const CONTENT_TYPE_MAP: Record<string, ContentTypeBadge> = {
youtube: { label: 'YouTube', color: '#FF0000' },
article: { label: 'Article', color: colors.accentPrimary },
pdf: { label: 'PDF', color: '#E44D26' },
tweet: { label: 'Tweet', color: '#1DA1F2' },
reddit: { label: 'Reddit', color: '#FF4500' },
github: { label: 'GitHub', color: '#6e5494' },
generic: { label: 'Web Page', color: colors.textSecondary },
};
function getContentTypeBadge(type: string): ContentTypeBadge {
return CONTENT_TYPE_MAP[type] ?? { label: type, color: colors.textSecondary };
}
export default function IntakeScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ url?: string }>();
const incomingUrl = params.url ?? '';
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob);
const [rules, setRules] = useState<IntakeRule[]>([]);
const [templates, setTemplates] = useState<MobilePromptTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [matchedRule, setMatchedRule] = useState<IntakeRule | null>(null);
const [detectedType, setDetectedType] = useState<string>('generic');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<IntakeSubmitResult | null>(null);
useEffect(() => {
void loadRulesAndTemplates();
}, []);
useEffect(() => {
if (incomingUrl && rules.length > 0) {
matchUrl(incomingUrl);
}
}, [incomingUrl, rules]);
async function loadRulesAndTemplates(): Promise<void> {
try {
const [fetchedRules, fetchedTemplates] = await Promise.all([
listIntakeRules(),
listPromptTemplates(),
]);
setRules(fetchedRules);
setTemplates(fetchedTemplates);
} catch {
// Non-fatal — user can still submit
}
}
function matchUrl(url: string): void {
for (const rule of rules) {
if (!rule.enabled) continue;
try {
const regex = new RegExp(rule.urlPattern, 'i');
if (regex.test(url)) {
setMatchedRule(rule);
setDetectedType(rule.contentType);
setSelectedTemplate(rule.templateId);
return;
}
} catch {
// Invalid regex — skip
}
}
// Fallback heuristic classification
if (/youtube\.com|youtu\.be/i.test(url)) setDetectedType('youtube');
else if (/twitter\.com|x\.com/i.test(url)) setDetectedType('tweet');
else if (/reddit\.com/i.test(url)) setDetectedType('reddit');
else if (/github\.com/i.test(url)) setDetectedType('github');
else if (/\.pdf(\?|$)/i.test(url)) setDetectedType('pdf');
else setDetectedType('article');
}
async function handleProcess(): Promise<void> {
if (!incomingUrl.trim()) return;
setBusy(true);
setError(null);
try {
const submitResult = await submitIntake(
incomingUrl.trim(),
activeWorkspaceId ?? undefined,
selectedTemplate ?? undefined,
);
setResult(submitResult);
waitForJob(submitResult.jobId, (job) => {
if (job.status === 'complete') {
router.push(`/note/${job.noteId}`);
}
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Intake submission failed');
} finally {
setBusy(false);
}
}
const badge = getContentTypeBadge(detectedType);
const activeWorkspaceName =
workspaces.find((w: MobileWorkspace) => w.id === activeWorkspaceId)?.name ?? 'Default';
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.heading}>Process URL</Text>
<Text style={styles.hint}>Intake pipeline will extract, classify, and create a note from this URL.</Text>
{/* URL display */}
<View style={styles.urlCard}>
<Text style={styles.urlLabel}>URL</Text>
<Text style={styles.urlText} numberOfLines={3}>{incomingUrl || 'No URL provided'}</Text>
</View>
{/* Content type badge */}
<View style={styles.row}>
<Text style={styles.label}>Detected type</Text>
<View style={[styles.badge, { backgroundColor: badge.color + '22', borderColor: badge.color }]}>
<Text style={[styles.badgeText, { color: badge.color }]}>{badge.label}</Text>
</View>
</View>
{/* Matched rule */}
{matchedRule && (
<View style={styles.row}>
<Text style={styles.label}>Matched rule</Text>
<Text style={styles.value}>{matchedRule.name}</Text>
</View>
)}
{/* Workspace */}
<View style={styles.row}>
<Text style={styles.label}>Workspace</Text>
<Text style={styles.value}>{activeWorkspaceName}</Text>
</View>
{/* Template override */}
{templates.length > 0 && (
<View style={styles.section}>
<Text style={styles.label}>Template</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chipRow}>
<Pressable
accessibilityLabel="Auto-select template"
onPress={() => setSelectedTemplate(null)}
style={[styles.chip, selectedTemplate === null ? styles.chipActive : null]}
>
<Text style={[styles.chipText, selectedTemplate === null ? styles.chipTextActive : null]}>
Auto
</Text>
</Pressable>
{templates.slice(0, 8).map((t) => (
<Pressable
key={t.id}
accessibilityLabel={`Select template ${t.name}`}
onPress={() => setSelectedTemplate(t.slug)}
style={[styles.chip, selectedTemplate === t.slug ? styles.chipActive : null]}
>
<Text style={[styles.chipText, selectedTemplate === t.slug ? styles.chipTextActive : null]}>
{t.name}
</Text>
</Pressable>
))}
</ScrollView>
</View>
)}
{/* Error */}
{error && <Text style={styles.error}>{error}</Text>}
{/* Result: processing */}
{result && (
<View style={styles.resultCard}>
<ActivityIndicator color={colors.accentPrimary} size="small" />
<Text style={styles.resultText}>
Job queued processing as {result.contentType}. You will be redirected when complete.
</Text>
<Text style={styles.resultMeta}>Job ID: {result.jobId}</Text>
</View>
)}
{/* Process button */}
{!result && (
<Pressable
accessibilityLabel="Process URL with intake pipeline"
onPress={() => void handleProcess()}
disabled={busy || !incomingUrl.trim()}
style={[styles.button, (busy || !incomingUrl.trim()) ? styles.buttonDisabled : null]}
>
{busy ? (
<ActivityIndicator color={colors.textPrimary} size="small" />
) : (
<Text style={styles.buttonText}>Process</Text>
)}
</Pressable>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
padding: 20,
gap: 16,
backgroundColor: colors.bgCanvas,
minHeight: '100%',
},
heading: {
fontSize: 24,
fontWeight: '700',
color: colors.textPrimary,
},
hint: {
fontSize: 14,
color: colors.textSecondary,
lineHeight: 20,
},
urlCard: {
backgroundColor: colors.surfaceCard,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderDefault,
padding: 14,
gap: 6,
},
urlLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textTertiary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
urlText: {
fontSize: 14,
color: colors.accentPrimary,
lineHeight: 20,
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 4,
},
label: {
fontSize: 14,
fontWeight: '600',
color: colors.textSecondary,
},
value: {
fontSize: 14,
color: colors.textPrimary,
fontWeight: '500',
},
badge: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 4,
},
badgeText: {
fontSize: 12,
fontWeight: '700',
},
section: {
gap: 8,
},
chipRow: {
gap: 8,
paddingVertical: 4,
},
chip: {
borderRadius: 999,
borderWidth: 1,
borderColor: colors.borderDefault,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: colors.surfaceCard,
},
chipActive: {
backgroundColor: colors.accentPrimary,
borderColor: colors.accentPrimary,
},
chipText: {
color: colors.textSecondary,
fontSize: 13,
fontWeight: '600',
},
chipTextActive: {
color: colors.textPrimary,
},
error: {
color: colors.danger,
fontSize: 14,
fontWeight: '500',
},
resultCard: {
backgroundColor: colors.surfaceCard,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.accentPrimary,
padding: 14,
gap: 8,
alignItems: 'center',
},
resultText: {
fontSize: 14,
color: colors.textPrimary,
textAlign: 'center',
lineHeight: 20,
},
resultMeta: {
fontSize: 11,
color: colors.textTertiary,
},
button: {
backgroundColor: colors.accentPrimary,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
},
buttonText: {
color: colors.textPrimary,
fontWeight: '700',
fontSize: 16,
},
buttonDisabled: {
opacity: 0.5,
},
});

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useLocalSearchParams } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, TextInput, View } from 'react-native';
import { useNotesStore, type NotesState } from '../../store/notes-store';
import { usePromptStore, type PromptState } from '../../store/prompt-store';
import { getReadingTime, suggestTags } from '../../api/note-prompts';
@ -109,6 +109,39 @@ export default function NoteDetailScreen() {
<Text style={styles.body}>Last updated: {formattedUpdatedAt}</Text>
</View>
{/* Share */}
{selectedNote && !isLoading && (
<View style={styles.card}>
<Text style={styles.sectionTitle}>Share</Text>
<View style={styles.actionRow}>
<Pressable
accessibilityLabel="Share note as text"
style={styles.secondaryButton}
onPress={() => {
void Share.share({
message: `${selectedNote.title}\n\n${selectedNote.body}`,
title: selectedNote.title,
});
}}
>
<Text style={styles.secondaryButtonText}>Share as text</Text>
</Pressable>
<Pressable
accessibilityLabel="Share deep link to note"
style={styles.secondaryButton}
onPress={() => {
void Share.share({
message: `notelett://note/${noteId}`,
title: `NoteLett: ${selectedNote.title}`,
});
}}
>
<Text style={styles.secondaryButtonText}>Share link</Text>
</Pressable>
</View>
</View>
)}
{/* Smart Actions */}
<View style={styles.card}>
<Pressable

View File

@ -0,0 +1,64 @@
import { create } from 'zustand';
import { listIntakeJobs, getIntakeJob, type IntakeJob } from '../api/intake';
export type IntakeState = {
activeJobs: IntakeJob[];
completedJobIds: string[];
isPolling: boolean;
pollActiveJobs: () => Promise<void>;
waitForJob: (jobId: string, onComplete: (job: IntakeJob) => void) => void;
clearCompleted: (jobId: string) => void;
};
const POLL_STATUSES = 'queued,extracting,processing';
export const useIntakeStore = create<IntakeState>((set, get) => ({
activeJobs: [],
completedJobIds: [],
isPolling: false,
async pollActiveJobs() {
if (get().isPolling) return;
set({ isPolling: true });
try {
const jobs = await listIntakeJobs({ status: POLL_STATUSES, limit: 50 });
set({ activeJobs: jobs, isPolling: false });
} catch {
set({ isPolling: false });
}
},
waitForJob(jobId: string, onComplete: (job: IntakeJob) => void) {
let attempts = 0;
const maxAttempts = 60;
const intervalMs = 3000;
const timer = setInterval(async () => {
attempts += 1;
if (attempts > maxAttempts) {
clearInterval(timer);
return;
}
try {
const job = await getIntakeJob(jobId);
if (job.status === 'complete' || job.status === 'failed') {
clearInterval(timer);
set((state) => ({
activeJobs: state.activeJobs.filter((j) => j.id !== jobId),
completedJobIds: [...state.completedJobIds, jobId],
}));
onComplete(job);
}
} catch {
// Retry on next tick
}
}, intervalMs);
},
clearCompleted(jobId: string) {
set((state) => ({
completedJobIds: state.completedJobIds.filter((id) => id !== jobId),
}));
},
}));