feat(photos): improve search weighting and type safety

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-19 00:04:30 -07:00
parent 9a9040cad5
commit c2c7870915
9 changed files with 335 additions and 286 deletions

View file

@ -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'

View file

@ -659,7 +659,7 @@ async function trigramFallback(
filter: MessageSearchFilter,
limit: number,
): Promise<SearchResult> {
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}

View file

@ -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<MessageSearchResponse> {
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<MessageSearchResponse>(`/my/messages/search?${params}`);
} catch (err) {
throw err instanceof Error ? err : new Error('searchMessages failed');

View file

@ -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<Photo[]> {
export async function fetchPhotos(limit = 100, since?: string, deviceId?: string): Promise<Photo[]> {
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<Photo[]>(`/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<Album[]> {
throw err instanceof Error ? err : new Error('fetchAlbums failed');
}
}
export async function fetchPhotosByAlbum(albumId: string, limit = 200): Promise<Photo[]> {
try {
const params = new URLSearchParams();
params.set('limit', String(limit));
return await apiGet<Photo[]>(`/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 <img> 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}`;
}

View file

@ -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,

View file

@ -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<Photo[]> {
export function usePhotos(deviceId?: string): UseQueryResult<Photo[]> {
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<Album[]> {
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<Photo[]> {
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<Identity[]> {
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<IdentityPhotoListItem[]> {
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<void, Error, { id: string; name: string | null }> {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, name }) => renameIdentity(id, name),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['identities'] });
},
});
}
export function useMergeIdentities(): UseMutationResult<void, Error, { targetId: string; sourceId: string }> {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ targetId, sourceId }) => mergeIdentities(targetId, sourceId),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['identities'] });
void qc.invalidateQueries({ queryKey: ['identity-photos'] });
},
});
}

View file

@ -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<string | null>(convParam);
const [composing, setComposing] = useState(false);
const [form, setForm] = useState<ComposeState>(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 <mark> nodes.
// Returns an array of strings + <mark> 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(<span key={key++}>{text.slice(i)}</span>);
break;
}
if (start > i) parts.push(<span key={key++}>{text.slice(i, start)}</span>);
const end = text.indexOf('»', start + 1);
if (end === -1) {
parts.push(<span key={key++}>{text.slice(start + 1)}</span>);
break;
}
parts.push(<mark key={key++}>{text.slice(start + 1, end)}</mark>);
i = end + 1;
}
return parts;
},
[],
);
useEffect(() => {
if (convParam) setSelectedId(convParam);
@ -212,6 +329,16 @@ export function MailTab(): ReactElement {
>
<Plus size={13} /> Compose
</PrimaryButton>
<SearchWrap>
<SearchIconWrap><Search size={14} /></SearchIconWrap>
<SearchInput
type="search"
placeholder="Search subject, sender, body…"
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
aria-label="Search mail"
/>
</SearchWrap>
</Toolbar>
{composing && (
@ -275,11 +402,46 @@ export function MailTab(): ReactElement {
{!isLoading && !error && data?.length === 0 && (
<EmptyState icon={<Mail size={28} />} heading="No emails" sub="Email threads will appear here once sync runs." />
)}
{!isLoading && !error && data && data.length > 0 && (
<>
<ThreadCount>{data.length} thread{data.length !== 1 ? 's' : ''}</ThreadCount>
<MailList conversations={data} selectedId={selectedId} onSelect={setSelectedId} />
</>
{searching ? (
<SearchResultsPanel>
{search.isLoading && <SpinnerWrap aria-live="polite">Searching</SpinnerWrap>}
{search.error && <PageError message={String(search.error.message)} />}
{!search.isLoading && !search.error && search.hits.length === 0 && (
<ThreadCount>No matches for "{debouncedQuery}"</ThreadCount>
)}
{!search.isLoading && search.hits.length > 0 && (
<>
<ThreadCount>
{search.total} match{search.total !== 1 ? 'es' : ''}
</ThreadCount>
{search.hits.map((hit) => (
<Hit
key={hit.message.id}
type="button"
onClick={() => setSelectedId(hit.message.conversationId)}
>
<HitSubject>
{renderHighlight(hit.subjectSnippet ?? hit.message.subject ?? '(no subject)')}
</HitSubject>
<HitMeta>
{hit.message.fromHandle}
{' · '}
{new Date(hit.message.sentAt).toLocaleDateString()}
{hit.fuzzy && <FuzzyBadge>did you mean</FuzzyBadge>}
</HitMeta>
<HitSnippet>{renderHighlight(hit.snippet)}</HitSnippet>
</Hit>
))}
</>
)}
</SearchResultsPanel>
) : (
!isLoading && !error && data && data.length > 0 && (
<>
<ThreadCount>{data.length} thread{data.length !== 1 ? 's' : ''}</ThreadCount>
<MailList conversations={data} selectedId={selectedId} onSelect={setSelectedId} />
</>
)
)}
</SidePanel>
<Content>

View file

@ -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 (
<Tile aria-label={label}>
<TileWrapper>
{url ? (
<TileImage src={url} alt={label} loading="lazy" />
) : (
<TilePlaceholder aria-hidden="true">
<Image size={24} />
</TilePlaceholder>
)}
{photo.mediaType === 'video' && <VideoBadge aria-label="Video">video</VideoBadge>}
{photo.mediaType === 'live_photo' && <VideoBadge aria-label="Live photo">live</VideoBadge>}
</TileWrapper>
<TileLabel title={label}>{label}</TileLabel>
</Tile>
);
}
export function PhotosTab(): ReactElement {
const [view, setView] = useState<View>('all');
const [openAlbum, setOpenAlbum] = useState<Album | null>(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: <Image size={14} />, label: 'All' },
{ id: 'albums', icon: <FolderOpen size={14} />, label: 'Albums' },
{ id: 'identities', icon: <Users size={14} />, label: 'People' },
{ id: 'favorites', icon: <Star size={14} />, label: 'Favorites' },
];
const { data, isLoading, error } = usePhotos();
return (
<Root>
<PageHeading>
<Title>Photos</Title>
<Tabs>
{headerTabs.map((t) => (
<TabButton
key={t.id}
$active={view === t.id && !openAlbum}
onClick={() => {
setView(t.id);
setOpenAlbum(null);
}}
>
{t.icon} {t.label}
</TabButton>
))}
</Tabs>
</PageHeading>
{openAlbum ? (
<AlbumDetail album={openAlbum} onBack={() => setOpenAlbum(null)} />
) : view === 'all' ? (
<PageHeading>Photos</PageHeading>
{isLoading && <SpinnerWrap aria-live="polite">Loading photos</SpinnerWrap>}
{error && <PageError message="Failed to load photos" />}
{!isLoading && !error && data?.length === 0 && (
<EmptyState icon={<Image size={36} />} heading="No photos" sub="Photos will appear here once sync runs." />
)}
{!isLoading && !error && data && data.length > 0 && (
<>
{allPhotos.isLoading && <SpinnerWrap aria-live="polite">Loading photos</SpinnerWrap>}
{allPhotos.error && <PageError message="Failed to load photos" />}
{!allPhotos.isLoading && !allPhotos.error && (allPhotos.data?.length ?? 0) === 0 && (
<EmptyState icon={<Image size={36} />} heading="No photos" sub="Photos will appear here once sync runs." />
)}
{!allPhotos.isLoading && !allPhotos.error && allPhotos.data && allPhotos.data.length > 0 && (
<>
<Meta>
{allPhotos.data.length} photo{allPhotos.data.length !== 1 ? 's' : ''}
</Meta>
<PhotoGrid photos={allPhotos.data} />
</>
)}
</>
) : view === 'albums' ? (
<AlbumsView deviceId={deviceId} onAlbumClick={setOpenAlbum} />
) : view === 'identities' ? (
<IdentitiesView deviceId={deviceId} />
) : (
<>
<Meta>
{favorites.length} favorite{favorites.length !== 1 ? 's' : ''}
</Meta>
{favorites.length === 0 ? (
<EmptyState icon={<Star size={36} />} heading="No favorites" sub="Stars in Photos.app show up here." />
) : (
<PhotoGrid photos={favorites} ariaLabel="Favorite photos" />
)}
<Meta>{data.length} photo{data.length !== 1 ? 's' : ''}</Meta>
<Grid aria-label="Photo grid">
{data.map((photo) => (
<PhotoTile key={photo.id} photo={photo} />
))}
</Grid>
</>
)}
</Root>

View file

@ -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<Record<string, unknown>> | 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;
}