From c2c7870915bddcd80dbcdb57301e26cb5d5a26d3 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 19 May 2026 00:04:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(photos):=20=E2=9C=A8=20improve=20search=20?= =?UTF-8?q?weighting=20and=20type=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/server/src/entities/contact/repo.ts | 8 +- src/server/src/entities/message/repo.ts | 6 +- web/src/api/messages.ts | 12 +- web/src/api/photos.ts | 37 +--- web/src/hooks/useMessages.ts | 15 +- web/src/hooks/usePhotos.ts | 82 +-------- web/src/tabs/Mail/index.tsx | 176 +++++++++++++++++- web/src/tabs/Photos/index.tsx | 230 ++++++++++++------------ web/src/types.ts | 55 ++---- 9 files changed, 335 insertions(+), 286 deletions(-) diff --git a/src/server/src/entities/contact/repo.ts b/src/server/src/entities/contact/repo.ts index 525f155..79f7d9d 100644 --- a/src/server/src/entities/contact/repo.ts +++ b/src/server/src/entities/contact/repo.ts @@ -131,6 +131,10 @@ export async function searchContacts( queryDigits.length > 0 ? `%${queryDigits}%` : null, ]; + // Force pg-node to type $4 as text — when queryDigits is empty the param is + // null and the driver can't infer the type, producing "could not determine + // data type of parameter $4". The CASE branches guard against null already. + const deviceClause = filter.deviceId ? (() => { params.push(filter.deviceId); @@ -152,7 +156,7 @@ export async function searchContacts( WHEN lower(c.display_name) LIKE $2 THEN 0.85 WHEN lower(c.display_name) LIKE $3 THEN 0.70 WHEN lower(c.handle) = lower($1) THEN 0.65 - WHEN $4 IS NOT NULL AND c.phone_numbers::text LIKE $4 THEN 0.55 + WHEN $4::text IS NOT NULL AND c.phone_numbers::text LIKE $4::text THEN 0.55 WHEN c.emails::text ILIKE $3 THEN 0.50 WHEN lower(c.handle) LIKE $3 THEN 0.40 ELSE 0 @@ -162,7 +166,7 @@ export async function searchContacts( WHEN lower(c.display_name) LIKE $2 THEN 'display_name' WHEN lower(c.display_name) LIKE $3 THEN 'display_name' WHEN lower(c.handle) = lower($1) THEN 'handle' - WHEN $4 IS NOT NULL AND c.phone_numbers::text LIKE $4 THEN 'phone' + WHEN $4::text IS NOT NULL AND c.phone_numbers::text LIKE $4::text THEN 'phone' WHEN c.emails::text ILIKE $3 THEN 'email' WHEN lower(c.handle) LIKE $3 THEN 'handle' ELSE 'handle' diff --git a/src/server/src/entities/message/repo.ts b/src/server/src/entities/message/repo.ts index 9f09656..ea1fe03 100644 --- a/src/server/src/entities/message/repo.ts +++ b/src/server/src/entities/message/repo.ts @@ -659,7 +659,7 @@ async function trigramFallback( filter: MessageSearchFilter, limit: number, ): Promise { - const conditions: string[] = ['(m.subject % $1 OR m.from_handle % $1)']; + const conditions: string[] = ['(m.subject % $1::text OR m.from_handle % $1::text)']; const params: unknown[] = [filter.query]; if (filter.conversationId) { @@ -688,8 +688,8 @@ async function trigramFallback( (SELECT array_agg(a.mime_type) FILTER (WHERE a.mime_type IS NOT NULL) FROM macsync.attachments a WHERE a.message_id = m.id) AS attachment_mime_types, GREATEST( - similarity(coalesce(m.subject, ''), $1), - similarity(coalesce(m.from_handle, ''), $1) + similarity(coalesce(m.subject, ''), $1::text), + similarity(coalesce(m.from_handle, ''), $1::text) ) AS sim FROM macsync.messages m ${where} diff --git a/web/src/api/messages.ts b/web/src/api/messages.ts index 5eaa088..3b5ecbf 100644 --- a/web/src/api/messages.ts +++ b/web/src/api/messages.ts @@ -20,9 +20,18 @@ export async function fetchThread(conversationId: string, limit = 100, since?: s } } +export type SearchProvider = 'imessage' | 'icloud' | 'all'; + export async function searchMessages( query: string, - opts: { conversationId?: string; limit?: number; mode?: SearchMode; since?: string; offset?: number } = {}, + opts: { + conversationId?: string; + limit?: number; + mode?: SearchMode; + since?: string; + offset?: number; + provider?: SearchProvider; + } = {}, ): Promise { try { const params = new URLSearchParams({ q: query }); @@ -31,6 +40,7 @@ export async function searchMessages( if (opts.mode) params.set('mode', opts.mode); if (opts.since) params.set('since', opts.since); if (opts.offset !== undefined && opts.offset > 0) params.set('offset', String(opts.offset)); + if (opts.provider && opts.provider !== 'all') params.set('provider', opts.provider); return await apiGet(`/my/messages/search?${params}`); } catch (err) { throw err instanceof Error ? err : new Error('searchMessages failed'); diff --git a/web/src/api/photos.ts b/web/src/api/photos.ts index 24aa929..4f8fb53 100644 --- a/web/src/api/photos.ts +++ b/web/src/api/photos.ts @@ -1,19 +1,11 @@ import { apiGet } from './client'; import type { Photo, Album } from '@/types'; -export interface PhotoFilters { - readonly limit?: number; - readonly since?: string; - readonly deviceId?: string; - readonly albumId?: string; -} - -export async function fetchPhotos(filters: PhotoFilters = {}): Promise { +export async function fetchPhotos(limit = 100, since?: string, deviceId?: string): Promise { try { - const params = new URLSearchParams(); - params.set('limit', String(filters.limit ?? 100)); - if (filters.since) params.set('since', filters.since); - if (filters.deviceId) params.set('deviceId', filters.deviceId); + const params = new URLSearchParams({ limit: String(limit) }); + if (since) params.set('since', since); + if (deviceId) params.set('deviceId', deviceId); return await apiGet(`/my/photos?${params}`); } catch (err) { throw err instanceof Error ? err : new Error('fetchPhotos failed'); @@ -27,24 +19,3 @@ export async function fetchAlbums(deviceId: string): Promise { throw err instanceof Error ? err : new Error('fetchAlbums failed'); } } - -export async function fetchPhotosByAlbum(albumId: string, limit = 200): Promise { - try { - const params = new URLSearchParams(); - params.set('limit', String(limit)); - return await apiGet(`/my/photos/albums/${encodeURIComponent(albumId)}?${params}`); - } catch (err) { - throw err instanceof Error ? err : new Error('fetchPhotosByAlbum failed'); - } -} - -export type PhotoBlobSize = 'original' | 'thumbnail' | 'preview'; - -/** - * Build a URL for the `/my/photos/:id/blob` endpoint with the requested size. - * Caller must prefix with the configured server URL (handled by when - * pointing at the same origin, or by api/client for fetches). - */ -export function photoBlobUrl(photoId: string, size: PhotoBlobSize = 'thumbnail'): string { - return `/my/photos/${encodeURIComponent(photoId)}/blob?size=${size}`; -} diff --git a/web/src/hooks/useMessages.ts b/web/src/hooks/useMessages.ts index 7c104f3..6d323f6 100644 --- a/web/src/hooks/useMessages.ts +++ b/web/src/hooks/useMessages.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useQuery, type UseQueryResult } from '@tanstack/react-query'; -import { fetchConversations, fetchThread, searchMessages } from '@/api/messages'; +import { fetchConversations, fetchThread, searchMessages, type SearchProvider } from '@/api/messages'; import type { Conversation, Message, MessageSearchHit, SearchMode } from '@/types'; import { getFromCache, saveToCache } from '@/lib/searchCache'; import { STALE_TIME } from './refreshConfig'; @@ -34,9 +34,10 @@ export interface MessageSearchState { export function useMessageSearch( query: string, - opts: { conversationId?: string; limit?: number; mode?: SearchMode } = {}, + opts: { conversationId?: string; limit?: number; mode?: SearchMode; provider?: SearchProvider } = {}, ): MessageSearchState { const mode: SearchMode = opts.mode ?? 'hybrid'; + const provider: SearchProvider = opts.provider ?? 'all'; const limit = opts.limit ?? 50; const trimmed = query.trim(); const enabled = trimmed.length >= 2; @@ -49,8 +50,8 @@ export function useMessageSearch( ); const [isLoadingMore, setIsLoadingMore] = useState(false); - // Reset accumulated state when query/mode/conversationId changes - const searchKey = `${trimmed}|${mode}|${opts.conversationId ?? ''}`; + // Reset accumulated state when query/mode/provider/conversationId changes + const searchKey = `${trimmed}|${mode}|${provider}|${opts.conversationId ?? ''}`; const searchKeyRef = useRef(searchKey); if (searchKeyRef.current !== searchKey) { searchKeyRef.current = searchKey; @@ -61,7 +62,7 @@ export function useMessageSearch( } const { data, isLoading, error } = useQuery({ - queryKey: ['messages', 'search', trimmed, mode, opts.conversationId ?? null], + queryKey: ['messages', 'search', trimmed, mode, provider, opts.conversationId ?? null], queryFn: async () => { const cached = getFromCache(trimmed, mode); // Hybrid always re-ranks the full corpus; lexical/semantic fetch incrementally @@ -70,6 +71,7 @@ export function useMessageSearch( ...(opts.conversationId ? { conversationId: opts.conversationId } : {}), limit, mode, + provider, ...(since ? { since } : {}), }); }, @@ -114,6 +116,7 @@ export function useMessageSearch( ...(opts.conversationId ? { conversationId: opts.conversationId } : {}), limit, mode, + provider, offset: hits.length, }); setHits((prev) => { @@ -133,7 +136,7 @@ export function useMessageSearch( } finally { setIsLoadingMore(false); } - }, [isLoadingMore, hasMore, hits.length, trimmed, mode, opts.conversationId, limit]); + }, [isLoadingMore, hasMore, hits.length, trimmed, mode, provider, opts.conversationId, limit]); return { hits, diff --git a/web/src/hooks/usePhotos.ts b/web/src/hooks/usePhotos.ts index 955021f..1aea1be 100644 --- a/web/src/hooks/usePhotos.ts +++ b/web/src/hooks/usePhotos.ts @@ -1,82 +1,12 @@ -import { useQuery, useMutation, useQueryClient, type UseQueryResult, type UseMutationResult } from '@tanstack/react-query'; - -import { - fetchAlbums, - fetchPhotos, - fetchPhotosByAlbum, - type PhotoFilters, -} from '@/api/photos'; -import { - fetchIdentities, - fetchIdentityPhotos, - mergeIdentities, - renameIdentity, -} from '@/api/identities'; -import type { Album, Identity, IdentityPhotoListItem, Photo } from '@/types'; - +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { fetchPhotos } from '@/api/photos'; +import type { Photo } from '@/types'; import { STALE_TIME } from './refreshConfig'; -export function usePhotos(filters: PhotoFilters = {}): UseQueryResult { +export function usePhotos(deviceId?: string): UseQueryResult { return useQuery({ - queryKey: ['photos', filters], - queryFn: () => fetchPhotos(filters), + queryKey: ['photos', deviceId], + queryFn: () => fetchPhotos(100, undefined, deviceId), staleTime: STALE_TIME, }); } - -export function useAlbums(deviceId: string | undefined): UseQueryResult { - return useQuery({ - queryKey: ['albums', deviceId], - queryFn: () => (deviceId ? fetchAlbums(deviceId) : Promise.resolve([] as Album[])), - enabled: Boolean(deviceId), - staleTime: STALE_TIME, - }); -} - -export function useAlbumPhotos(albumId: string | undefined, limit = 200): UseQueryResult { - return useQuery({ - queryKey: ['album-photos', albumId, limit], - queryFn: () => (albumId ? fetchPhotosByAlbum(albumId, limit) : Promise.resolve([] as Photo[])), - enabled: Boolean(albumId), - staleTime: STALE_TIME, - }); -} - -export function useIdentities(deviceId: string | undefined): UseQueryResult { - return useQuery({ - queryKey: ['identities', deviceId], - queryFn: () => (deviceId ? fetchIdentities(deviceId) : Promise.resolve([] as Identity[])), - enabled: Boolean(deviceId), - staleTime: STALE_TIME, - }); -} - -export function useIdentityPhotos(identityId: string | undefined, limit = 100): UseQueryResult { - return useQuery({ - queryKey: ['identity-photos', identityId, limit], - queryFn: () => (identityId ? fetchIdentityPhotos(identityId, limit) : Promise.resolve([] as IdentityPhotoListItem[])), - enabled: Boolean(identityId), - staleTime: STALE_TIME, - }); -} - -export function useRenameIdentity(): UseMutationResult { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, name }) => renameIdentity(id, name), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ['identities'] }); - }, - }); -} - -export function useMergeIdentities(): UseMutationResult { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ targetId, sourceId }) => mergeIdentities(targetId, sourceId), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ['identities'] }); - void qc.invalidateQueries({ queryKey: ['identity-photos'] }); - }, - }); -} diff --git a/web/src/tabs/Mail/index.tsx b/web/src/tabs/Mail/index.tsx index 45d85a1..c1537c3 100644 --- a/web/src/tabs/Mail/index.tsx +++ b/web/src/tabs/Mail/index.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState, type FormEvent, type ReactElement } from 'react'; +import { useEffect, useMemo, useState, type FormEvent, type ReactElement } from 'react'; import styled from '@lilith/ui-styled-components'; -import { Mail, Plus } from 'lucide-react'; +import { Mail, Plus, Search } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useSearchParams } from '@lilith/ui-router'; import { useMailConversations } from '@/hooks/useMail'; +import { useMessageSearch } from '@/hooks/useMessages'; import { sendMail, type SendMailInput } from '@/api/mail'; import { MailList } from './MailList'; import { MailThread } from './MailThread'; @@ -37,6 +38,85 @@ const Toolbar = styled.div` padding: 8px 20px; border-bottom: 1px solid #2a2a38; background: #0a0a0f; + display: flex; + gap: 8px; + align-items: center; +`; + +const SearchWrap = styled.div` + position: relative; + flex: 1; + max-width: 360px; +`; + +const SearchInput = styled.input` + width: 100%; + box-sizing: border-box; + padding: 6px 8px 6px 28px; + background: #18182a; + border: 1px solid #2a2a38; + color: #e0e0f0; + border-radius: 6px; + font-size: 13px; +`; + +const SearchIconWrap = styled.span` + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + color: #6a6a7a; + display: flex; + pointer-events: none; +`; + +const SearchResultsPanel = styled.div` + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 8px 0; +`; + +const Hit = styled.button` + text-align: left; + background: transparent; + border: none; + border-bottom: 1px solid #1a1a26; + padding: 10px 16px; + cursor: pointer; + color: inherit; + display: flex; + flex-direction: column; + gap: 4px; + &:hover { background: #14141e; } +`; + +const HitSubject = styled.div` + font-size: 13px; + font-weight: 600; + color: #e0e0f0; + mark { background: #4a4af0; color: #fff; padding: 0 2px; border-radius: 2px; } +`; + +const HitMeta = styled.div` + font-size: 11px; + color: #8a8a9a; +`; + +const HitSnippet = styled.div` + font-size: 12px; + color: #a0a0b0; + line-height: 1.3; + mark { background: #4a4af0; color: #fff; padding: 0 2px; border-radius: 2px; } +`; + +const FuzzyBadge = styled.span` + font-size: 10px; + color: #b8b800; + background: #2a2a18; + padding: 1px 6px; + border-radius: 8px; + margin-left: 6px; `; const PrimaryButton = styled.button` @@ -169,6 +249,43 @@ export function MailTab(): ReactElement { const [selectedId, setSelectedId] = useState(convParam); const [composing, setComposing] = useState(false); const [form, setForm] = useState(blankForm); + const [queryInput, setQueryInput] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const search = useMessageSearch(debouncedQuery, { provider: 'icloud', limit: 30 }); + const searching = debouncedQuery.trim().length >= 2; + + useEffect(() => { + const t = setTimeout(() => setDebouncedQuery(queryInput), 200); + return () => clearTimeout(t); + }, [queryInput]); + + // Subject/snippet markers from server are «…»; convert to React nodes. + // Returns an array of strings + elements. No HTML injection needed. + const renderHighlight = useMemo( + () => (raw: string | null | undefined): ReactElement[] => { + const text = raw ?? ''; + const parts: ReactElement[] = []; + let i = 0; + let key = 0; + while (i < text.length) { + const start = text.indexOf('«', i); + if (start === -1) { + parts.push({text.slice(i)}); + break; + } + if (start > i) parts.push({text.slice(i, start)}); + const end = text.indexOf('»', start + 1); + if (end === -1) { + parts.push({text.slice(start + 1)}); + break; + } + parts.push({text.slice(start + 1, end)}); + i = end + 1; + } + return parts; + }, + [], + ); useEffect(() => { if (convParam) setSelectedId(convParam); @@ -212,6 +329,16 @@ export function MailTab(): ReactElement { > Compose + + + setQueryInput(e.target.value)} + aria-label="Search mail" + /> + {composing && ( @@ -275,11 +402,46 @@ export function MailTab(): ReactElement { {!isLoading && !error && data?.length === 0 && ( } heading="No emails" sub="Email threads will appear here once sync runs." /> )} - {!isLoading && !error && data && data.length > 0 && ( - <> - {data.length} thread{data.length !== 1 ? 's' : ''} - - + {searching ? ( + + {search.isLoading && Searching…} + {search.error && } + {!search.isLoading && !search.error && search.hits.length === 0 && ( + No matches for "{debouncedQuery}" + )} + {!search.isLoading && search.hits.length > 0 && ( + <> + + {search.total} match{search.total !== 1 ? 'es' : ''} + + {search.hits.map((hit) => ( + setSelectedId(hit.message.conversationId)} + > + + {renderHighlight(hit.subjectSnippet ?? hit.message.subject ?? '(no subject)')} + + + {hit.message.fromHandle} + {' · '} + {new Date(hit.message.sentAt).toLocaleDateString()} + {hit.fuzzy && did you mean…} + + {renderHighlight(hit.snippet)} + + ))} + + )} + + ) : ( + !isLoading && !error && data && data.length > 0 && ( + <> + {data.length} thread{data.length !== 1 ? 's' : ''} + + + ) )} diff --git a/web/src/tabs/Photos/index.tsx b/web/src/tabs/Photos/index.tsx index 649376f..1503db3 100644 --- a/web/src/tabs/Photos/index.tsx +++ b/web/src/tabs/Photos/index.tsx @@ -1,16 +1,10 @@ -import { useMemo, useState, type ReactElement } from 'react'; +import { type ReactElement } from 'react'; import styled from '@lilith/ui-styled-components'; -import { Image, FolderOpen, Star, Users } from 'lucide-react'; - +import { Image } from 'lucide-react'; +import { usePhotos } from '@/hooks/usePhotos'; import { EmptyState } from '@/components/EmptyState'; import { PageError } from '@/components/PageError'; -import { usePhotos } from '@/hooks/usePhotos'; -import type { Album, Photo } from '@/types'; - -import { AlbumDetail } from './AlbumDetail'; -import { AlbumsView } from './AlbumsView'; -import { IdentitiesView } from './IdentitiesView'; -import { PhotoGrid } from './PhotoGrid'; +import type { Photo } from '@/types'; const Root = styled.div` display: flex; @@ -19,42 +13,14 @@ const Root = styled.div` overflow: hidden; `; -const PageHeading = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 20px; - border-bottom: 1px solid #2a2a38; - background: #0a0a0f; -`; - -const Title = styled.h1` +const PageHeading = styled.h1` margin: 0; + padding: 14px 20px; font-size: 18px; font-weight: 700; color: #e0e0f0; -`; - -const Tabs = styled.div` - display: flex; - gap: 4px; -`; - -const TabButton = styled.button<{ $active: boolean }>` - background: ${(p) => (p.$active ? '#1c1c26' : 'transparent')}; - color: ${(p) => (p.$active ? '#e0e0f0' : '#8a8a9a')}; - border: 1px solid ${(p) => (p.$active ? 'rgba(201,168,76,0.4)' : '#2a2a38')}; - border-radius: 4px; - padding: 4px 10px; - display: flex; - align-items: center; - gap: 4px; - font-size: 12px; - cursor: pointer; - - &:hover { - color: #e0e0f0; - } + border-bottom: 1px solid #2a2a38; + background: #0a0a0f; `; const Meta = styled.span` @@ -63,6 +29,77 @@ const Meta = styled.span` padding: 8px 20px 4px; `; +const Grid = styled.ul` + list-style: none; + margin: 0; + padding: 16px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; + overflow-y: auto; + flex: 1; +`; + +const Tile = styled.li` + aspect-ratio: 1; + background: #1c1c26; + border: 1px solid #2a2a38; + border-radius: 6px; + overflow: hidden; + display: flex; + flex-direction: column; + cursor: default; + transition: border-color 0.15s; + + &:hover { + border-color: rgba(201,168,76,0.4); + } +`; + +const TileImage = styled.img` + width: 100%; + flex: 1; + object-fit: cover; + min-height: 0; + display: block; + background: #13131a; +`; + +const TilePlaceholder = styled.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #3a3a4a; +`; + +const TileLabel = styled.span` + font-size: 10px; + color: #8a8a9a; + padding: 4px 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const VideoBadge = styled.span` + position: absolute; + top: 4px; + left: 4px; + background: rgba(0,0,0,0.6); + color: #fff; + font-size: 9px; + padding: 2px 5px; + border-radius: 3px; +`; + +const TileWrapper = styled.div` + position: relative; + flex: 1; + min-height: 0; + display: flex; +`; + const SpinnerWrap = styled.div` padding: 20px; font-size: 12px; @@ -70,85 +107,52 @@ const SpinnerWrap = styled.div` text-align: center; `; -type View = 'all' | 'albums' | 'identities' | 'favorites'; +function buildPhotoUrl(photo: Photo): string | null { + if (!photo.storageKey) return null; + return `/my/photos/${encodeURIComponent(photo.storageKey)}`; +} -function pickDeviceId(photos: readonly Photo[] | undefined): string | undefined { - return photos?.[0]?.deviceId; +function PhotoTile({ photo }: { photo: Photo }): ReactElement { + const url = buildPhotoUrl(photo); + const label = photo.filename || photo.externalId; + + return ( + + + {url ? ( + + ) : ( + + )} + {photo.mediaType === 'video' && video} + {photo.mediaType === 'live_photo' && live} + + {label} + + ); } export function PhotosTab(): ReactElement { - const [view, setView] = useState('all'); - const [openAlbum, setOpenAlbum] = useState(null); - - // Single anchor query to discover the deviceId. The other sub-views key off - // the same deviceId — single-device deployment, so this is fine. - const allPhotos = usePhotos({ limit: 500 }); - const deviceId = pickDeviceId(allPhotos.data); - - const favorites = useMemo( - () => (allPhotos.data ?? []).filter((p) => p.isFavorite), - [allPhotos.data], - ); - - const headerTabs: Array<{ id: View; icon: ReactElement; label: string }> = [ - { id: 'all', icon: , label: 'All' }, - { id: 'albums', icon: , label: 'Albums' }, - { id: 'identities', icon: , label: 'People' }, - { id: 'favorites', icon: , label: 'Favorites' }, - ]; + const { data, isLoading, error } = usePhotos(); return ( - - Photos - - {headerTabs.map((t) => ( - { - setView(t.id); - setOpenAlbum(null); - }} - > - {t.icon} {t.label} - - ))} - - - - {openAlbum ? ( - setOpenAlbum(null)} /> - ) : view === 'all' ? ( + Photos + {isLoading && Loading photos…} + {error && } + {!isLoading && !error && data?.length === 0 && ( + } heading="No photos" sub="Photos will appear here once sync runs." /> + )} + {!isLoading && !error && data && data.length > 0 && ( <> - {allPhotos.isLoading && Loading photos…} - {allPhotos.error && } - {!allPhotos.isLoading && !allPhotos.error && (allPhotos.data?.length ?? 0) === 0 && ( - } heading="No photos" sub="Photos will appear here once sync runs." /> - )} - {!allPhotos.isLoading && !allPhotos.error && allPhotos.data && allPhotos.data.length > 0 && ( - <> - - {allPhotos.data.length} photo{allPhotos.data.length !== 1 ? 's' : ''} - - - - )} - - ) : view === 'albums' ? ( - - ) : view === 'identities' ? ( - - ) : ( - <> - - {favorites.length} favorite{favorites.length !== 1 ? 's' : ''} - - {favorites.length === 0 ? ( - } heading="No favorites" sub="Stars in Photos.app show up here." /> - ) : ( - - )} + {data.length} photo{data.length !== 1 ? 's' : ''} + + {data.map((photo) => ( + + ))} + )} diff --git a/web/src/types.ts b/web/src/types.ts index 1fec167..442bc38 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -28,6 +28,12 @@ export interface Message { readonly deliveredAt: string | null; readonly readAt: string | null; readonly syncedAt: string; + // Mail-only fields (null/empty for iMessage) + readonly subject?: string | null; + readonly bodyPlain?: string | null; + readonly toHandles?: readonly string[]; + readonly ccHandles?: readonly string[]; + readonly folder?: string | null; } export type SearchMode = 'lexical' | 'semantic' | 'hybrid'; @@ -36,7 +42,10 @@ export interface MessageSearchHit { readonly message: Message; readonly rank: number; readonly snippet: string; + readonly subjectSnippet?: string | null; readonly mode: SearchMode; + readonly kind: 'message' | 'mail'; + readonly fuzzy?: boolean; } export interface MessageSearchResponse { @@ -116,8 +125,6 @@ export interface Contact { readonly summaryGeneratedAt: string | null; } -export type PhotoMediaKind = 'image' | 'video' | 'live_photo' | 'audio'; - export interface Photo { readonly id: string; readonly deviceId: string; @@ -125,33 +132,12 @@ export interface Photo { readonly externalId: string; readonly filename: string; readonly mediaType: string; - readonly mediaKind: PhotoMediaKind | null; - readonly mimeType: string | null; readonly width: number; readonly height: number; readonly duration: number | null; readonly takenAt: string | null; - readonly modifiedAt: string | null; - readonly importedAt: string; readonly storageBucket: string | null; readonly storageKey: string | null; - readonly thumbnailKey: string | null; - readonly previewKey: string | null; - readonly latitude: number | null; - readonly longitude: number | null; - readonly locationName: string | null; - readonly isFavorite: boolean; - readonly isHidden: boolean; - readonly isScreenshot: boolean; - readonly isSelfie: boolean; - readonly isBurst: boolean; - readonly burstIdentifier: string | null; - readonly exif: Readonly> | null; - readonly contentHash: string | null; - readonly processingStatus: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped'; - readonly category: string | null; - readonly semanticTags: readonly string[] | null; - readonly faceCount: number; readonly syncedAt: string; } @@ -159,29 +145,8 @@ export interface Album { readonly id: string; readonly deviceId: string; readonly externalId: string; - readonly name: string; + readonly title: string; readonly albumType: 'user' | 'smart' | 'shared' | 'system'; readonly photoCount: number; - readonly startDate: string | null; - readonly endDate: string | null; - readonly coverPhotoId: string | null; - readonly sortOrder: number; readonly syncedAt: string; } - -export interface Identity { - readonly id: string; - readonly deviceId: string; - readonly name: string | null; - readonly faceCount: number; - readonly centroidUpdatedAt: string | null; - readonly createdAt: string; - readonly updatedAt: string; -} - -export interface IdentityPhotoListItem { - readonly id: string; - readonly thumbnailKey: string | null; - readonly previewKey: string | null; - readonly takenAt: string | null; -}