feat(frontend): 주소록 화면 · 메일 연동

레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 —
과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다.
디자인 README "8단계", 계약 3절.

리뷰로 고친 것:
- 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다.
- [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만.
- [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다.
- 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다.
- 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId).
- 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다).
- 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:13:20 +09:00
parent 08cf31fbe6
commit e2c41d38bb
65 changed files with 11130 additions and 214 deletions
+2
View File
@@ -74,6 +74,8 @@ describe('에러 코드 사전', () => {
// 웹메일 6단계
'MAIL_012', // 그사이 다른 곳에서 필터가 바뀜 — 필터 화면이 알림 없이 목록 위 줄 · 고치기 창 안에 사전 문구를 직접 꺼내 보이고 [다시 불러오기]를 둔다
'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다
// 웹메일 8단계 (주소록)
'CONTACT_001', // 같은 이름의 그룹 — 새 그룹 · 이름 바꾸기 창이 알림 없이 칸 아래에 보인다
]
for (const code of usedByClient) {
@@ -0,0 +1,254 @@
import { nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { toApiError } from './useApi'
import { useContacts } from './useContacts'
import { useToast } from './useToast'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { CONTACTS_TOAST_TEXT } from '@/shared/constants/contacts'
import type {
Contact,
ContactGroup,
ContactGroupDeleted,
ContactGroupMembersAdded,
ContactGroupMembersPayload,
ContactInput,
ContactPatch,
} from '@/shared/types/contacts'
import type { Address } from '@/shared/types/mail'
import { copyText } from '@/shared/utils/clipboard'
import { composePrefillState } from '@/shared/utils/compose-prefill'
import { contactDisplayName, groupRecipientsOf } from '@/shared/utils/contacts'
import { contactsListLocation, contactsRouteStateOf } from '@/shared/utils/contacts-route'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
// 주소록에서 하는 일과 그 알림 (디자인 README "8단계 › 상태 · 알림" · 디자인 원본 ContactsEdit "알림")
// - 저장 "연락처를 저장했습니다." · 지우기는 확인 없이 하고 "‘박지훈’ 연락처를 지웠습니다." + [되돌리기](10초 — 띄운 세션에서만 움직인다, useToast).
// 되돌리기는 지우기 응답의 표로(POST /contact-restorations — 서버가 지운 그대로 다시 만든다, 새 id) → "연락처를 되돌렸습니다".
// 표가 없으면(서버가 기억하지 못함 — null) [되돌리기] 없이 알린다. 되돌리기의 실패(표가 지남 · 같은 이름 · 한도 · 장애)는 인터셉터가 알린다
// - 그룹 지우기 "‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다." + [되돌리기](같은 표 — 그때의 이름 · 지금도 있는 구성원으로)
// - [고치기]는 그 연락처를 새로 받아 창을 연다(loadForEdit — 창을 연 사이 다른 곳에서 바꾼 칸을 덮지 않게, 저장은 바꾼 칸만)
// - 구성원 넣기 "2명을 ‘거래처’ 그룹에 넣었습니다."
// - 이미 지워진 것(다른 곳에서 지움 — 404 RES_001)을 지우면 알리지 않고 지운 것으로 본다(메일함 지우기와 같다)
// - 칸 안에 보일 거절(연락처 VAL_001 · 그룹 이름 CONTACT_001 · VAL_001)은 알림을 끄고 그대로 던진다 — 창이 칸 아래에 보인다.
// 한도(CONTACT_002) · 엔진 장애(MAIL_001) 등은 인터셉터가 사전 문구로 알린다
// - 보던 연락처 · 그룹을 지우면 그 목록으로 주소를 바꾸고(기록을 쌓지 않는다), 누른 단추가 사라져 초점을 잃었으면 목록 칸으로 옮긴다
// - [메일 쓰기]는 받는 사람을 채운 새 쓰기 화면 — 주소는 URL 이 아니라 라우터 상태로 (compose-prefill.ts)
/** 연락처 창이 칸 아래에 보이는 거절 */
export const CONTACT_SAVE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001']
/** 그룹 이름 창이 칸 아래에 보이는 거절 — 같은 이름 · 형식 */
export const CONTACT_GROUP_NAME_INLINE_CODES: readonly ErrorCode[] = ['CONTACT_001', 'VAL_001']
/** 목록 칸 (ContactsList 의 data-contacts-list) — 지운 뒤 초점을 잃으면 여기로 */
const CONTACTS_LIST_SELECTOR = '[data-contacts-list]'
function focusIsLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
export function useContactActions() {
const store = useContactsStore()
const contactsApi = useContacts()
const authStore = useAuthStore()
const router = useRouter()
const toast = useToast()
/** 누른 단추가 사라져 초점을 잃었으면 목록 칸으로 — 다른 곳으로 옮긴 초점은 빼앗지 않는다 */
async function recoverFocus(): Promise<void> {
await nextTick()
if (focusIsLost()) document.querySelector<HTMLElement>(CONTACTS_LIST_SELECTOR)?.focus({ preventScroll: true })
}
/** 지금 주소록 화면이 그 연락처 · 그 그룹을 보고 있으면 그 목록(고른 것 없이)으로 바꿔 끼운다 */
async function leaveRemoved(target: { contactId?: string; groupId?: string }): Promise<void> {
const state = contactsRouteStateOf(router.currentRoute.value)
if (!state) return
if (target.groupId && state.source.kind === 'group' && state.source.groupId === target.groupId) {
await router.replace(contactsListLocation({ kind: 'all' }))
} else if (target.contactId && state.selectedId === target.contactId && state.source.kind !== 'directory') {
await router.replace(contactsListLocation(state.source, state.q))
}
}
// ── 연락처 ─────────────────────────────────────────
/** 새 연락처 저장 — 세션이 바뀌었으면 null (알리지 않는다). 실패는 그대로 던진다 (VAL_001 은 알림을 끈다 — 창이 보인다) */
async function createContact(input: ContactInput): Promise<Contact | null> {
const saved = await store.createContact(input, { silent: CONTACT_SAVE_INLINE_CODES })
if (saved) toast.success(CONTACTS_TOAST_TEXT.saved)
return saved
}
/** 고친 연락처 저장 — 바꾼 칸만(patch). 세션이 바뀌었으면 null. 실패는 그대로 던진다 */
async function updateContact(contact: Contact, patch: ContactPatch): Promise<Contact | null> {
const saved = await store.updateContact(contact.id, patch, { silent: CONTACT_SAVE_INLINE_CODES })
if (saved) toast.success(CONTACTS_TOAST_TEXT.saved)
return saved
}
/**
* [고치기] — 창에 채울 그 연락처를 새로 받는다(받는 동안 store.editLoadingId). 받으면 목록 · 상세도 그 값으로 고친다.
* 못 받으면 null — 창을 열지 않는다: 없는 연락처(RES_001 — 알림 없이)는 목록에서 빼 상세가 "찾을 수 없음" + [목록으로]를 그리고,
* 그 밖의 실패는 인터셉터가 알린다. 기다리는 사이 세션이 바뀌었어도 null
*/
async function loadForEdit(contact: Contact): Promise<Contact | null> {
if (store.editLoadingId === contact.id) return null
const session = authStore.generation
store.editLoadingId = contact.id
try {
const fresh = await contactsApi.getContact(contact.id, { silent: ['RES_001'] })
if (authStore.generation !== session) return null
store.replaceContact(fresh)
return fresh
} catch (caught) {
if (authStore.generation === session && toApiError(caught).code === 'RES_001') store.forgetContact(contact.id)
return null
} finally {
if (store.editLoadingId === contact.id) store.editLoadingId = null
}
}
/** [되돌리기] — 표로 다시 만든다 (memberIds: 그룹을 지울 때의 구성원). 실패는 인터셉터가 알렸다 */
async function restore(token: string, memberIds: readonly string[] = []): Promise<void> {
try {
const restored = await store.restore(token, memberIds)
if (!restored) return
toast.success(restored.kind === 'contact' ? CONTACTS_TOAST_TEXT.restored : CONTACTS_TOAST_TEXT.groupRestored)
} catch {
// 표가 지남 · 이미 씀(RES_001), 같은 이름의 그룹(CONTACT_001), 한도(CONTACT_002), 장애 — 인터셉터가 사전 문구로 알렸다
}
}
/** 표가 있으면 [되돌리기] 단추 (10초 — 띄운 세션에서만), 없으면 단추 없이 */
function undoAction(token: string | null, memberIds: readonly string[] = []) {
return token ? { label: CONTACTS_TOAST_TEXT.undo, run: () => void restore(token, memberIds) } : undefined
}
/** 지우기 — 확인 없이 하고 [되돌리기]를 준다. 지웠으면(이미 없었던 것 포함) true */
async function removeContact(contact: Contact): Promise<boolean> {
let deleted: Awaited<ReturnType<typeof store.deleteContact>>
try {
deleted = await store.deleteContact(contact.id, { silent: ['RES_001'] })
} catch (caught) {
if (toApiError(caught).code !== 'RES_001') return false
store.forgetContact(contact.id)
await leaveRemoved({ contactId: contact.id })
await recoverFocus()
return true
}
if (!deleted) return false
toast.success(CONTACTS_TOAST_TEXT.removed(contactDisplayName(deleted.contact)), undoAction(deleted.restoreToken))
await leaveRemoved({ contactId: contact.id })
await recoverFocus()
return true
}
// ── 그룹 ──────────────────────────────────────────
/** 새 그룹 — 같은 이름(CONTACT_001) · 형식(VAL_001)은 알림을 끄고 던진다 */
async function createGroup(name: string): Promise<ContactGroup | null> {
const created = await store.createGroup(name, { silent: CONTACT_GROUP_NAME_INLINE_CODES })
if (created) toast.success(CONTACTS_TOAST_TEXT.groupCreated(created.name))
return created
}
async function renameGroup(group: ContactGroup, name: string): Promise<ContactGroup | null> {
const renamed = await store.renameGroup(group.id, name, { silent: CONTACT_GROUP_NAME_INLINE_CODES })
if (renamed) toast.success(CONTACTS_TOAST_TEXT.groupRenamed)
return renamed
}
/** 그룹 지우기 — 확인 없이 하고 [되돌리기]를 준다. 연락처는 그대로 있다 */
async function removeGroup(group: ContactGroup): Promise<boolean> {
let deleted: ContactGroupDeleted | null
try {
deleted = await store.deleteGroup(group.id, { silent: ['RES_001'] })
} catch (caught) {
if (toApiError(caught).code !== 'RES_001') return false
store.forgetGroup(group.id)
await leaveRemoved({ groupId: group.id })
await recoverFocus()
return true
}
if (!deleted) return false
const count = deleted.memberIds.length > 0 ? formatCount(deleted.memberIds.length) : null
toast.success(
CONTACTS_TOAST_TEXT.groupRemoved(deleted.name, count),
undoAction(deleted.restoreToken, deleted.memberIds),
)
await leaveRemoved({ groupId: group.id })
await recoverFocus()
return true
}
/**
* 구성원 넣기 — "2명을 ‘거래처’ 그룹에 넣었습니다." 수는 응답의 addedCount(고른 수와 다를 수 있다 — 서버가 이미 구성원 ·
* 그룹에 넣을 수 없는 카드를 건너뛴다). 실패는 인터셉터가 알리고 그대로 던진다
*/
async function addMembers(group: ContactGroup, payload: ContactGroupMembersPayload): Promise<ContactGroupMembersAdded | null> {
const added = await store.addMembers(group.id, payload)
if (!added) return null
toast.success(
added.addedCount > 0
? CONTACTS_TOAST_TEXT.membersAdded(formatCount(added.addedCount), added.group.name)
: CONTACTS_TOAST_TEXT.membersNone(added.group.name),
)
return added
}
// ── 메일 쓰기 · 주소 복사 ──────────────────────────────
/** 받는 사람을 채운 새 쓰기 화면 — 쓰다 만 메일은 쓰기 화면의 임시 저장 규칙대로 남는다. 상태에 연 직원을 싣는다(compose-prefill.ts) */
async function composeTo(addresses: readonly Address[]): Promise<void> {
await router.push({ name: 'mail-compose', state: composePrefillState(addresses, authStore.user?.email ?? '') })
}
/**
* [그룹에 메일 쓰기] — 구성원의 대표 주소로 (이메일이 없는 구성원은 뺀다). 받는 동안은 store.composingGroupId(단추의 바쁜 표시 — 거듭 누르면 무시).
* 받지 못하면 인터셉터가 알린다. 받는 사이 다른 화면 · 목록으로 갔거나 세션이 바뀌었으면 쓰기 화면으로 끌고 가지 않는다
*/
async function composeToGroup(group: ContactGroup): Promise<void> {
if (store.composingGroupId !== null) return
const session = authStore.generation
const startedAt = router.currentRoute.value.fullPath
store.composingGroupId = group.id
let members: Contact[]
try {
members = await contactsApi.listGroupMembers(group.id)
} catch {
return
} finally {
if (store.composingGroupId === group.id) store.composingGroupId = null
}
if (authStore.generation !== session || router.currentRoute.value.fullPath !== startedAt) return
const { addresses } = groupRecipientsOf(members)
if (addresses.length === 0) {
toast.error(CONTACTS_TOAST_TEXT.groupNoAddress(group.name))
return
}
await composeTo(addresses)
}
/** 주소 복사 — 결과를 알린다 (브라우저가 막으면 실패 알림) */
async function copyAddress(address: string): Promise<void> {
if (await copyText(address)) toast.success(CONTACTS_TOAST_TEXT.copied)
else toast.error(CONTACTS_TOAST_TEXT.copyFailed)
}
return {
createContact,
updateContact,
loadForEdit,
removeContact,
createGroup,
renameGroup,
removeGroup,
addMembers,
composeTo,
composeToGroup,
copyAddress,
}
}
@@ -0,0 +1,82 @@
import { computed, ref, type ComputedRef } from 'vue'
import { withoutKey, type ContactEntryWarning } from '@/components/contacts/contacts-ui'
import { CONTACT_DIALOG_TEXT } from '@/shared/constants/contacts'
import { duplicateWho, type ContactEmailRow } from '@/shared/utils/contacts'
import { isValidEmail, normalizeEmail } from '@/shared/utils/mail-address'
import { useContactsStore } from '@/stores/contacts.store'
// 연락처 창의 같은 주소 안내 (디자인 원본 ContactsEdit · README "8단계 › 연락처 추가 · 고치기") — 막지 않는 갈색 글 + [그 연락처 보기]
// - 이메일 칸을 떠날 때(check) 내 주소록에 같은 주소(다른 연락처 — 대소문자 무시)가 있는지 알림 없이 찾는다(GET /contacts?q=<주소>)
// - 칸을 고치면(forget) 그 줄의 안내를 내리고 기다리던 찾기를 버린다 — 늦게 온 결과가 옛 주소의 안내를 다시 띄우지 않게
// - 창을 다시 열면(reset) 모두 버린다. 찾지 못했으면(실패 · 세션이 바뀜) 안내 없이 둔다
// 안내는 늦게 뜨므로 그리는 쪽(ContactEntryRows)이 늘 있는 알림 영역 안에 그린다
export interface ContactDuplicateCheckOptions {
/** 지금 창의 이메일 줄 */
rows: () => readonly ContactEmailRow[]
/** 고치는 연락처 — 자기 자신의 주소는 안내하지 않는다 */
exceptId: () => string | null
}
export interface ContactDuplicateCheck {
/** 이메일 줄마다 안내 — 없으면 null (줄 차례) */
warnings: ComputedRef<(ContactEntryWarning | null)[]>
check: (rowKey: number) => Promise<void>
forget: (rowKey: number) => void
reset: () => void
}
export function useContactDuplicateCheck(options: ContactDuplicateCheckOptions): ContactDuplicateCheck {
const contactsStore = useContactsStore()
/** 안내 — 이메일 줄 키마다 */
const byKey = ref<Record<number, ContactEntryWarning>>({})
/** 줄마다의 찾기 차례 — 칸을 고치면 오른다 */
const seqs = new Map<number, number>()
/** 창을 연 차례 — 다시 열면 오른다 */
let epoch = 0
function bump(rowKey: number): number {
const seq = (seqs.get(rowKey) ?? 0) + 1
seqs.set(rowKey, seq)
return seq
}
function forget(rowKey: number) {
bump(rowKey)
byKey.value = withoutKey(byKey.value, rowKey)
}
function reset() {
epoch += 1
seqs.clear()
byKey.value = {}
}
async function check(rowKey: number): Promise<void> {
const address = options.rows().find((row) => row.key === rowKey)?.address.trim() ?? ''
const seq = bump(rowKey)
if (!isValidEmail(address)) return
const opened = epoch
const found = await contactsStore.findContactsWithEmail(address, options.exceptId())
if (opened !== epoch || seqs.get(rowKey) !== seq || found === undefined) return
const current = options.rows().find((row) => row.key === rowKey)
if (!current || normalizeEmail(current.address) !== normalizeEmail(address)) return
const other = found[0]
const rest = withoutKey(byKey.value, rowKey)
byKey.value = other
? {
...rest,
[rowKey]: {
text: CONTACT_DIALOG_TEXT.duplicate(duplicateWho(other, contactsStore.groups)),
contactId: other.id,
linkLabel: CONTACT_DIALOG_TEXT.duplicateView,
},
}
: rest
}
const warnings = computed(() => options.rows().map((row) => byKey.value[row.key] ?? null))
return { warnings, check, forget, reset }
}
+160
View File
@@ -0,0 +1,160 @@
import type { AxiosRequestConfig } from 'axios'
import { useApi } from './useApi'
import { CONTACT_GROUP_MEMBER_PAGES_MAX, CONTACTS_PAGE_SIZE } from '@/shared/constants/contacts'
import type {
Contact,
ContactDeleted,
ContactGroup,
ContactGroupCreatePayload,
ContactGroupDeleted,
ContactGroupListQuery,
ContactGroupMembersAdded,
ContactGroupMembersPayload,
ContactGroupRenamePayload,
ContactInput,
ContactListQuery,
ContactPatch,
ContactRestored,
DirectoryEntry,
DirectoryListQuery,
} from '@/shared/types/contacts'
import type { PaginatedResult } from '@/shared/types/pagination'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type ContactsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
/** 경로에 넣는 id 는 항상 인코딩한다 */
const segment = (value: string) => encodeURIComponent(value)
// 주소록 API Composable — docs/mail/api-contract.md 3절 "주소록 — 8단계" 의 엔드포인트 (모두 로그인 필요)
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
// 컴포넌트는 이것을 직접 부르기보다 스토어(contacts.store) · 하는 일(useContactActions)을 거친다
export function useContacts() {
const api = useApi()
/** 연락처 — 이름 차례, total 포함. groupId 면 그 그룹의 구성원, q 면 찾은 것 (cursor 는 받은 그 q · groupId 로만) */
function listContacts(query: ContactListQuery, options?: ContactsRequestOptions): Promise<PaginatedResult<Contact>> {
return api.get<PaginatedResult<Contact>, PaginatedResult<Contact>>('/contacts', { ...options, params: query })
}
function getContact(id: string, options?: ContactsRequestOptions): Promise<Contact> {
return api.get<Contact, Contact>(`/contacts/${segment(id)}`, options)
}
/** 만들기 — 연락처 3,000개 · 그룹 구성원 500명을 넘으면 409 CONTACT_002, 없는 groupId 는 404 RES_001 */
function createContact(input: ContactInput, options?: ContactsRequestOptions): Promise<Contact> {
return api.post<Contact, Contact>('/contacts', input, options)
}
/**
* 고치기 — 연 때와 달라진 칸만 보낸다(보내지 않은 칸 · 엔진 카드의 다른 칸은 서버가 그대로 둔다 — 창을 연 사이 다른 곳에서 바꾼 칸을 덮지 않게).
* emails · phones · groupIds 는 보내면 통째로 바뀐다
*/
function updateContact(id: string, patch: ContactPatch, options?: ContactsRequestOptions): Promise<Contact> {
return api.patch<Contact, Contact>(`/contacts/${segment(id)}`, patch, options)
}
/** 지우기 — 영구 삭제라 지운 연락처와 [되돌리기] 표를 돌려준다 (표가 null 이면 되돌릴 수 없다) */
function deleteContact(id: string, options?: ContactsRequestOptions): Promise<ContactDeleted> {
return api.delete<ContactDeleted, ContactDeleted>(`/contacts/${segment(id)}`, options)
}
/**
* 되돌리기 — 지우기 응답의 표로 지운 그대로(다른 프로그램의 칸 · 그룹 · 구성원까지) 다시 만든다. id 는 새로 받는다.
* 표가 없음 · 지남 · 이미 씀은 404 RES_001, 같은 이름의 그룹 409 CONTACT_001, 한도 409 CONTACT_002 (계약 3절 "되돌리기")
*/
function restoreDeleted(token: string, options?: ContactsRequestOptions): Promise<ContactRestored> {
return api.post<ContactRestored, ContactRestored>('/contact-restorations', { token }, options)
}
function listContactGroups(
query: ContactGroupListQuery,
options?: ContactsRequestOptions,
): Promise<PaginatedResult<ContactGroup>> {
return api.get<PaginatedResult<ContactGroup>, PaginatedResult<ContactGroup>>('/contact-groups', {
...options,
params: query,
})
}
/** 새 그룹 — 같은 이름이면 409 CONTACT_001, 그룹 100개면 409 CONTACT_002 */
function createContactGroup(payload: ContactGroupCreatePayload, options?: ContactsRequestOptions): Promise<ContactGroup> {
return api.post<ContactGroup, ContactGroup>('/contact-groups', payload, options)
}
function renameContactGroup(
id: string,
payload: ContactGroupRenamePayload,
options?: ContactsRequestOptions,
): Promise<ContactGroup> {
return api.patch<ContactGroup, ContactGroup>(`/contact-groups/${segment(id)}`, payload, options)
}
/** 그룹 지우기 — 연락처는 그대로다. 응답의 표로 [되돌리기]가 다시 만든다 */
function deleteContactGroup(id: string, options?: ContactsRequestOptions): Promise<ContactGroupDeleted> {
return api.delete<ContactGroupDeleted, ContactGroupDeleted>(`/contact-groups/${segment(id)}`, options)
}
/** 구성원 넣기 — 내 연락처(contactIds) · 사내 목록(directoryIds — 내 주소록에도 만든다) 합쳐 1~100개 */
function addContactGroupMembers(
id: string,
payload: ContactGroupMembersPayload,
options?: ContactsRequestOptions,
): Promise<ContactGroupMembersAdded> {
return api.post<ContactGroupMembersAdded, ContactGroupMembersAdded>(
`/contact-groups/${segment(id)}/members`,
payload,
options,
)
}
/** 사내 목록 — 이름 차례, total 포함. excludeGroupId 면 그 그룹 구성원의 주소를 뺀다. 사내 목록을 읽지 못하면 503 MAIL_001 */
function listDirectoryEntries(
query: DirectoryListQuery,
options?: ContactsRequestOptions,
): Promise<PaginatedResult<DirectoryEntry>> {
return api.get<PaginatedResult<DirectoryEntry>, PaginatedResult<DirectoryEntry>>('/directory-entries', {
...options,
params: query,
})
}
/** 사내 항목 하나 — 주소로 연 사내 항목 (없거나 빼는 계정이면 404 RES_001) */
function getDirectoryEntry(id: string, options?: ContactsRequestOptions): Promise<DirectoryEntry> {
return api.get<DirectoryEntry, DirectoryEntry>(`/directory-entries/${segment(id)}`, options)
}
/**
* 그룹의 구성원 전부 — 100명씩 다음 쪽까지 받는다 (그룹 하나에 500명까지 — 계약 3절 "메일과 함께").
* 받는 사람 추천의 그룹 줄 · [그룹에 메일 쓰기]가 쓴다
*/
async function listGroupMembers(groupId: string, options?: ContactsRequestOptions): Promise<Contact[]> {
const members: Contact[] = []
let cursor: string | undefined
for (let page = 0; page < CONTACT_GROUP_MEMBER_PAGES_MAX; page += 1) {
const result = await listContacts({ groupId, limit: CONTACTS_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, options)
members.push(...result.items)
if (!result.pageInfo.hasNext || !result.pageInfo.nextCursor) break
cursor = result.pageInfo.nextCursor
}
return members
}
return {
listContacts,
getContact,
createContact,
updateContact,
deleteContact,
restoreDeleted,
listContactGroups,
createContactGroup,
renameContactGroup,
deleteContactGroup,
addContactGroupMembers,
listDirectoryEntries,
getDirectoryEntry,
listGroupMembers,
}
}
@@ -0,0 +1,53 @@
import { computed, inject, shallowRef, type ComputedRef, type InjectionKey } from 'vue'
import type { Contact, ContactGroup } from '@/shared/types/contacts'
import type { ContactPrefill } from '@/shared/utils/contacts'
// 주소록 화면의 창 — 패널 · 목록 · 상세가 열고, 레이아웃의 창 묶음(ContactsDialogs)이 그린다 (메일함 패널의 MailboxDialogs 와 같은 결)
// 레이아웃(ContactsLayout)이 만들어 provide 하고, 아래 화면은 useContactsDialogs() 로 받는다.
// 창은 레이아웃에 있어 주소록 안에서 목록 · 고른 연락처가 바뀌어도 닫히지 않는다 — 주소록을 떠날 때만 나가기 확인이 묻는다
export type ContactsDialogRequest =
/** 연락처 추가(contact 가 null — prefill 이 있으면 채워서) · 고치기 */
| { kind: 'contact'; contact: Contact | null; prefill: ContactPrefill | null }
/** 새 그룹(group 이 null) · 이름 바꾸기 */
| { kind: 'group-name'; group: ContactGroup | null }
/** 구성원 넣기 */
| { kind: 'members'; group: ContactGroup }
export interface ContactsDialogsHandle {
/** 지금 연 창 — 없으면 null (읽기만) */
request: ComputedRef<ContactsDialogRequest | null>
openContact: (contact: Contact | null, prefill?: ContactPrefill | null) => void
openGroupName: (group: ContactGroup | null) => void
openMembers: (group: ContactGroup) => void
close: () => void
}
export const CONTACTS_DIALOGS: InjectionKey<ContactsDialogsHandle> = Symbol('contacts-dialogs')
export function createContactsDialogs(): ContactsDialogsHandle {
const request = shallowRef<ContactsDialogRequest | null>(null)
return {
request: computed(() => request.value),
openContact: (contact, prefill = null) => {
request.value = { kind: 'contact', contact, prefill }
},
openGroupName: (group) => {
request.value = { kind: 'group-name', group }
},
openMembers: (group) => {
request.value = { kind: 'members', group }
},
close: () => {
request.value = null
},
}
}
/** 주소록 레이아웃 아래의 화면에서 부른다 */
export function useContactsDialogs(): ContactsDialogsHandle {
const handle = inject(CONTACTS_DIALOGS, null)
if (!handle) throw new Error('주소록 창은 주소록 레이아웃(ContactsLayout) 안에서만 열 수 있습니다.')
return handle
}
+4 -1
View File
@@ -299,7 +299,10 @@ export function useMail() {
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
/** 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만 */
/**
* 주소록(8단계 — 주소마다 한 줄, 대표 주소가 먼저) → 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만.
* 그룹은 여기에 없다 — 쓰기 화면이 그룹 목록을 따로 받는다 (useRecipientSuggestions)
*/
function listRecipientSuggestions(
query: RecipientSuggestionQuery,
options?: MailRequestOptions,
@@ -0,0 +1,42 @@
import { computed, inject, shallowRef, type ComputedRef, type InjectionKey } from 'vue'
import type { ContactPrefill } from '@/shared/utils/contacts'
// 메일 화면의 연락처 추가 창 — 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」가 열고, 메일 레이아웃(MailLayout)의 MailContactDialogHost 가 그린다
// (계약 3절 "메일과 함께" · 디자인 README "8단계 › 메일과 함께").
// 창을 읽기 화면 안(v-if="message")에 두면 메일을 바꿀 때(뒤로 · 이전/다음 · 바탕화면 알림 · 다시 받는 동안) 확인 없이 사라진다 —
// 그래서 메일 레이아웃에 둔다: 메일 화면 안에서는 메일이 바뀌어도 창이 남고(채운 값은 연 때의 보낸 사람이라 메일과 상관없다),
// 메일 화면을 떠날 때만 창의 나가기 확인이 묻는다(주소록 화면의 창 묶음과 같은 결 — 2026-10-29 리뷰 M3)
export interface MailContactDialogRequest {
prefill: ContactPrefill
/** 창을 닫은 뒤 초점을 돌려줄 곳 — 연 ⋯ 단추가 아직 있으면 그것 */
returnFocus: (() => void) | null
}
export interface MailContactDialogHandle {
/** 지금 연 창 — 없으면 null (읽기만) */
request: ComputedRef<MailContactDialogRequest | null>
open: (prefill: ContactPrefill, returnFocus?: () => void) => void
close: () => void
}
export const MAIL_CONTACT_DIALOG: InjectionKey<MailContactDialogHandle> = Symbol('mail-contact-dialog')
export function createMailContactDialog(): MailContactDialogHandle {
const request = shallowRef<MailContactDialogRequest | null>(null)
return {
request: computed(() => request.value),
open: (prefill, returnFocus) => {
request.value = { prefill, returnFocus: returnFocus ?? null }
},
close: () => {
request.value = null
},
}
}
/** 메일 레이아웃 아래에서 부른다 — 레이아웃 밖(테스트 등)이면 null (창을 열지 않는다) */
export function useMailContactDialog(): MailContactDialogHandle | null {
return inject(MAIL_CONTACT_DIALOG, null)
}
@@ -6,6 +6,7 @@ import {
RECIPIENT_SEARCH_DELAY_MS,
useRecipientSuggestions,
} from '@/composables/useRecipientSuggestions'
import type { Contact, ContactGroup } from '@/shared/types/contacts'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
@@ -46,15 +47,20 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
})
function setup(complete: boolean, searchResult: RecipientSuggestion[] = []) {
requests = installMockApi((request) =>
request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false),
)
requests = installMockApi((request) => {
if (request.url === '/contact-groups') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
return request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false)
})
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
if (!handle) throw new Error('scope')
return handle
}
const searches = () => requests.filter((request) => request.params.q !== undefined).map((request) => request.params.q)
const suggestionRequests = () => requests.filter((request) => request.url === '/recipient-suggestions')
const searches = () =>
suggestionRequests()
.filter((request) => request.params.q !== undefined)
.map((request) => request.params.q)
it('쓰기 화면을 열 때 q 없이 100개를 한 번 받아 두고 화면에서 거른다', async () => {
const handle = setup(true)
@@ -62,7 +68,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
handle.preload()
await flushPromises()
expect(requests.map((request) => request.params)).toEqual([{ limit: 100 }])
expect(suggestionRequests().map((request) => request.params)).toEqual([{ limit: 100 }])
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
excluded = new Set([DOYUN.email])
@@ -80,7 +86,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
expect(searches()).toEqual([])
})
it('모자라면 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 — 초성은 묻지 않는다', async () => {
it('모자라고 더 있으면(hasNext) 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 · 같은 글자는 다시 묻지 않는다', async () => {
const handle = setup(false, [VENDOR])
handle.preload()
await flushPromises()
@@ -104,9 +110,33 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
expect(searches()).toEqual(['vendor'])
})
it('미리 받기가 실패하면 치는 대로 q 로 묻고(자모만 친 글자는 묻지 않는다), 다음 preload(칸에 초점)가 다시 받는다', async () => {
it('초성 · 조합 중 글자도 서버에 묻는다 — 8단계부터 서버의 q 가 같은 맞추기다 (주소록으로 찬 100줄 밖의 사내 · 최근 주소)', async () => {
const SEOHA: RecipientSuggestion = { name: '도서하', email: 'seoha.do@arasystem.it.kr', source: 'directory' }
const handle = setup(false, [SEOHA])
handle.preload()
await flushPromises()
handle.search('ㄷㅅ')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
handle.search('도ㅅ')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
// 조합 중(윤을 치는 중)도 — 그사이 또 치면 마지막 글자만
handle.search('도서')
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
handle.search('도서ㅎ')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
expect(searches()).toEqual(['ㄷㅅ', '도ㅅ', '도서ㅎ'])
expect(handle.matchesFor('ㄷㅅ').map((match) => match.suggestion)).toEqual([SEOHA])
})
it('미리 받기가 실패하면 치는 대로 q 로 묻고(초성도), 다음 preload(칸에 초점)가 다시 받는다', async () => {
let failPreload = true
requests = installMockApi((request) => {
if (request.url === '/contact-groups') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
if (request.params.q !== undefined) return page([VENDOR], false)
if (failPreload) return fail(503, 'MAIL_001')
return page([DOYUN, JUNHO], false)
@@ -119,11 +149,11 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
handle.search('ㄷㅇ')
clock.advance(1_000)
await flushPromises()
expect(searches()).toEqual([])
expect(searches()).toEqual(['ㄷㅇ'])
handle.search('vendor')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
expect(searches()).toEqual(['vendor'])
expect(searches()).toEqual(['ㄷㅇ', 'vendor'])
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
failPreload = false
@@ -131,7 +161,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
await flushPromises()
handle.preload()
await flushPromises()
expect(requests.filter((request) => request.params.q === undefined)).toHaveLength(2)
expect(suggestionRequests().filter((request) => request.params.q === undefined)).toHaveLength(2)
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
})
@@ -141,5 +171,133 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
handle?.preload()
await flushPromises()
expect(handle?.matchesFor('도윤')).toEqual([])
expect(handle?.optionsFor('도윤')).toEqual([])
})
describe('주소록 · 그룹 (8단계 — 계약 3절 "메일과 함께")', () => {
const VENDORS: ContactGroup = { id: 'g1', name: '거래처', memberCount: 3 }
const JIHOON: RecipientSuggestion = {
name: '박지훈',
email: 'jihoon.park@hanbit.example',
source: 'contact',
company: '(주)한빛상사',
emailLabel: 'work',
}
function member(id: string, name: string | null, emails: string[]): Contact {
return {
id,
name,
emails: emails.map((address) => ({ address, label: 'work' as const })),
phones: [],
company: null,
department: null,
title: null,
note: null,
groupIds: ['g1'],
}
}
function setupWithGroups(members: (cursor: unknown) => ReturnType<typeof ok> | ReturnType<typeof fail>) {
requests = installMockApi((request) => {
if (request.url === '/contact-groups') {
return ok({ items: [VENDORS], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
if (request.url === '/contacts') return members(request.params.cursor)
return page([JIHOON, DOYUN], false)
})
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
if (!handle) throw new Error('scope')
return handle
}
it('열 때 그룹 목록을 한 번 받아 두고(알림 없이) 그룹 이름으로 찾는다 — 묶음은 주소록 → 그룹 → 사내', async () => {
const handle = setupWithGroups(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
handle.preload()
handle.preload()
await flushPromises()
const groupRequests = requests.filter((request) => request.url === '/contact-groups')
expect(groupRequests.map((request) => request.params)).toEqual([{ limit: 100 }])
expect(groupRequests[0]?.config.silent).toBe(true)
expect(handle.optionsFor('ㄱㄹ').map((option) => option.kind)).toEqual(['group'])
// 주소 추천만 보는 matchesFor 에는 그룹이 없다 (서버에 더 물을지는 주소 추천의 수로 정한다)
expect(handle.matchesFor('ㄱㄹ')).toEqual([])
})
it('주소록 → 그룹 → 사내 차례로 섞어 보인다', async () => {
requests = installMockApi((request) => {
if (request.url === '/contact-groups') {
return ok({
items: [{ id: 'g2', name: '박팀', memberCount: 2 }],
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
})
}
return page([JUNHO, JIHOON], false)
})
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
handle?.preload()
await flushPromises()
expect(
handle
?.optionsFor('박')
.map((option) => (option.kind === 'group' ? `그룹 ${option.match.group.name}` : option.match.suggestion.email)),
).toEqual(['jihoon.park@hanbit.example', '그룹 박팀', JUNHO.email])
})
it('그룹을 고르면 구성원을 100명씩 다음 쪽까지 받아 대표 주소를 돌려준다 — 이메일이 없는 구성원은 센다', async () => {
const handle = setupWithGroups((cursor) =>
cursor === undefined
? ok({
items: [member('c1', '박지훈', ['jihoon.park@hanbit.example', 'jihoon.p@mail.example']), member('c2', '메모만', [])],
pageInfo: { hasNext: true, nextCursor: 'page-2', total: 3 },
})
: ok({
items: [member('c3', null, ['ops@vendor.example'])],
pageInfo: { hasNext: false, nextCursor: null, total: 3 },
}),
)
const result = await handle.expandGroup(VENDORS)
const memberRequests = requests.filter((request) => request.url === '/contacts')
expect(memberRequests.map((request) => request.params)).toEqual([
{ groupId: 'g1', limit: 100 },
{ groupId: 'g1', limit: 100, cursor: 'page-2' },
])
expect(result).toEqual({
addresses: [
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: null, email: 'ops@vendor.example' },
],
withoutEmail: 1,
})
})
it('구성원을 받지 못하면 null (알림은 인터셉터) · 기다리는 사이 세션이 바뀌면 결과를 버린다', async () => {
const failing = setupWithGroups(() => fail(503, 'MAIL_001'))
expect(await failing.expandGroup(VENDORS)).toBeNull()
let finish: (result: ReturnType<typeof ok>) => void = () => undefined
const late = new Promise<ReturnType<typeof ok>>((resolve) => {
finish = resolve
})
requests = installMockApi((request) =>
request.url === '/contacts' ? late : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }),
)
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
const pending = handle?.expandGroup(VENDORS)
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
finish(ok({ items: [member('c1', '박지훈', ['jihoon.park@hanbit.example'])], pageInfo: { hasNext: false, nextCursor: null, total: 1 } }))
expect(await pending).toBeNull()
})
it('이미 넣은 주소인지는 지금 칸 값으로 본다 (대소문자 무시)', () => {
const handle = setup(true)
excluded = new Set(['jihoon.park@hanbit.example'])
expect(handle.isAdded('Jihoon.Park@hanbit.example')).toBe(true)
expect(handle.isAdded('ops@vendor.example')).toBe(false)
})
})
})
@@ -1,22 +1,33 @@
import { onScopeDispose, ref, shallowRef, type InjectionKey } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useContacts } from './useContacts'
import { useMail } from './useMail'
import { CONTACTS_PAGE_SIZE } from '@/shared/constants/contacts'
import type { ContactGroup } from '@/shared/types/contacts'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { groupRecipientsOf, type GroupRecipients } from '@/shared/utils/contacts'
import { normalizeEmail } from '@/shared/utils/mail-address'
import {
RECIPIENT_SUGGESTION_LIMIT,
arrangeRecipientOptions,
filterRecipientSuggestions,
hasHangulJamo,
matchContactGroups,
type RecipientMatch,
type RecipientOption,
} from '@/shared/utils/recipient-match'
import { useAuthStore } from '@/stores/auth.store'
// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 디자인 README "2단계 › 받는 사람 자동 완성")
// - 쓰기 화면을 열 때 q 없이 limit=100 으로 한 번 받아 두고, 치는 대로 화면에서 거른다(초성 포함 — recipient-match.ts)
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다.
// 받은 것은 받아 둔 목록에 더한다. 서버는 초성을 모르므로 초성 · 홀로 쓴 자모가 든 글자는 묻지 않는다
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다(늦은 응답은 버린다).
// 받은 것은 받아 둔 목록에 더한다. 8단계부터 서버의 q 도 이 화면과 같은 맞추기라(초성 · 조합 중 글자 — 계약 2절 "받는 사람 추천") 그런 글자도 묻는다 —
// 주소록이 크면 받아 둔 100줄이 주소록으로 차서 사내 · 최근 주소가 그 안에 없다
// - 추천은 보조 정보다 — 못 받아도 알리지 않는다 (silent). 주소는 그대로 칠 수 있다.
// 미리 받기가 실패하면 치는 대로 q 로 묻고(초성 · 자모만 친 글자는 여전히 묻지 않는다), 다음에 칸에 초점이 올 때 다시 받아 본다
// 미리 받기가 실패하면 치는 대로 q 로 묻고, 다음에 칸에 초점이 올 때 다시 받아 본다
// - 8단계(계약 3절 "메일과 함께"): 서버 추천에 내 주소록(source contact)이 먼저 온다. 그룹은 서버 추천에 없어 열 때 그룹 목록을 한 번 받아 두고
// 이름으로 찾는다(주소록 → 그룹 → 사내 → 최근 — optionsFor). 그룹을 고르면 구성원을 받아(100명씩 다음 쪽까지 — expandGroup)
// 대표 주소를 넣는다 — 그룹 목록 · 구성원을 못 받아도 주소 추천은 그대로다. 구성원 받기의 실패는 고른 일이라 인터셉터가 알린다
// 쓰기 화면이 만들어 provide 하고(RECIPIENT_SUGGESTIONS), 받는 사람 칸(RecipientField)이 inject 해 쓴다.
/** 처음 받아 두는 개수 — 계약의 최대 limit */
@@ -35,18 +46,31 @@ export interface RecipientSuggestionsOptions {
export interface RecipientSuggestionsHandle {
/** 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다 (실패했으면 다시 받는다) */
preload: () => void
/** 친 글자에 맞는 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
/** 친 글자에 맞는 주소 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
matchesFor: (query: string) => RecipientMatch[]
/** 추천 목록의 줄 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 모두 합쳐 최대 8개 (반응형) */
optionsFor: (query: string) => RecipientOption[]
/** 맞는 것이 모자라면 서버에 묻는다 (기다렸다가 — 그사이 또 치면 마지막 글자만) */
search: (query: string) => void
/** 그룹의 구성원 → 넣을 대표 주소 · 이메일이 없는 구성원 수. 못 받았거나 그사이 세션이 바뀌었으면 null (실패는 인터셉터가 알렸다) */
expandGroup: (group: ContactGroup) => Promise<GroupRecipients | null>
/** 받는 사람 · 참조 · 숨은참조 가운데 이미 넣은 주소인가 — 지금 칸 값으로 본다 */
isAdded: (email: string) => boolean
}
export const RECIPIENT_SUGGESTIONS: InjectionKey<RecipientSuggestionsHandle> = Symbol('recipient-suggestions')
export function useRecipientSuggestions(options: RecipientSuggestionsOptions): RecipientSuggestionsHandle {
const { listRecipientSuggestions } = useMail()
const { listContactGroups, listGroupMembers } = useContacts()
const authStore = useAuthStore()
const clock = options.clock ?? systemClock
/** 받아 둔 그룹 — 이름으로 찾는다 (그룹은 100개까지 — 한 쪽) */
const groups = shallowRef<readonly ContactGroup[]>([])
/** 그룹 받기 — 실패했으면 다음 preload 가 다시 받는다 */
let groupsState: 'idle' | 'loading' | 'done' | 'failed' = 'idle'
/** 받아 둔 추천 — 처음 받은 것 + 검색으로 더 받은 것 (주소가 겹치지 않게) */
const pool = shallowRef<readonly RecipientSuggestion[]>([])
/** 처음 받은 목록이 전부다 — 서버에 더 물을 것이 없다 */
@@ -69,7 +93,22 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
if (added.length > 0) pool.value = [...pool.value, ...added]
}
function preloadGroups() {
if (groupsState === 'loading' || groupsState === 'done') return
groupsState = 'loading'
void listContactGroups({ limit: CONTACTS_PAGE_SIZE }, { silent: true })
.then((page) => {
groupsState = 'done'
groups.value = Array.isArray(page?.items) ? page.items : []
})
.catch(() => {
// 그룹 줄 없이 주소 추천만 — 다음에 칸에 초점이 오면 다시 받아 본다
groupsState = 'failed'
})
}
function preload() {
preloadGroups()
if (preloadState === 'loading' || preloadState === 'done') return
preloadState = 'loading'
void listRecipientSuggestions({ limit: RECIPIENT_SUGGESTION_PRELOAD }, { silent: true })
@@ -88,6 +127,24 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
return filterRecipientSuggestions(query, pool.value, options.excluded())
}
function optionsFor(query: string): RecipientOption[] {
return arrangeRecipientOptions(matchesFor(query), matchContactGroups(query, groups.value))
}
function isAdded(email: string): boolean {
return options.excluded().has(normalizeEmail(email))
}
async function expandGroup(group: ContactGroup): Promise<GroupRecipients | null> {
const session = authStore.generation
try {
const members = await listGroupMembers(group.id)
return authStore.generation === session ? groupRecipientsOf(members) : null
} catch {
return null
}
}
function cancelSearch() {
if (searchTimer !== null) clock.clearTimeout(searchTimer)
searchTimer = null
@@ -116,12 +173,12 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
cancelSearch()
const text = query.trim()
if (text === '' || text.length > SEARCH_QUERY_MAX_LENGTH || complete.value) return
if (hasHangulJamo(text) || asked.has(text)) return
if (asked.has(text)) return
if (matchesFor(text).length >= RECIPIENT_SUGGESTION_LIMIT) return
searchTimer = clock.setTimeout(() => void ask(text), RECIPIENT_SEARCH_DELAY_MS)
}
onScopeDispose(cancelSearch)
return { preload, matchesFor, search }
return { preload, matchesFor, optionsFor, search, expandGroup, isAdded }
}