refactor(database): migrate to new modular database layer and unify data access
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 12m51s
Frontend CI / build-android-apk (push) Successful in 1h1m26s

- Remove legacy database.ts, LocalDataSource.ts, and MessageRepository.ts
- Create new src/database/ module with messageRepository, userCacheRepository, conversationRepository, and groupCacheRepository
- Update all consumers to import from @/database instead of services/database
- Add web platform blur handling for modal components to fix focus issues
- Flatten SystemMessageItem and NotificationsScreen styles for consistent design
- Add draggable slider in ChatSettingsScreen and dynamic font size support
- Introduce 9 new chat color themes
- Add profile screens for about, terms, and privacy policy with navigation routes
- Add policy links to login and registration screens
- Fix post share URL format from /posts/ to /post/
This commit is contained in:
lafay
2026-04-04 08:01:45 +08:00
parent 189b977fac
commit 82c2970a85
76 changed files with 3382 additions and 2000 deletions

View File

@@ -0,0 +1,197 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { ConversationResponse, ConversationDetailResponse } from '@/types/dto';
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IConversationRepository {
save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void>;
getAll(): Promise<any[]>;
updateUnreadCount(id: string, count: number): Promise<void>;
updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void>;
delete(conversationId: string): Promise<void>;
getCache(id: string): Promise<ConversationCacheData | null>;
getListCache(): Promise<ConversationResponse[]>;
saveCache(conv: ConversationCacheData): Promise<void>;
saveListCache(convs: ConversationResponse[]): Promise<void>;
updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void>;
updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void>;
saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void>;
}
export class ConversationRepository implements IConversationRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[conv.id, conv.participantId, conv.lastMessageId || null, conv.lastSeq || 0, conv.unreadCount || 0, conv.createdAt, conv.updatedAt]
);
});
}
async getAll(): Promise<any[]> {
return this.dataSource.query<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`);
}
async updateUnreadCount(id: string, count: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE conversations SET unreadCount = ? WHERE id = ?`, [count, id]);
});
}
async updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
[lastMessageId, lastSeq, updatedAt, id]
);
});
}
async delete(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
await this.dataSource.run(`DELETE FROM conversations WHERE id = ?`, [conversationId]);
});
}
async getCache(id: string): Promise<ConversationCacheData | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
return r?.data ? safeParseJson<ConversationCacheData>(r.data) : null;
}
async getListCache(): Promise<ConversationResponse[]> {
let rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`
);
if (!rows.length) {
rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`
);
}
const list = rows.map(r => safeParseJson<ConversationResponse>(r.data))
.filter((c): c is ConversationResponse => Boolean(c))
.filter(c => typeof c.id !== 'undefined' && typeof c.type !== 'undefined');
return list.sort((a, b) => {
const aPinned = a.is_pinned ? 1 : 0;
const bPinned = b.is_pinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
return bTime - aTime;
});
}
async saveCache(conv: ConversationCacheData): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const updatedAt = ('updated_at' in conv && conv.updated_at) ? String(conv.updated_at) : new Date().toISOString();
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(conv.id), JSON.stringify(conv), updatedAt]
);
});
}
async saveListCache(convs: ConversationResponse[]): Promise<void> {
if (!convs?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || new Date().toISOString()]
);
}
});
}
async updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (!r?.data) return;
const conv = safeParseJson<ConversationResponse>(r.data);
if (!conv) return;
const updated = { ...conv, ...updates };
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
[JSON.stringify(updated), updatedAt, String(id)]
);
});
}
async updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const listRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (listRow?.data) {
const conv = safeParseJson<ConversationResponse>(listRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
const cacheRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
if (cacheRow?.data) {
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
});
}
async saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void> {
if (!convs?.length) return;
const now = new Date().toISOString();
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || now]
);
}
if (users?.length) {
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
}
if (groups?.length) {
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
}
});
}
}
export const conversationRepository = new ConversationRepository();

View File

@@ -0,0 +1,78 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { GroupResponse, GroupMemberResponse } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IGroupCacheRepository {
save(group: GroupResponse): Promise<void>;
saveBatch(groups: GroupResponse[]): Promise<void>;
get(groupId: string): Promise<GroupResponse | null>;
getAll(): Promise<GroupResponse[]>;
saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void>;
getMembers(groupId: string): Promise<GroupMemberResponse[]>;
}
export class GroupCacheRepository implements IGroupCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(group: GroupResponse): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(group.id), JSON.stringify(group), new Date().toISOString()]
);
});
}
async saveBatch(groups: GroupResponse[]): Promise<void> {
if (!groups?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
});
}
async get(groupId: string): Promise<GroupResponse | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM groups WHERE id = ?`, [String(groupId)]
);
return r?.data ? safeParseJson<GroupResponse>(r.data) : null;
}
async getAll(): Promise<GroupResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM groups ORDER BY updatedAt DESC`
);
return rows.map(r => safeParseJson<GroupResponse>(r.data)).filter((g): g is GroupResponse => Boolean(g));
}
async saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
await this.dataSource.run(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
for (const m of members) {
await this.dataSource.run(
`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
[String(groupId), String(m.user_id), JSON.stringify(m), now]
);
}
});
}
async getMembers(groupId: string): Promise<GroupMemberResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`, [String(groupId)]
);
return rows.map(r => safeParseJson<GroupMemberResponse>(r.data)).filter((m): m is GroupMemberResponse => Boolean(m));
}
}
export const groupCacheRepository = new GroupCacheRepository();

