feat(rn): add xuqm_release Node.js script, expand IM SDK with friends/groups/conversations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-04-29 00:37:10 +08:00
父节点 d00231279e
当前提交 3d488250ee
共有 9 个文件被更改,包括 419 次插入5 次删除

查看文件

@ -12,6 +12,7 @@ import type {
ImGroup, ImGroup,
ImMessage, ImMessage,
MsgType, MsgType,
PageResult,
UserProfile, UserProfile,
} from './types' } from './types'
import { uploadFile } from './upload' import { uploadFile } from './upload'
@ -20,6 +21,10 @@ let client: ImClient | null = null
let _currentUserId: string | null = null let _currentUserId: string | null = null
const draftStore = new Map<string, string>() const draftStore = new Map<string, string>()
const listenerMap = new WeakMap<ImEventListener, ImEventListener>() const listenerMap = new WeakMap<ImEventListener, ImEventListener>()
let conversationMemory: ConversationData[] = []
const conversationListeners = new Set<(conversations: ConversationData[]) => void>()
const messageConversationMemory = new Map<string, { targetId: string; chatType: ChatType }>()
const conversationLastMessageMemory = new Map<string, string>()
function generateMessageId(): string { function generateMessageId(): string {
const cryptoApi = globalThis as typeof globalThis & { crypto?: { randomUUID?: () => string } } const cryptoApi = globalThis as typeof globalThis & { crypto?: { randomUUID?: () => string } }
@ -110,6 +115,142 @@ function normalizeConversation(item: {
} }
} }
function sortConversations(conversations: ConversationData[]): ConversationData[] {
return [...conversations].sort((a, b) => {
if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned)
return b.lastMsgTime - a.lastMsgTime
})
}
function conversationKey(targetId: string, chatType: ChatType): string {
return `${chatType}:${targetId}`
}
function emitConversationMemory(): void {
const snapshot = sortConversations(conversationMemory)
conversationMemory = snapshot
conversationListeners.forEach(listener => listener(snapshot))
}
function upsertConversationMemory(conversation: ConversationData): void {
const index = conversationMemory.findIndex(
item => item.targetId === conversation.targetId && item.chatType === conversation.chatType,
)
if (index >= 0) {
const existing = conversationMemory[index]
conversationMemory[index] = {
...existing,
...conversation,
unreadCount: conversation.unreadCount,
isMuted: conversation.isMuted,
isPinned: conversation.isPinned,
}
} else {
conversationMemory = [...conversationMemory, conversation]
}
emitConversationMemory()
}
function markConversationReadMemory(targetId: string, chatType: ChatType): void {
conversationMemory = conversationMemory.map(item =>
item.targetId === targetId && item.chatType === chatType
? { ...item, unreadCount: 0 }
: item,
)
emitConversationMemory()
}
function setConversationMutedMemory(targetId: string, chatType: ChatType, muted: boolean): void {
conversationMemory = conversationMemory.map(item =>
item.targetId === targetId && item.chatType === chatType
? { ...item, isMuted: muted }
: item,
)
emitConversationMemory()
}
function setConversationPinnedMemory(targetId: string, chatType: ChatType, pinned: boolean): void {
conversationMemory = conversationMemory.map(item =>
item.targetId === targetId && item.chatType === chatType
? { ...item, isPinned: pinned }
: item,
)
emitConversationMemory()
}
function deleteConversationMemory(targetId: string, chatType: ChatType): void {
conversationMemory = conversationMemory.filter(
item => !(item.targetId === targetId && item.chatType === chatType),
)
emitConversationMemory()
}
function applyMessageToMemory(message: ImMessage): void {
const normalized = normalizeMessage(message)
const key = conversationKey(normalized.toId, normalized.chatType)
messageConversationMemory.set(normalized.id, { targetId: normalized.toId, chatType: normalized.chatType })
conversationLastMessageMemory.set(key, normalized.id)
const index = conversationMemory.findIndex(
item => item.targetId === normalized.toId && item.chatType === normalized.chatType,
)
const revoked = normalized.revoked || normalized.status === 'REVOKED' || normalized.msgType === 'REVOKED'
const lastMsgContent = revoked ? '消息已撤回' : normalized.content
const unreadDelta = !revoked && normalized.fromId !== _currentUserId ? 1 : 0
const nextConversation: ConversationData = {
targetId: normalized.toId,
chatType: normalized.chatType,
lastMsgContent,
lastMsgType: revoked ? 'REVOKED' : normalized.msgType,
lastMsgTime: normalized.createdAt,
unreadCount: unreadDelta,
isMuted: index >= 0 ? conversationMemory[index].isMuted : false,
isPinned: index >= 0 ? conversationMemory[index].isPinned : false,
}
if (index >= 0) {
const current = conversationMemory[index]
conversationMemory[index] = {
...current,
...nextConversation,
unreadCount: unreadDelta > 0 ? current.unreadCount + unreadDelta : current.unreadCount,
}
} else {
conversationMemory = [...conversationMemory, nextConversation]
}
emitConversationMemory()
}
function applyRevokeToMemory(messageId: string): void {
const messageConversation = messageConversationMemory.get(messageId)
if (!messageConversation) return
const key = conversationKey(messageConversation.targetId, messageConversation.chatType)
if (conversationLastMessageMemory.get(key) !== messageId) return
conversationMemory = conversationMemory.map(item =>
item.targetId === messageConversation.targetId && item.chatType === messageConversation.chatType
? { ...item, lastMsgContent: '消息已撤回', lastMsgType: 'REVOKED' }
: item,
)
emitConversationMemory()
}
function applyEditToMemory(message: ImMessage): void {
const normalized = normalizeMessage(message)
const messageConversation = messageConversationMemory.get(normalized.id)
if (!messageConversation) return
const key = conversationKey(messageConversation.targetId, messageConversation.chatType)
if (conversationLastMessageMemory.get(key) !== normalized.id) return
conversationMemory = conversationMemory.map(item =>
item.targetId === messageConversation.targetId && item.chatType === messageConversation.chatType
? {
...item,
lastMsgContent: normalized.content,
lastMsgType: normalized.msgType,
lastMsgTime: normalized.createdAt,
}
: item,
)
emitConversationMemory()
}
function formatQueryDateTime(value?: Date | string | number): string | undefined { function formatQueryDateTime(value?: Date | string | number): string | undefined {
if (value === undefined || value === null) return undefined if (value === undefined || value === null) return undefined
if (typeof value === 'string') return value if (typeof value === 'string') return value
@ -236,7 +377,9 @@ export const ImSDK = {
content: model.content, content: model.content,
status: model.status as ImMessage['status'], status: model.status as ImMessage['status'],
mentionedUserIds: model.mentionedUserIds ?? undefined, mentionedUserIds: model.mentionedUserIds ?? undefined,
revoked: model.status === 'REVOKED',
createdAt: model.serverCreatedAt, createdAt: model.serverCreatedAt,
editedAt: model.editedAt ?? undefined,
})) }))
} }
} }
@ -295,7 +438,9 @@ export const ImSDK = {
content: model.content, content: model.content,
status: model.status as ImMessage['status'], status: model.status as ImMessage['status'],
mentionedUserIds: model.mentionedUserIds ?? undefined, mentionedUserIds: model.mentionedUserIds ?? undefined,
revoked: model.status === 'REVOKED',
createdAt: model.serverCreatedAt, createdAt: model.serverCreatedAt,
editedAt: model.editedAt ?? undefined,
})) }))
} }
} }
@ -398,12 +543,16 @@ export const ImSDK = {
const finalMsg = normalizeMessage(msg, outgoing) const finalMsg = normalizeMessage(msg, outgoing)
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(finalMsg, _currentUserId) await ImDatabase.saveMessage(finalMsg, _currentUserId)
} else {
applyMessageToMemory(finalMsg)
} }
return finalMsg return finalMsg
} catch (error) { } catch (error) {
const failed = { ...outgoing, status: 'FAILED' as const } const failed = { ...outgoing, status: 'FAILED' as const }
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(failed, _currentUserId) await ImDatabase.saveMessage(failed, _currentUserId)
} else {
applyMessageToMemory(failed)
} }
return failed return failed
} }
@ -562,6 +711,10 @@ export const ImSDK = {
}) })
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
await ImDatabase.revokeMessage(config.appId, messageId)
} else {
applyMessageToMemory(msg)
applyRevokeToMemory(messageId)
} }
return msg return msg
}, },
@ -575,6 +728,8 @@ export const ImSDK = {
}) })
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
} else {
applyEditToMemory(msg)
} }
return msg return msg
}, },
@ -611,6 +766,26 @@ export const ImSDK = {
return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}`) return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}`)
}, },
async listGroupMembers(groupId: string): Promise<UserProfile[]> {
const config = getConfig()
const res = await apiRequest<UserProfile[] | { content?: UserProfile[] }>(`/api/im/groups/${encodeURIComponent(groupId)}/members`, {
params: { appId: config.appId },
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async searchGroupMembers(groupId: string, keyword: string, size = 20): Promise<UserProfile[]> {
const config = getConfig()
const res = await apiRequest<UserProfile[] | { content?: UserProfile[] }>(`/api/im/groups/${encodeURIComponent(groupId)}/members/search`, {
params: {
appId: config.appId,
keyword,
size: String(size),
},
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async updateGroupInfo(groupId: string, name?: string, announcement?: string): Promise<ImGroup> { async updateGroupInfo(groupId: string, name?: string, announcement?: string): Promise<ImGroup> {
return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}`, { return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}`, {
method: 'PUT', method: 'PUT',
@ -840,7 +1015,7 @@ export const ImSDK = {
if (ImDatabase.isInitialized()) { if (ImDatabase.isInitialized()) {
const models = await ImDatabase.getConversations(config.appId) const models = await ImDatabase.getConversations(config.appId)
if (models.length > 0) { if (models.length > 0) {
return models.map(model => normalizeConversation({ const conversations = models.map(model => normalizeConversation({
targetId: model.targetId, targetId: model.targetId,
chatType: model.chatType, chatType: model.chatType,
lastMsgContent: model.lastMsgContent, lastMsgContent: model.lastMsgContent,
@ -850,6 +1025,9 @@ export const ImSDK = {
isMuted: model.isMuted, isMuted: model.isMuted,
isPinned: model.isPinned, isPinned: model.isPinned,
})) }))
conversationMemory = conversations
emitConversationMemory()
return conversations
} }
} }
@ -857,12 +1035,19 @@ export const ImSDK = {
params: { appId: config.appId }, params: { appId: config.appId },
}) })
const conversations = Array.isArray(res) ? res : (res.content ?? []) const conversations = Array.isArray(res) ? res : (res.content ?? [])
return conversations.map(normalizeConversation) const normalized = conversations.map(normalizeConversation)
conversationMemory = normalized
emitConversationMemory()
return normalized
}, },
subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void { subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void {
if (!ImDatabase.isInitialized()) { if (!ImDatabase.isInitialized()) {
return () => {} conversationListeners.add(callback)
callback(sortConversations(conversationMemory))
return () => {
conversationListeners.delete(callback)
}
} }
const config = getConfig() const config = getConfig()
return ImDatabase.subscribeConversations(config.appId, (models) => { return ImDatabase.subscribeConversations(config.appId, (models) => {
@ -888,6 +1073,8 @@ export const ImSDK = {
}) })
if (ImDatabase.isInitialized()) { if (ImDatabase.isInitialized()) {
await ImDatabase.markRead(config.appId, targetId) await ImDatabase.markRead(config.appId, targetId)
} else {
markConversationReadMemory(targetId, chatType)
} }
}, },
@ -903,6 +1090,8 @@ export const ImSDK = {
}) })
if (ImDatabase.isInitialized()) { if (ImDatabase.isInitialized()) {
await ImDatabase.setConversationMuted(config.appId, targetId, muted) await ImDatabase.setConversationMuted(config.appId, targetId, muted)
} else {
setConversationMutedMemory(targetId, chatType, muted)
} }
}, },
@ -918,6 +1107,8 @@ export const ImSDK = {
}) })
if (ImDatabase.isInitialized()) { if (ImDatabase.isInitialized()) {
await ImDatabase.setConversationPinned(config.appId, targetId, pinned) await ImDatabase.setConversationPinned(config.appId, targetId, pinned)
} else {
setConversationPinnedMemory(targetId, chatType, pinned)
} }
}, },
@ -953,6 +1144,8 @@ export const ImSDK = {
}) })
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.deleteConversation(config.appId, targetId, chatType, _currentUserId) await ImDatabase.deleteConversation(config.appId, targetId, chatType, _currentUserId)
} else {
deleteConversationMemory(targetId, chatType)
} }
}, },
@ -978,28 +1171,41 @@ export const ImSDK = {
onMessage: async (msg) => { onMessage: async (msg) => {
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
} else {
applyMessageToMemory(normalizeMessage(msg))
} }
listener.onMessage?.(normalizeMessage(msg)) listener.onMessage?.(normalizeMessage(msg))
}, },
onGroupMessage: async (msg) => { onGroupMessage: async (msg) => {
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
} else {
applyMessageToMemory(normalizeMessage(msg))
} }
listener.onGroupMessage?.(normalizeMessage(msg)) listener.onGroupMessage?.(normalizeMessage(msg))
}, },
onSystemMessage: async (msg) => { onSystemMessage: async (msg) => {
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
} else {
applyMessageToMemory(normalizeMessage(msg))
} }
listener.onSystemMessage?.(normalizeMessage(msg)) listener.onSystemMessage?.(normalizeMessage(msg))
}, },
onRead: async (msg) => { onRead: async (msg) => {
if (ImDatabase.isInitialized() && _currentUserId) { if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
} else {
applyMessageToMemory(normalizeMessage(msg))
} }
listener.onRead?.(normalizeMessage(msg)) listener.onRead?.(normalizeMessage(msg))
}, },
onRevoke: async (data) => { onRevoke: async (data) => {
if (ImDatabase.isInitialized()) {
await ImDatabase.revokeMessage(getConfig().appId, data.msgId)
} else {
applyRevokeToMemory(data.msgId)
}
listener.onRevoke?.(data) listener.onRevoke?.(data)
}, },
} }

