Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views. - Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts. - Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface. - Added loading and error states in ContactsListView for better user feedback during data fetching. - Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list. - Updated package.json to include @formkit/auto-animate for improved UI animations.
288 lines
8.2 KiB
TypeScript
288 lines
8.2 KiB
TypeScript
import type { Email } from "@/lib/email-data"
|
|
import type { FullContact } from "@/lib/contacts/types"
|
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
|
import {
|
|
fieldMatchScore,
|
|
normalizeContactSearchText,
|
|
} from "@/lib/contacts/contact-match-score"
|
|
import type { SearchParams } from "./search-params"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Suggestion types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type ContactSuggestion = {
|
|
kind: "contact"
|
|
contact: FullContact
|
|
email: string
|
|
displayName: string
|
|
/** Score 0-1 (higher = better match). */
|
|
score: number
|
|
}
|
|
|
|
export type EmailSuggestion = {
|
|
kind: "email"
|
|
email: string
|
|
displayName: string
|
|
score: number
|
|
}
|
|
|
|
export type SearchSuggestion = ContactSuggestion | EmailSuggestion
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prefix matching helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function normalize(s: string): string {
|
|
return normalizeContactSearchText(s)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// matchContacts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function matchContacts(
|
|
query: string,
|
|
contacts: FullContact[],
|
|
limit = 5
|
|
): ContactSuggestion[] {
|
|
if (!query.trim()) return []
|
|
const results: ContactSuggestion[] = []
|
|
for (const c of contacts) {
|
|
const fullName = fullContactDisplayName(c)
|
|
let bestScore = 0
|
|
let bestEmail = c.emails[0]?.value ?? ""
|
|
|
|
bestScore = Math.max(bestScore, fieldMatchScore(fullName, query))
|
|
bestScore = Math.max(bestScore, fieldMatchScore(c.firstName, query))
|
|
bestScore = Math.max(bestScore, fieldMatchScore(c.lastName, query))
|
|
|
|
for (const e of c.emails) {
|
|
const s = fieldMatchScore(e.value, query)
|
|
if (s > bestScore) {
|
|
bestScore = s
|
|
bestEmail = e.value
|
|
}
|
|
}
|
|
|
|
if (bestScore > 0) {
|
|
results.push({
|
|
kind: "contact",
|
|
contact: c,
|
|
email: bestEmail,
|
|
displayName: fullName,
|
|
score: bestScore,
|
|
})
|
|
}
|
|
}
|
|
results.sort((a, b) => b.score - a.score)
|
|
return results.slice(0, limit)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// matchEmails (unique sender emails from email data)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function matchEmails(
|
|
query: string,
|
|
emails: Email[],
|
|
limit = 5
|
|
): EmailSuggestion[] {
|
|
if (!query.trim()) return []
|
|
const seen = new Map<string, { name: string; score: number }>()
|
|
|
|
for (const e of emails) {
|
|
if (!e.senderEmail) continue
|
|
const addr = e.senderEmail.toLowerCase()
|
|
const name = e.sender
|
|
const s1 = fieldMatchScore(addr, query)
|
|
const s2 = fieldMatchScore(name, query)
|
|
const s = Math.max(s1, s2)
|
|
if (s > 0) {
|
|
const existing = seen.get(addr)
|
|
if (!existing || s > existing.score) {
|
|
seen.set(addr, { name, score: s })
|
|
}
|
|
}
|
|
}
|
|
|
|
const results: EmailSuggestion[] = []
|
|
for (const [addr, { name, score }] of seen) {
|
|
results.push({ kind: "email", email: addr, displayName: name, score })
|
|
}
|
|
results.sort((a, b) => b.score - a.score)
|
|
return results.slice(0, limit)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ghost text (best completion for the input)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function bestCompletion(
|
|
query: string,
|
|
suggestions: SearchSuggestion[]
|
|
): string {
|
|
if (!query.trim() || suggestions.length === 0) return ""
|
|
const top = suggestions[0]!
|
|
const q = normalize(query)
|
|
const candidates =
|
|
top.kind === "contact"
|
|
? [top.email, top.displayName]
|
|
: [top.email, top.displayName]
|
|
|
|
for (const c of candidates) {
|
|
const cn = normalize(c)
|
|
if (cn.startsWith(q)) {
|
|
return c.slice(query.length)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// filterEmailsBySearchParams
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function withinToMs(within: string): number {
|
|
const map: Record<string, number> = {
|
|
"1d": 86_400_000,
|
|
"3d": 3 * 86_400_000,
|
|
"1w": 7 * 86_400_000,
|
|
"2w": 14 * 86_400_000,
|
|
"1m": 30 * 86_400_000,
|
|
"2m": 60 * 86_400_000,
|
|
"6m": 180 * 86_400_000,
|
|
"1y": 365 * 86_400_000,
|
|
}
|
|
return map[within] ?? 0
|
|
}
|
|
|
|
function textMatchesEmail(email: Email, text: string): boolean {
|
|
const t = normalize(text)
|
|
if (!t) return true
|
|
|
|
const fields = [
|
|
email.subject,
|
|
email.preview,
|
|
email.sender,
|
|
email.senderEmail ?? "",
|
|
email.body ?? "",
|
|
]
|
|
for (const f of fields) {
|
|
if (normalize(f).includes(t)) return true
|
|
}
|
|
|
|
if (email.conversation) {
|
|
for (const msg of email.conversation) {
|
|
if (normalize(msg.sender).includes(t)) return true
|
|
if (normalize(msg.senderEmail).includes(t)) return true
|
|
if (normalize(msg.preview).includes(t)) return true
|
|
if (normalize(msg.body).includes(t)) return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export function filterEmailsBySearchParams(
|
|
emails: Email[],
|
|
params: SearchParams,
|
|
opts?: {
|
|
starredIds?: string[]
|
|
importantIds?: string[]
|
|
}
|
|
): Email[] {
|
|
const now = Date.now()
|
|
return emails.filter((email) => {
|
|
if (params.in === "all" && email.spam) return false
|
|
if (params.in === "inbox" && !email.labels?.includes("inbox")) return false
|
|
if (params.in === "sent" && !email.labels?.includes("sent")) return false
|
|
if (params.in === "drafts" && !email.labels?.includes("drafts")) return false
|
|
if (params.in === "spam" && !email.spam) return false
|
|
if (params.in === "trash" && !email.deleted) return false
|
|
if (params.in === "starred") {
|
|
const isStarred =
|
|
email.starred || (opts?.starredIds?.includes(email.id) ?? false)
|
|
if (!isStarred) return false
|
|
}
|
|
|
|
if (params.q && !textMatchesEmail(email, params.q)) return false
|
|
|
|
if (params.hasWords && !textMatchesEmail(email, params.hasWords))
|
|
return false
|
|
|
|
if (params.doesNotHave) {
|
|
if (textMatchesEmail(email, params.doesNotHave)) return false
|
|
}
|
|
|
|
if (params.from) {
|
|
const f = normalize(params.from)
|
|
const senderMatch =
|
|
normalize(email.sender).includes(f) ||
|
|
normalize(email.senderEmail ?? "").includes(f)
|
|
if (!senderMatch) return false
|
|
}
|
|
|
|
if (params.to) {
|
|
const t = normalize(params.to)
|
|
let found = false
|
|
if (email.conversation) {
|
|
for (const msg of email.conversation) {
|
|
if (
|
|
normalize(msg.sender).includes(t) ||
|
|
normalize(msg.senderEmail).includes(t)
|
|
)
|
|
found = true
|
|
}
|
|
}
|
|
if (
|
|
normalize(email.sender).includes(t) ||
|
|
normalize(email.senderEmail ?? "").includes(t)
|
|
)
|
|
found = true
|
|
if (!found) return false
|
|
}
|
|
|
|
if (params.subject) {
|
|
if (!normalize(email.subject).includes(normalize(params.subject)))
|
|
return false
|
|
}
|
|
|
|
if (params.has.includes("attachment") && !email.hasAttachment) return false
|
|
|
|
if (params.within) {
|
|
const ms = withinToMs(params.within)
|
|
if (ms > 0) {
|
|
const emailDate = new Date(email.date).getTime()
|
|
if (now - emailDate > ms) return false
|
|
}
|
|
}
|
|
|
|
if (params.after) {
|
|
const afterDate = new Date(params.after).getTime()
|
|
if (Number.isFinite(afterDate) && new Date(email.date).getTime() < afterDate)
|
|
return false
|
|
}
|
|
|
|
if (params.before) {
|
|
const beforeDate = new Date(params.before).getTime()
|
|
if (Number.isFinite(beforeDate) && new Date(email.date).getTime() > beforeDate)
|
|
return false
|
|
}
|
|
|
|
if (params.size) {
|
|
const sizeNum = parseFloat(params.size)
|
|
if (Number.isFinite(sizeNum) && sizeNum > 0) {
|
|
const multiplier = params.sizeUnit === "Mo" ? 1_048_576 : 1024
|
|
const thresholdBytes = sizeNum * multiplier
|
|
const totalSize =
|
|
email.attachments?.reduce((a, att) => a + (att.sizeBytes ?? 0), 0) ?? 0
|
|
if (params.sizeOp === "gt" && totalSize <= thresholdBytes) return false
|
|
if (params.sizeOp === "lt" && totalSize >= thresholdBytes) return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
})
|
|
}
|