View File

@@ -0,0 +1,152 @@
import { localDataSource } from '../LocalDataSource';
import { CachedMessage } from '../types';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IMessageRepository {
saveMessage(message: CachedMessage): Promise<void>;
saveMessagesBatch(messages: CachedMessage[]): Promise<void>;
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
getMaxSeq(conversationId: string): Promise<number>;
getMinSeq(conversationId: string): Promise<number>;
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
getCount(conversationId: string): Promise<number>;
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
markAsRead(messageId: string): Promise<void>;
markConversationAsRead(conversationId: string): Promise<void>;
delete(messageId: string): Promise<void>;
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
clearConversation(conversationId: string): Promise<void>;
search(keyword: string): Promise<CachedMessage[]>;
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
}
export class MessageRepository implements IMessageRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async saveMessage(message: CachedMessage): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[message.id, message.conversationId, message.senderId, message.content || '',
message.type || 'text', message.isRead ? 1 : 0, message.createdAt,
message.seq || 0, message.status || 'normal',
message.segments ? JSON.stringify(message.segments) : null]
);
});
}
async saveMessagesBatch(messages: CachedMessage[]): Promise<void> {
if (!messages?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const msg of messages) {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[msg.id, msg.conversationId, msg.senderId, msg.content || '',
msg.type || 'text', msg.isRead ? 1 : 0, msg.createdAt,
msg.seq || 0, msg.status || 'normal',
msg.segments ? JSON.stringify(msg.segments) : null]
);
}
});
}
async getByConversation(conversationId: string, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? ORDER BY seq DESC LIMIT ?`,
[conversationId, limit]
);
return rows.reverse().map(r => ({
...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined
}));
}
async getMaxSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ maxSeq: number }>(
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.maxSeq || 0;
}
async getMinSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ minSeq: number }>(
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.minSeq || 0;
}
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
[conversationId, beforeSeq, limit]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
}
async getCount(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.count || 0;
}
async getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, [conversationId, beforeSeq]
);
return r?.count || 0;
}
async markAsRead(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE id = ?`, [messageId]);
});
}
async markConversationAsRead(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE conversationId = ?`, [conversationId]);
});
}
async delete(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE id = ?`, [messageId]);
});
}
async updateStatus(messageId: string, status: string, clearContent: boolean = false): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
if (clearContent) {
await this.dataSource.run(
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
[status, messageId]
);
} else {
await this.dataSource.run(`UPDATE messages SET status = ? WHERE id = ?`, [status, messageId]);
}
});
}
async clearConversation(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
});
}
async search(keyword: string): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
}
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
return { totalMessages: msgCount?.count || 0, totalConversations: convCount?.count || 0 };
}
}
export const messageRepository = new MessageRepository();

View File

@@ -0,0 +1,44 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IPostCacheRepository {
save(id: string, data: any): Promise<void>;
get(id: string): Promise<any | null>;
getAll(): Promise<any[]>;
delete(id: string): Promise<void>;
}
export class PostCacheRepository implements IPostCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(id: string, data: any): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(id), JSON.stringify(data), new Date().toISOString()]
);
});
}
async get(id: string): Promise<any | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM posts_cache WHERE id = ?`, [String(id)]
);
return r?.data ? JSON.parse(r.data) : null;
}
async getAll(): Promise<any[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM posts_cache ORDER BY updatedAt DESC`
);
return rows.map(r => JSON.parse(r.data));
}
async delete(id: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM posts_cache WHERE id = ?`, [String(id)]);
});
}
}
export const postCacheRepository = new PostCacheRepository();

View File

@@ -0,0 +1,81 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { UserDTO } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IUserCacheRepository {
save(user: UserDTO): Promise<void>;
saveBatch(users: UserDTO[]): Promise<void>;
get(userId: string): Promise<UserDTO | null>;
getLatest(): Promise<UserDTO | null>;
saveCurrent(user: UserDTO): Promise<void>;
getCurrent(): Promise<UserDTO | null>;
clearCurrent(): Promise<void>;
}
export class UserCacheRepository implements IUserCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(user.id), JSON.stringify(user), new Date().toISOString()]
);
});
}
async saveBatch(users: UserDTO[]): Promise<void> {
if (!users?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
});
}
async get(userId: string): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users WHERE id = ?`, [String(userId)]
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async getLatest(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async saveCurrent(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
['me', JSON.stringify(user), new Date().toISOString()]
);
});
}
async getCurrent(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM current_user_cache WHERE id = 'me'`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async clearCurrent(): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM current_user_cache WHERE id = 'me'`);
});
}
}
export const userCacheRepository = new UserCacheRepository();

View File

@@ -0,0 +1,5 @@
export { IMessageRepository, MessageRepository, messageRepository } from './MessageRepository';
export { IConversationRepository, ConversationRepository, conversationRepository } from './ConversationRepository';
export { IUserCacheRepository, UserCacheRepository, userCacheRepository } from './UserCacheRepository';
export { IGroupCacheRepository, GroupCacheRepository, groupCacheRepository } from './GroupCacheRepository';
export { IPostCacheRepository, PostCacheRepository, postCacheRepository } from './PostCacheRepository';