查看文件

@ -74,12 +74,14 @@ export const ImDatabase = {
m.status = msg.status m.status = msg.status
m.mentionedUserIds = msg.mentionedUserIds ?? null m.mentionedUserIds = msg.mentionedUserIds ?? null
m.serverCreatedAt = new Date(msg.createdAt).getTime() m.serverCreatedAt = new Date(msg.createdAt).getTime()
m.editedAt = msg.editedAt ?? null
m.syncedAt = now m.syncedAt = now
}) })
} else { } else {
await existing[0].update((m: MessageModel) => { await existing[0].update((m: MessageModel) => {
m.status = msg.status m.status = msg.status
m.content = msg.content m.content = msg.content
m.editedAt = msg.editedAt ?? m.editedAt
m.syncedAt = now m.syncedAt = now
}) })
} }
@ -124,6 +126,42 @@ export const ImDatabase = {
}) })
}, },
async revokeMessage(appId: string, messageId: string): Promise<void> {
const db = getDb()
const messages = await db
.get<MessageModel>('im_messages')
.query(Q.where('app_id', appId), Q.where('server_id', messageId))
.fetch()
if (messages.length === 0) return
const message = messages[0]
const now = Date.now()
const revokedContent = '消息已撤回'
await db.write(async () => {
await message.update((m: MessageModel) => {
m.status = 'REVOKED'
m.content = revokedContent
m.syncedAt = now
})
const conversations = await db
.get<ConversationModel>('im_conversations')
.query(Q.where('app_id', appId), Q.where('target_id', message.toId))
.fetch()
if (conversations.length > 0) {
await conversations[0].update((c: ConversationModel) => {
if (c.lastMsgId === messageId) {
c.lastMsgContent = revokedContent
c.lastMsgType = 'REVOKED'
c.updatedAt = new Date()
}
})
}
})
},
async getMessages(appId: string, targetId: string, chatType: string, currentUserId: string, limit = 50): Promise<MessageModel[]> { async getMessages(appId: string, targetId: string, chatType: string, currentUserId: string, limit = 50): Promise<MessageModel[]> {
const db = getDb() const db = getDb()
const convId = conversationId(appId, currentUserId, targetId, chatType) const convId = conversationId(appId, currentUserId, targetId, chatType)

查看文件

@ -15,5 +15,6 @@ export class MessageModel extends Model {
@field('status') status!: string @field('status') status!: string
@field('mentioned_user_ids') mentionedUserIds!: string | null @field('mentioned_user_ids') mentionedUserIds!: string | null
@field('server_created_at') serverCreatedAt!: number @field('server_created_at') serverCreatedAt!: number
@field('edited_at') editedAt!: number | null
@field('synced_at') syncedAt!: number @field('synced_at') syncedAt!: number
} }

查看文件

@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb' import { appSchema, tableSchema } from '@nozbe/watermelondb'
export const imDbSchema = appSchema({ export const imDbSchema = appSchema({
version: 3, version: 4,
tables: [ tables: [
tableSchema({ tableSchema({
name: 'im_conversations', name: 'im_conversations',
@ -34,6 +34,7 @@ export const imDbSchema = appSchema({
{ name: 'status', type: 'string' }, { name: 'status', type: 'string' },
{ name: 'mentioned_user_ids', type: 'string', isOptional: true }, { name: 'mentioned_user_ids', type: 'string', isOptional: true },
{ name: 'server_created_at', type: 'number' }, { name: 'server_created_at', type: 'number' },
{ name: 'edited_at', type: 'number', isOptional: true },
{ name: 'synced_at', type: 'number' }, { name: 'synced_at', type: 'number' },
], ],
}), }),

查看文件

@ -33,6 +33,7 @@ export interface ImMessage {
groupReadCount?: number groupReadCount?: number
revoked?: boolean revoked?: boolean
createdAt: number createdAt: number
editedAt?: number | null
} }
export interface ImEventListener { export interface ImEventListener {

查看文件

@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* XuqmGroup Update Service React Native Bundle Release Script
*
* Platform-native approach: runs via Node.js (always available in RN projects).
* Add to your app's package.json scripts:
* "xuqm:release": "node node_modules/@xuqm/update/scripts/xuqm_release.mjs"
*
* Config: xuqm.config.json in the project root
* {
* "serverUrl": "https://update.dev.xuqinmin.com",
* "appId": "your-app-id",
* "apiToken": "your-api-token",
* "rn": {
* "modules": [
* { "moduleId": "main", "entryFile": "index.js", "platforms": ["android", "ios"] }
* ],
* "bundleOutputDir": "/tmp/xuqm_rn_bundle"
* }
* }
*
* Dependencies: none beyond Node.js built-ins (uses native fetch + node:child_process)
* Minimum Node.js: 18 (built-in fetch)
*/
import { execSync } from 'node:child_process'
import { createReadStream, mkdirSync, existsSync, readFileSync } from 'node:fs'
import { createInterface } from 'node:readline'
import path from 'node:path'
// ── Config ─────────────────────────────────────────────────────────────────
const CONFIG_FILE = process.env.XUQM_CONFIG_FILE ?? 'xuqm.config.json'
if (!existsSync(CONFIG_FILE)) {
console.error(`[xuqm] Config not found: ${CONFIG_FILE}`)
process.exit(1)
}
const cfg = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
const { serverUrl, appId, apiToken, rn = {} } = cfg
if (!serverUrl || !appId || !apiToken) {
console.error('[xuqm] serverUrl / appId / apiToken are required in config')
process.exit(1)
}
const modules = rn.modules ?? [{ moduleId: 'main', entryFile: 'index.js', platforms: ['android', 'ios'] }]
const bundleOutputDir = rn.bundleOutputDir ?? '/tmp/xuqm_rn_bundle'
// ── Helpers ─────────────────────────────────────────────────────────────────
const rl = createInterface({ input: process.stdin, output: process.stdout })
const ask = (q) => new Promise(res => rl.question(`\x1b[36m${q}\x1b[0m`, res))
const confirm = async (q) => /^y/i.test(((await ask(`${q} [y/N]: `)) ?? '').trim())
function apiHeaders() { return { Authorization: `Bearer ${apiToken}` } }
async function apiFetch(path, opts = {}) {
const res = await fetch(`${serverUrl}${path}`, { headers: apiHeaders(), ...opts })
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
return res.json()
}
// Multipart upload using FormData (Node 18+ has built-in FormData)
async function uploadBundle(moduleId, platform, bundleFile, version, minVersion, note) {
const form = new FormData()
const blob = new Blob([readFileSync(bundleFile)], { type: 'application/octet-stream' })
form.append('appId', appId)
form.append('moduleId', moduleId)
form.append('platform', platform.toUpperCase())
form.append('version', version)
form.append('minCommonVersion', minVersion)
form.append('note', note)
form.append('bundle', blob, path.basename(bundleFile))
const res = await fetch(`${serverUrl}/api/v1/rn/upload`, {
method: 'POST',
headers: apiHeaders(),
body: form,
})
if (!res.ok) throw new Error(`Upload failed: ${await res.text()}`)
return res.json()
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main() {
console.log('\x1b[36m=== XuqmGroup React Native Release ===\x1b[0m\n')
// ── 1. Local version from package.json ──────────────────────────────────
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
let localVersion = pkg.version ?? ''
console.log(`Local version (package.json): \x1b[32m${localVersion}\x1b[0m`)
// ── 2. Server latest ─────────────────────────────────────────────────────
let serverVersion = 'none'
try {
const resp = await apiFetch(`/api/v1/rn/list?appId=${appId}`)
const published = (resp.data ?? []).filter(x => x.publishStatus === 'PUBLISHED')
serverVersion = published[0]?.version ?? 'none'
} catch { /* no bundles yet */ }
console.log(`Server latest: \x1b[33m${serverVersion}\x1b[0m`)
// ── 3. Validate version ───────────────────────────────────────────────────
if (localVersion && serverVersion !== 'none' && localVersion <= serverVersion) {
console.warn(`\x1b[33m⚠ Local (${localVersion}) ≤ server (${serverVersion})\x1b[0m`)
const cont = await confirm('Continue anyway?')
if (!cont) { localVersion = await ask('New version string: ') }
}
if (!localVersion) localVersion = await ask('Version string (e.g. 1.2.3): ')
// ── 4. Options ────────────────────────────────────────────────────────────
const note = await ask('Release notes: ')
const minVersion = await ask('Min compatible native version (e.g. 1.0.0): ')
console.log('\n\x1b[36m--- Summary ---\x1b[0m')
console.log(` Version: ${localVersion}`)
console.log(` Modules: ${modules.map(m => m.moduleId).join(', ')}`)
const ok = await confirm('Proceed?')
if (!ok) { console.log('Aborted.'); rl.close(); return }
// ── 5. Build & upload each module/platform ────────────────────────────────
mkdirSync(bundleOutputDir, { recursive: true })
for (const mod of modules) {
for (const platform of mod.platforms) {
const outDir = path.join(bundleOutputDir, mod.moduleId, platform)
mkdirSync(outDir, { recursive: true })
const bundleFile = path.join(outDir, `${mod.moduleId}.${platform}.bundle`)
// Build using react-native CLI (platform-native, no extra deps)
console.log(`\n\x1b[36mBuilding ${mod.moduleId}/${platform}...\x1b[0m`)
execSync(
`npx react-native bundle \
--platform ${platform} \
--entry-file ${mod.entryFile ?? 'index.js'} \
--bundle-output ${bundleFile} \
--dev false \
--minify true`,
{ stdio: 'inherit' }
)
// Upload
console.log('\x1b[36mUploading...\x1b[0m')
const resp = await uploadBundle(mod.moduleId, platform, bundleFile, localVersion, minVersion, note)
const bundleId = resp.data?.id
console.log(`\x1b[32m✓ ${mod.moduleId}/${platform} uploaded, ID: ${bundleId}\x1b[0m`)
console.log(` Publish: POST ${serverUrl}/api/v1/rn/${bundleId}/publish`)
}
}
rl.close()
console.log('\n\x1b[32m=== Release complete ===\x1b[0m')
}
main().catch(e => {
console.error(`\n\x1b[31m❌ ${e.message}\x1b[0m`)
rl.close()
process.exit(1)
})

查看文件

@ -71,6 +71,15 @@ export const ImSDK = {
}) })
}, },
async editMessage(messageId: string, content: string): Promise<ImMessage> {
const config = getConfig()
return apiRequest<ImMessage>(`/api/im/messages/${encodeURIComponent(messageId)}`, {
method: 'PUT',
params: { appId: config.appId },
body: { content },
})
},
addListener(listener: ImEventListener) { addListener(listener: ImEventListener) {
client?.addListener(listener) client?.addListener(listener)
}, },

查看文件

@ -28,6 +28,7 @@ export interface ImMessage {
status: MsgStatus status: MsgStatus
mentionedUserIds?: string mentionedUserIds?: string
createdAt: string createdAt: string
editedAt?: string | null
} }
export interface ImEventListener { export interface ImEventListener {

查看文件

@ -10,7 +10,7 @@ export { ScaledImage } from '../packages/common/src'
export { ImSDK } from '../packages/im/src' export { ImSDK } from '../packages/im/src'
export { ImClient } from '../packages/im/src' export { ImClient } from '../packages/im/src'
export { uploadFile } from '../packages/im/src' export { uploadFile } from '../packages/im/src'
export { listFriends, addFriend, removeFriend } from '../packages/im/src' export { listFriends, addFriend, removeFriend, editMessage } from '../packages/im/src'
export type { export type {
ImMessage, ImGroup, ChatType, MsgType, MsgStatus, ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
ImEventListener, SendMessageParams, ImEventListener, SendMessageParams,