2 Commits
v0.1.9 ... main

Author SHA1 Message Date
2977094657
950fb4c7b4 improvement(chat): 会话列表可拖拽调宽并优化 realtime 关闭同步
- 中间栏新增拖拽调宽/双击重置;宽度按物理 px 持久化(兼容旧 key,并按 dpr 换算)

- 关闭 realtime 前触发 syncChatRealtimeMessages(max_scan=5000),避免回退到过期解密快照

- 按 dpr 调整联系人/消息头像与 skeleton 尺寸
2026-01-28 18:19:58 +08:00
2977094657
891d4b8a1b improvement(chat): WCDB 回退补全昵称/头像
- contact.db 缺失(企业/开放平台/openim/群等)时,回退 WCDB realtime 查询 displayName/avatarUrl

- 覆盖消息/会话:senderDisplayName/senderAvatar、link card from、quoteTitle、会话列表 name/avatar

- realtime 场景尽量复用已建立的 WCDB 连接;best-effort,失败不影响主流程
2026-01-28 18:19:38 +08:00
2 changed files with 426 additions and 21 deletions

View File

@@ -99,7 +99,18 @@
</div>
<!-- 中间列表区域 -->
<div class="w-80 border-r border-gray-200 flex flex-col min-h-0" style="background-color: #F7F7F7">
<div
class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative"
:style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }"
>
<!-- 拖动调整会话列表宽度 -->
<div
class="session-list-resizer"
:class="{ 'session-list-resizer-active': sessionListResizing }"
title="拖动调整会话列表宽度"
@pointerdown="onSessionListResizerPointerDown"
@dblclick="resetSessionListWidth"
/>
<!-- 聊天列表 -->
<div class="h-full flex flex-col min-h-0">
<!-- 搜索栏 -->
@@ -143,8 +154,8 @@
<!-- 联系人列表 -->
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden">
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 py-2">
<div class="w-10 h-10 rounded-md bg-gray-200 skeleton-pulse"></div>
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(85px/var(--dpr))]">
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div>
<div class="flex-1 space-y-2">
<div class="h-3.5 bg-gray-200 rounded skeleton-pulse" :style="{ width: (60 + (i % 4) * 15) + 'px' }"></div>
<div class="h-3 bg-gray-200 rounded skeleton-pulse" :style="{ width: (80 + (i % 3) * 20) + 'px' }"></div>
@@ -159,12 +170,12 @@
</div>
<template v-else>
<div v-for="contact in filteredContacts" :key="contact.id"
class="px-3 py-2 cursor-pointer transition-colors duration-150 border-b border-gray-100"
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(85px/var(--dpr))] flex items-center"
:class="selectedContact?.id === contact.id ? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]' : 'hover:bg-[#eaeaea]'"
@click="selectContact(contact)">
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-3 w-full">
<!-- 联系人头像 -->
<div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<div v-if="contact.avatar" class="w-full h-full">
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover">
</div>
@@ -340,7 +351,7 @@
<div v-else class="flex items-center" :class="message.isSent ? 'justify-end' : 'justify-start'">
<div class="flex items-start max-w-md" :class="message.isSent ? 'flex-row-reverse' : ''">
<!-- 消息发送者头像 -->
<div class="w-[36px] h-[36px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
<div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
<div v-if="message.avatar" class="w-full h-full">
<img
:src="message.avatar"
@@ -1802,6 +1813,129 @@ watch(
}
)
// 会话列表(中间栏)宽度(按物理像素 px 配置):默认 295px支持拖动调整并持久化
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width'
const SESSION_LIST_WIDTH_DEFAULT = 295
const SESSION_LIST_WIDTH_MIN = 220
const SESSION_LIST_WIDTH_MAX = 520
const sessionListWidth = ref(SESSION_LIST_WIDTH_DEFAULT)
const sessionListResizing = ref(false)
let sessionListResizeStartX = 0
let sessionListResizeStartWidth = SESSION_LIST_WIDTH_DEFAULT
let sessionListResizeStartDpr = 1
let sessionListResizePrevCursor = ''
let sessionListResizePrevUserSelect = ''
const clampSessionListWidth = (n) => {
const v = Number.isFinite(n) ? n : SESSION_LIST_WIDTH_DEFAULT
return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(v)))
}
const loadSessionListWidth = () => {
if (!process.client) return
try {
const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY)
const v = parseInt(String(raw || ''), 10)
if (!Number.isNaN(v)) {
sessionListWidth.value = clampSessionListWidth(v)
return
}
// Legacy: value was stored as CSS px. Convert to physical px using current dpr.
const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY)
const legacyV = parseInt(String(legacy || ''), 10)
if (!Number.isNaN(legacyV)) {
const dpr = window.devicePixelRatio || 1
const converted = clampSessionListWidth(legacyV * dpr)
sessionListWidth.value = converted
try {
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(converted))
localStorage.removeItem(SESSION_LIST_WIDTH_KEY_LEGACY)
} catch {}
}
} catch {}
}
const saveSessionListWidth = () => {
if (!process.client) return
try {
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(clampSessionListWidth(sessionListWidth.value)))
} catch {}
}
const setSessionListResizingActive = (active) => {
if (!process.client) return
try {
const body = document.body
if (!body) return
if (active) {
sessionListResizePrevCursor = body.style.cursor || ''
sessionListResizePrevUserSelect = body.style.userSelect || ''
body.style.cursor = 'col-resize'
body.style.userSelect = 'none'
} else {
body.style.cursor = sessionListResizePrevCursor
body.style.userSelect = sessionListResizePrevUserSelect
sessionListResizePrevCursor = ''
sessionListResizePrevUserSelect = ''
}
} catch {}
}
const onSessionListResizerPointerMove = (ev) => {
if (!sessionListResizing.value) return
const clientX = Number(ev?.clientX || 0)
// `clientX` delta is in CSS px. We store width as physical px, so multiply by dpr.
sessionListWidth.value = clampSessionListWidth(
sessionListResizeStartWidth + (clientX - sessionListResizeStartX) * (sessionListResizeStartDpr || 1)
)
}
const stopSessionListResize = () => {
if (!process.client) return
if (!sessionListResizing.value) return
sessionListResizing.value = false
setSessionListResizingActive(false)
try {
window.removeEventListener('pointermove', onSessionListResizerPointerMove)
} catch {}
saveSessionListWidth()
}
const onSessionListResizerPointerUp = () => {
stopSessionListResize()
}
const onSessionListResizerPointerDown = (ev) => {
if (!process.client) return
try {
ev?.preventDefault?.()
} catch {}
sessionListResizing.value = true
sessionListResizeStartX = Number(ev?.clientX || 0)
sessionListResizeStartWidth = Number(sessionListWidth.value || SESSION_LIST_WIDTH_DEFAULT)
sessionListResizeStartDpr = window.devicePixelRatio || 1
setSessionListResizingActive(true)
try {
window.addEventListener('pointermove', onSessionListResizerPointerMove)
window.addEventListener('pointerup', onSessionListResizerPointerUp, { once: true })
} catch {}
}
const resetSessionListWidth = () => {
sessionListWidth.value = SESSION_LIST_WIDTH_DEFAULT
saveSessionListWidth()
}
onMounted(() => {
loadSessionListWidth()
})
// 桌面端设置(仅 Electron 环境可见)
const isDesktopEnv = ref(false)
const desktopSettingsOpen = ref(false)
@@ -4677,6 +4811,7 @@ onUnmounted(() => {
if (!process.client) return
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
stopSessionListResize()
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
messageSearchDebounceTimer = null
if (highlightMessageTimer) clearTimeout(highlightMessageTimer)
@@ -4976,8 +5111,22 @@ const toggleRealtime = async (opts = {}) => {
return true
}
// Turning off realtime: sync the latest WCDB rows into the decrypted sqlite DB first,
// otherwise the UI will fall back to an outdated decrypted snapshot.
realtimeEnabled.value = false
stopRealtimeStream()
try {
const api = useApi()
const u = String(selectedContact.value?.username || '').trim()
if (u) {
// Use a larger scan window on shutdown to reduce the chance of missing a backlog.
await api.syncChatRealtimeMessages({
account: selectedAccount.value,
username: u,
max_scan: 5000
})
}
} catch {}
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
if (selectedContact.value?.username) {
await refreshSelectedMessages()
@@ -5246,6 +5395,38 @@ const LinkCard = defineComponent({
background: #a1a1a1;
}
/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */
.session-list-panel {
width: calc(var(--session-list-width, 295px) / var(--dpr));
}
/* 会话列表拖动条(中间栏右侧) */
.session-list-resizer {
position: absolute;
top: 0;
right: -3px; /* 覆盖在 border 上,便于拖动 */
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 50;
}
.session-list-resizer::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 2px;
width: 2px;
background: transparent;
transition: background-color 0.15s ease;
}
.session-list-resizer:hover::after,
.session-list-resizer-active::after {
background: rgba(0, 0, 0, 0.12);
}
/* 消息气泡样式 */
.message-bubble {
border-radius: var(--message-radius);

View File

@@ -68,6 +68,8 @@ from ..session_last_message import (
from ..wcdb_realtime import (
WCDBRealtimeError,
WCDB_REALTIME,
get_avatar_urls as _wcdb_get_avatar_urls,
get_display_names as _wcdb_get_display_names,
get_messages as _wcdb_get_messages,
get_sessions as _wcdb_get_sessions,
)
@@ -2231,6 +2233,35 @@ def _postprocess_full_messages(
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
# contact.db may not include enterprise/openim contacts (or group chatroom records). WCDB has a more complete
# view of display names + avatar URLs, so we use it as a best-effort fallback.
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in uniq_senders:
if not u:
continue
row = sender_contact_rows.get(u)
if _pick_display_name(row, u) == u:
need_display.append(u)
if (not _pick_avatar_url(row)) and (u not in local_sender_avatars):
need_avatar.append(u)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
for m in merged:
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
@@ -2238,15 +2269,28 @@ def _postprocess_full_messages(
frow = sender_contact_rows.get(fu)
if frow is not None:
m["from"] = _pick_display_name(frow, fu)
else:
wd = str(wcdb_display_names.get(fu) or "").strip()
if wd:
m["from"] = wd
su = str(m.get("senderUsername") or "")
if not su:
continue
row = sender_contact_rows.get(su)
m["senderDisplayName"] = _pick_display_name(row, su)
display_name = _pick_display_name(row, su)
if display_name == su:
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
display_name = wd
m["senderDisplayName"] = display_name
avatar_url = _pick_avatar_url(row)
if not avatar_url and su in local_sender_avatars:
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(su) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
m["senderAvatar"] = avatar_url
qu = str(m.get("quoteUsername") or "").strip()
@@ -2262,9 +2306,15 @@ def _postprocess_full_messages(
if remark:
m["quoteTitle"] = remark
elif not qt:
m["quoteTitle"] = _pick_display_name(qrow, qu)
title = _pick_display_name(qrow, qu)
if title == qu:
wd = str(wcdb_display_names.get(qu) or "").strip()
if wd and wd != qu:
title = wd
m["quoteTitle"] = title
elif not qt:
m["quoteTitle"] = qu
wd = str(wcdb_display_names.get(qu) or "").strip()
m["quoteTitle"] = wd or qu
# Media URL fallback: if CDN URLs missing, use local media endpoints.
try:
@@ -2401,6 +2451,7 @@ def list_chat_sessions(
head_image_db_path = account_dir / "head_image.db"
base_url = str(request.base_url).rstrip("/")
rt_conn = None
rows: list[Any]
if source_norm == "realtime":
trace_id = f"rt-sessions-{int(time.time() * 1000)}-{threading.get_ident()}"
@@ -2416,6 +2467,7 @@ def list_chat_sessions(
try:
logger.info("[%s] ensure wcdb connected account=%s", trace_id, account_dir.name)
conn = WCDB_REALTIME.ensure_connected(account_dir)
rt_conn = conn
logger.info("[%s] wcdb connected account=%s handle=%s", trace_id, account_dir.name, int(conn.handle))
logger.info("[%s] wcdb_get_sessions account=%s", trace_id, account_dir.name)
wcdb_t0 = time.perf_counter()
@@ -2529,6 +2581,36 @@ def list_chat_sessions(
contact_rows = _load_contact_rows(contact_db_path, usernames)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
# Some sessions (notably enterprise groups / openim-related IDs) may be missing from decrypted contact.db
# (or lack nickname/avatar columns). In that case, fall back to WCDB APIs (same as WeFlow) to resolve
# display names + avatar URLs.
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in usernames:
if not u:
continue
row = contact_rows.get(u)
if _pick_display_name(row, u) == u:
need_display.append(u)
if (not _pick_avatar_url(row)) and (u not in local_avatar_usernames):
need_avatar.append(u)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
preview_mode = str(preview or "").strip().lower()
if preview_mode not in {"latest", "index", "session", "db", "none"}:
preview_mode = "latest"
@@ -2568,9 +2650,18 @@ def list_chat_sessions(
c_row = contact_rows.get(username)
display_name = _pick_display_name(c_row, username)
if display_name == username:
wd = str(wcdb_display_names.get(username) or "").strip()
if wd and wd != username:
display_name = wd
avatar_url = _pick_avatar_url(c_row)
if not avatar_url and username in local_avatar_usernames:
avatar_url = base_url + _build_avatar_url(account_dir.name, username)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(username) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
last_message = ""
if preview_mode == "session":
@@ -3896,6 +3987,35 @@ def list_chat_messages(
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
# contact.db may not include enterprise/openim contacts (or group chatroom records). WCDB has a more complete
# view of display names + avatar URLs, so we use it as a best-effort fallback.
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in uniq_senders:
if not u:
continue
row = sender_contact_rows.get(u)
if _pick_display_name(row, u) == u:
need_display.append(u)
if (not _pick_avatar_url(row)) and (u not in local_sender_avatars):
need_avatar.append(u)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
for m in merged:
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
@@ -3903,15 +4023,28 @@ def list_chat_messages(
frow = sender_contact_rows.get(fu)
if frow is not None:
m["from"] = _pick_display_name(frow, fu)
else:
wd = str(wcdb_display_names.get(fu) or "").strip()
if wd:
m["from"] = wd
su = str(m.get("senderUsername") or "")
if not su:
continue
row = sender_contact_rows.get(su)
m["senderDisplayName"] = _pick_display_name(row, su)
display_name = _pick_display_name(row, su)
if display_name == su:
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
display_name = wd
m["senderDisplayName"] = display_name
avatar_url = _pick_avatar_url(row)
if not avatar_url and su in local_sender_avatars:
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(su) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
m["senderAvatar"] = avatar_url
qu = str(m.get("quoteUsername") or "").strip()
@@ -3927,9 +4060,15 @@ def list_chat_messages(
if remark:
m["quoteTitle"] = remark
elif not qt:
m["quoteTitle"] = _pick_display_name(qrow, qu)
title = _pick_display_name(qrow, qu)
if title == qu:
wd = str(wcdb_display_names.get(qu) or "").strip()
if wd and wd != qu:
title = wd
m["quoteTitle"] = title
elif not qt:
m["quoteTitle"] = qu
wd = str(wcdb_display_names.get(qu) or "").strip()
m["quoteTitle"] = wd or qu
# Media URL fallback: if CDN URLs missing, use local media endpoints.
try:
@@ -4357,11 +4496,48 @@ async def _search_chat_messages_via_fts(
uniq_usernames = list(dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in hits]))
contact_rows = _load_contact_rows(contact_db_path, uniq_usernames)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, uniq_usernames)
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in uniq_usernames:
uu = str(u or "").strip()
if not uu:
continue
row = contact_rows.get(uu)
if _pick_display_name(row, uu) == uu:
need_display.append(uu)
if (not _pick_avatar_url(row)) and (uu not in local_avatar_usernames):
need_avatar.append(uu)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
conv_row = contact_rows.get(username)
conv_name = _pick_display_name(conv_row, username)
if conv_name == username:
wd = str(wcdb_display_names.get(username) or "").strip()
if wd and wd != username:
conv_name = wd
conv_avatar = _pick_avatar_url(conv_row)
if (not conv_avatar) and (username in local_avatar_usernames):
conv_avatar = base_url + _build_avatar_url(account_dir.name, username)
if not conv_avatar:
wa = str(wcdb_avatar_urls.get(username) or "").strip()
if wa.lower().startswith(("http://", "https://")):
conv_avatar = wa
for h in hits:
su = str(h.get("senderUsername") or "").strip()
@@ -4369,14 +4545,19 @@ async def _search_chat_messages_via_fts(
h["conversationAvatar"] = conv_avatar
if su:
row = contact_rows.get(su)
h["senderDisplayName"] = (
_pick_display_name(row, su)
if row is not None
else (conv_name if su == username else su)
)
display_name = _pick_display_name(row, su) if row is not None else (conv_name if su == username else su)
if display_name == su:
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
display_name = wd
h["senderDisplayName"] = display_name
avatar_url = _pick_avatar_url(row)
if (not avatar_url) and (su in local_avatar_usernames):
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(su) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
h["senderAvatar"] = avatar_url
else:
uniq_contacts = list(
@@ -4387,24 +4568,67 @@ async def _search_chat_messages_via_fts(
contact_rows = _load_contact_rows(contact_db_path, uniq_contacts)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, uniq_contacts)
wcdb_display_names: dict[str, str] = {}
wcdb_avatar_urls: dict[str, str] = {}
try:
need_display: list[str] = []
need_avatar: list[str] = []
for u in uniq_contacts:
uu = str(u or "").strip()
if not uu:
continue
row = contact_rows.get(uu)
if _pick_display_name(row, uu) == uu:
need_display.append(uu)
if (not _pick_avatar_url(row)) and (uu not in local_avatar_usernames):
need_avatar.append(uu)
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
for h in hits:
cu = str(h.get("username") or "").strip()
su = str(h.get("senderUsername") or "").strip()
crow = contact_rows.get(cu)
conv_name = _pick_display_name(crow, cu) if cu else ""
if cu and (conv_name == cu):
wd = str(wcdb_display_names.get(cu) or "").strip()
if wd and wd != cu:
conv_name = wd
h["conversationName"] = conv_name or cu
conv_avatar = _pick_avatar_url(crow)
if (not conv_avatar) and cu and (cu in local_avatar_usernames):
conv_avatar = base_url + _build_avatar_url(account_dir.name, cu)
if not conv_avatar and cu:
wa = str(wcdb_avatar_urls.get(cu) or "").strip()
if wa.lower().startswith(("http://", "https://")):
conv_avatar = wa
h["conversationAvatar"] = conv_avatar
if su:
row = contact_rows.get(su)
h["senderDisplayName"] = (
_pick_display_name(row, su) if row is not None else (conv_name if su == cu else su)
)
display_name = _pick_display_name(row, su) if row is not None else (conv_name if su == cu else su)
if display_name == su:
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
display_name = wd
h["senderDisplayName"] = display_name
avatar_url = _pick_avatar_url(row)
if (not avatar_url) and (su in local_avatar_usernames):
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
if not avatar_url:
wa = str(wcdb_avatar_urls.get(su) or "").strip()
if wa.lower().startswith(("http://", "https://")):
avatar_url = wa
h["senderAvatar"] = avatar_url
return {