diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index eb5cfe4..4a71e88 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -5055,7 +5055,17 @@ class ChatService { const contact = await this.getContact(username) const avatarResult = await wcdbService.getAvatarUrls([username]) - const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined + let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined + if (!this.isValidAvatarUrl(avatarUrl)) { + avatarUrl = undefined + } + if (!avatarUrl) { + const headImageAvatars = await this.getAvatarsFromHeadImageDb([username]) + const fallbackAvatarUrl = headImageAvatars[username] + if (this.isValidAvatarUrl(fallbackAvatarUrl)) { + avatarUrl = fallbackAvatarUrl + } + } const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username const cacheEntry: ContactCacheEntry = { avatarUrl, @@ -5523,6 +5533,13 @@ class ChatService { avatarUrl = avatarCandidate } } + if (!avatarUrl) { + const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedSessionId]) + const fallbackAvatarUrl = headImageAvatars[normalizedSessionId] + if (this.isValidAvatarUrl(fallbackAvatarUrl)) { + avatarUrl = fallbackAvatarUrl + } + } if (!Number.isFinite(messageCount)) { messageCount = messageCountResult.status === 'fulfilled' && diff --git a/electron/services/config.ts b/electron/services/config.ts index 4229069..c293ee1 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -53,6 +53,7 @@ interface ConfigSchema { notificationFilterList: string[] messagePushEnabled: boolean windowCloseBehavior: 'ask' | 'tray' | 'quit' + quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] } @@ -120,6 +121,7 @@ export class ConfigService { notificationFilterList: [], messagePushEnabled: false, windowCloseBehavior: 'ask', + quoteLayout: 'quote-top', wordCloudExcludeWords: [] } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 0a13243..e0f43f3 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1000,6 +1000,26 @@ class ExportService { return `${localType}_${this.getStableMessageKey(msg)}` } + private getImageMissingRunCacheKey( + sessionId: string, + imageMd5?: unknown, + imageDatName?: unknown, + imageDeepSearchOnMiss = true + ): string | null { + const normalizedSessionId = String(sessionId || '').trim() + const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase() + const normalizedImageDatName = String(imageDatName || '').trim().toLowerCase() + if (!normalizedSessionId) return null + if (!normalizedImageMd5 && !normalizedImageDatName) return null + + const primaryToken = normalizedImageMd5 || normalizedImageDatName + const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5 + ? normalizedImageDatName + : '' + const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink' + return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}` + } + private normalizeEmojiMd5(value: unknown): string | undefined { const md5 = String(value || '').trim().toLowerCase() if (!/^[a-f0-9]{32}$/.test(md5)) return undefined diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll index dc752c8..85d1ea8 100644 Binary files a/resources/arm64/wcdb_api.dll and b/resources/arm64/wcdb_api.dll differ diff --git a/resources/linux/libwcdb_api.so b/resources/linux/libwcdb_api.so index e19559d..e206d60 100755 Binary files a/resources/linux/libwcdb_api.so and b/resources/linux/libwcdb_api.so differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index 2b65e50..db376bb 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index e871bac..100bbc2 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 8776e5f..89049bf 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2420,7 +2420,6 @@ background: rgba(0, 0, 0, 0.04); border-left: 2px solid var(--primary); padding: 6px 10px; - margin-bottom: 8px; border-radius: 4px; font-size: 13px; @@ -2482,6 +2481,14 @@ .bubble-content { -webkit-app-region: no-drag; + + &.quote-layout-top .quoted-message { + margin-bottom: 8px; + } + + &.quote-layout-bottom .quoted-message { + margin-top: 8px; + } } // 时间分隔 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 07ef637..e9f8eda 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -52,6 +52,8 @@ interface GlobalMsgPrefixCacheEntry { completed: boolean } +type QuoteLayout = configService.QuoteLayout + const GLOBAL_MSG_PER_SESSION_LIMIT = 10 const GLOBAL_MSG_SEED_LIMIT = 120 const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3 @@ -7556,6 +7558,7 @@ function MessageBubble({ const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) const [quotedSenderName, setQuotedSenderName] = useState(undefined) + const [quoteLayout, setQuoteLayout] = useState('quote-top') const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -8549,6 +8552,18 @@ function MessageBubble({ myWxid ]) + useEffect(() => { + let cancelled = false + void configService.getQuoteLayout().then((layout) => { + if (!cancelled) setQuoteLayout(layout) + }).catch(() => { + if (!cancelled) setQuoteLayout('quote-top') + }) + return () => { + cancelled = true + } + }, []) + const locationMessageMeta = useMemo(() => { if (message.localType !== 48) return null const raw = message.rawContent || '' @@ -8584,6 +8599,31 @@ function MessageBubble({ // 是否有引用消息 const hasQuote = quotedContent.length > 0 const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName + const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => { + const quoteFirst = quoteLayout !== 'quote-bottom' + return ( +
+ {quoteFirst ? ( + <> + {quotedNode} + {messageNode} + + ) : ( + <> + {messageNode} + {quotedNode} + + )} +
+ ) + }, [quoteLayout]) + + const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => ( +
+ {displayQuotedSenderName && {displayQuotedSenderName}} + {contentNode} +
+ ), [displayQuotedSenderName]) const handlePlayVideo = useCallback(async () => { if (!videoInfo?.videoUrl) return @@ -9023,13 +9063,10 @@ function MessageBubble({ } return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderReferContent()} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderReferContent()),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9122,13 +9159,10 @@ function MessageBubble({ const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const referContent = message.quotedContent || q('refermsg > content') || '' return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderTextWithEmoji(cleanMessageContent(referContent))} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9338,13 +9372,10 @@ function MessageBubble({ } return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderReferContent2()} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderReferContent2()),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9623,14 +9654,9 @@ function MessageBubble({ // 带引用的消息 if (hasQuote) { - return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderTextWithEmoji(cleanMessageContent(quotedContent))} -
-
{renderTextWithEmoji(cleanedParsedContent)}
-
+ return renderBubbleWithQuote( + renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))), +
{renderTextWithEmoji(cleanedParsedContent)}
) } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 549b4ea..1f4a6cc 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -49,6 +49,7 @@ import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm' +import { Avatar } from '../components/Avatar' import type { SnsPost } from '../types/sns' import { cloneExportDateRange, @@ -538,6 +539,14 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } +const normalizeExportAvatarUrl = (value?: string | null): string | undefined => { + const normalized = String(value || '').trim() + if (!normalized) return undefined + const lower = normalized.toLowerCase() + if (lower === 'null' || lower === 'undefined') return undefined + return normalized +} + const toComparableNameSet = (values: Array): Set => { const set = new Set() for (const value of values) { @@ -1713,6 +1722,7 @@ function ExportPage() { startIndex: 0, endIndex: -1 }) + const avatarHydrationRequestedRef = useRef>(new Set()) const sessionMutualFriendsMetricsRef = useRef>({}) const sessionMutualFriendsDirectMetricsRef = useRef>({}) const sessionMutualFriendsQueueRef = useRef([]) @@ -1957,6 +1967,7 @@ function ExportPage() { displayName: contact.displayName, remark: contact.remark, nickname: contact.nickname, + alias: contact.alias, type: contact.type })) ).catch((error) => { @@ -1998,6 +2009,94 @@ function ExportPage() { } }, [ensureExportCacheScope, syncContactTypeCounts]) + const hydrateVisibleContactAvatars = useCallback(async (usernames: string[]) => { + const targets = Array.from(new Set( + (usernames || []) + .map((username) => String(username || '').trim()) + .filter(Boolean) + )).filter((username) => { + if (avatarHydrationRequestedRef.current.has(username)) return false + const contact = contactsList.find((item) => item.username === username) + const session = sessions.find((item) => item.username === username) + const existingAvatarUrl = normalizeExportAvatarUrl(contact?.avatarUrl || session?.avatarUrl) + return !existingAvatarUrl + }) + + if (targets.length === 0) return + targets.forEach((username) => avatarHydrationRequestedRef.current.add(username)) + + const settled = await Promise.allSettled( + targets.map(async (username) => { + const profile = await window.electronAPI.chat.getContactAvatar(username) + return { + username, + avatarUrl: normalizeExportAvatarUrl(profile?.avatarUrl), + displayName: profile?.displayName ? String(profile.displayName).trim() : undefined + } + }) + ) + + const avatarPatches = new Map() + for (const item of settled) { + if (item.status !== 'fulfilled') continue + const { username, avatarUrl, displayName } = item.value + if (!avatarUrl && !displayName) continue + avatarPatches.set(username, { avatarUrl, displayName }) + } + if (avatarPatches.size === 0) return + + const now = Date.now() + setContactsList((prev) => prev.map((contact) => { + const patch = avatarPatches.get(contact.username) + if (!patch) return contact + return { + ...contact, + displayName: patch.displayName || contact.displayName, + avatarUrl: patch.avatarUrl || contact.avatarUrl + } + })) + setSessions((prev) => prev.map((session) => { + const patch = avatarPatches.get(session.username) + if (!patch) return session + return { + ...session, + displayName: patch.displayName || session.displayName, + avatarUrl: patch.avatarUrl || session.avatarUrl + } + })) + setSessionDetail((prev) => { + if (!prev) return prev + const patch = avatarPatches.get(prev.wxid) + if (!patch) return prev + return { + ...prev, + displayName: patch.displayName || prev.displayName, + avatarUrl: patch.avatarUrl || prev.avatarUrl + } + }) + + let avatarCacheChanged = false + for (const [username, patch] of avatarPatches.entries()) { + if (!patch.avatarUrl) continue + const previous = contactsAvatarCacheRef.current[username] + if (previous?.avatarUrl === patch.avatarUrl) continue + contactsAvatarCacheRef.current[username] = { + avatarUrl: patch.avatarUrl, + updatedAt: now, + checkedAt: now + } + avatarCacheChanged = true + } + if (avatarCacheChanged) { + setAvatarCacheUpdatedAt(now) + const scopeKey = exportCacheScopeRef.current + if (scopeKey) { + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch(() => {}) + } + } + }, [contactsList, sessions]) + + useEffect(() => { if (!isExportRoute) return let cancelled = false @@ -3824,10 +3923,12 @@ function ExportPage() { displayName: contact.displayName || contact.username, remark: contact.remark, nickname: contact.nickname, + alias: contact.alias, type: contact.type })) const persistAt = Date.now() + setContactsList(contactsForPersist) setSessions(nextSessions) sessionsHydratedAtRef.current = persistAt if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) { @@ -5380,6 +5481,11 @@ function ExportPage() { const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } + void hydrateVisibleContactAvatars( + filteredContacts + .slice(startIndex, endIndex + 1) + .map((contact) => contact.username) + ) const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length === 0) return enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) @@ -5395,10 +5501,23 @@ function ExportPage() { enqueueSessionMediaMetricRequests, enqueueSessionMutualFriendsRequests, filteredContacts, + hydrateVisibleContactAvatars, scheduleSessionMediaMetricWorker, scheduleSessionMutualFriendsWorker ]) + useEffect(() => { + if (filteredContacts.length === 0) return + const bootstrapTargets = filteredContacts.slice(0, 24).map((contact) => contact.username) + void hydrateVisibleContactAvatars(bootstrapTargets) + }, [filteredContacts, hydrateVisibleContactAvatars]) + + useEffect(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId) return + void hydrateVisibleContactAvatars([sessionId]) + }, [hydrateVisibleContactAvatars, sessionDetail?.wxid]) + useEffect(() => { if (activeTaskCount > 0) return if (filteredContacts.length === 0) return @@ -5750,7 +5869,7 @@ function ExportPage() { displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId, remark: sameSession ? prev?.remark : mappedContact?.remark, nickName: sameSession ? prev?.nickName : mappedContact?.nickname, - alias: sameSession ? prev?.alias : undefined, + alias: sameSession ? prev?.alias : mappedContact?.alias, avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN), voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined), @@ -6627,11 +6746,12 @@ function ExportPage() {
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} - )} +
{contact.displayName}
@@ -7514,11 +7634,12 @@ function ExportPage() {
- {sessionMutualFriendsDialogTarget.avatarUrl ? ( - - ) : ( - {getAvatarLetter(sessionMutualFriendsDialogTarget.displayName)} - )} +

{sessionMutualFriendsDialogTarget.displayName} 的共同好友

@@ -7599,11 +7720,12 @@ function ExportPage() {
- {sessionDetail?.avatarUrl ? ( - - ) : ( - {getAvatarLetter(sessionDetail?.displayName || sessionDetail?.wxid || '')} - )} +

{sessionDetail?.displayName || '会话详情'}

diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 6b8bf8c..2de9358 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1145,6 +1145,134 @@ } } +.quote-layout-picker { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-top: 10px; +} + +.quote-layout-card { + position: relative; + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 14px; + background: var(--bg-primary); + color: inherit; + cursor: pointer; + text-align: left; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + transform: translateY(-1px); + } + + &.active { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); + background: color-mix(in srgb, var(--bg-primary) 92%, var(--primary) 8%); + } +} + +.quote-layout-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.quote-layout-card-title-group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.quote-layout-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.quote-layout-card-desc { + font-size: 12px; + color: var(--text-tertiary); +} + +.quote-layout-card-check { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid var(--border-color); + color: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s ease; + + &.active { + border-color: var(--primary); + background: var(--primary); + color: #fff; + } +} + +.quote-layout-preview { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + min-height: 112px; + + &.quote-bottom { + .quote-layout-preview-message { + order: 1; + } + + .quote-layout-preview-quote { + order: 2; + } + } +} + +.quote-layout-preview-quote { + padding: 8px 10px; + border-left: 2px solid var(--primary); + border-radius: 8px; + background: rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + gap: 3px; +} + +.quote-layout-preview-sender { + font-size: 12px; + font-weight: 600; + color: var(--primary); +} + +.quote-layout-preview-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.quote-layout-preview-message { + align-self: flex-start; + max-width: 88%; + padding: 9px 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--primary) 14%, var(--bg-primary)); + color: var(--text-primary); + font-size: 13px; + line-height: 1.45; +} + .theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index bfed9b1..36fc4ed 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -118,6 +118,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterList, setNotificationFilterList] = useState([]) const [windowCloseBehavior, setWindowCloseBehavior] = useState('ask') + const [quoteLayout, setQuoteLayout] = useState('quote-top') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) @@ -314,6 +315,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() + const savedQuoteLayout = await configService.getQuoteLayout() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() @@ -351,6 +353,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) setWindowCloseBehavior(savedWindowCloseBehavior) + setQuoteLayout(savedQuoteLayout) const savedExcludeWords = await configService.getWordCloudExcludeWords() setWordCloudExcludeWords(savedExcludeWords) @@ -1058,6 +1061,77 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ))}
+
+ + 选择聊天中引用消息与正文的上下顺序,右侧预览会同步展示布局差异。 +
+ {[ + { + value: 'quote-top' as const, + label: '引用在上', + description: '更接近当前 WeFlow 风格', + successMessage: '已切换为引用在上样式' + }, + { + value: 'quote-bottom' as const, + label: '正文在上', + description: '更接近微信 / 密语风格', + successMessage: '已切换为正文在上样式' + } + ].map(option => { + const selected = quoteLayout === option.value + const quotePreview = ( +
+ 张三 + 这是一条被引用的消息 +
+ ) + const messagePreview = ( +
这是当前发送的回复内容
+ ) + + return ( + + ) + })} +
+
+
diff --git a/src/services/config.ts b/src/services/config.ts index f5bfb53..37c404f 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -66,6 +66,7 @@ export const CONFIG_KEYS = { NOTIFICATION_FILTER_LIST: 'notificationFilterList', MESSAGE_PUSH_ENABLED: 'messagePushEnabled', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', + QUOTE_LAYOUT: 'quoteLayout', // 词云 WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords', @@ -90,6 +91,7 @@ export interface ExportDefaultMediaConfig { } export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' +export type QuoteLayout = 'quote-top' | 'quote-bottom' const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { images: true, @@ -660,6 +662,7 @@ export interface ContactsListCacheContact { displayName: string remark?: string nickname?: string + alias?: string type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } @@ -1172,6 +1175,7 @@ export async function getContactsListCache(scopeKey: string): Promise { + const value = await config.get(CONFIG_KEYS.QUOTE_LAYOUT) + if (value === 'quote-bottom') return value + return 'quote-top' +} + +export async function setQuoteLayout(layout: QuoteLayout): Promise { + await config.set(CONFIG_KEYS.QUOTE_LAYOUT, layout) +} + // 获取词云排除词列表 export async function getWordCloudExcludeWords(): Promise { const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)