Initial commit of akmon project
This commit is contained in:
827
utils/chatDataService.uts
Normal file
827
utils/chatDataService.uts
Normal file
@@ -0,0 +1,827 @@
|
||||
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import AkSupaRealtime from '@/components/supadb/aksuparealtime.uts'
|
||||
import { WS_URL, SUPA_KEY } from '@/ak/config.uts'
|
||||
import type { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
|
||||
import MediaCacheService from '@/utils/mediaCacheService.uts'
|
||||
|
||||
export type AudioErrorEvent = {
|
||||
errCode ?: number
|
||||
errMsg ?: string
|
||||
}
|
||||
|
||||
export type ChatAudioContext = {
|
||||
src : string
|
||||
autoplay : boolean
|
||||
play ?: () => void
|
||||
pause ?: () => void
|
||||
stop ?: () => void
|
||||
destroy ?: () => void
|
||||
onError ?: (callback : (res : AudioErrorEvent) => void) => void
|
||||
}
|
||||
|
||||
// Helper describing the subset of inner audio context api we use
|
||||
|
||||
export type ViewMessage = {
|
||||
id : string
|
||||
conversation_id : string
|
||||
sender_id : string
|
||||
content : string
|
||||
content_type : string
|
||||
reply_to : string | null
|
||||
metadata : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
ingress_type ?: string | null
|
||||
durationSeconds ?: number;
|
||||
locationTitle ?: string;
|
||||
videoSrc : string;
|
||||
timeText : string;
|
||||
avatar : string;
|
||||
senderName ?: string | null;
|
||||
isMe : boolean;
|
||||
bubbleExtraClass : string;
|
||||
rowExtraClass : string;
|
||||
anchorId : string;
|
||||
bubbleInlineStyle : string;
|
||||
ingressType ?: string | null;
|
||||
ingressLabel : string;
|
||||
};
|
||||
|
||||
export type MemberProfile = {
|
||||
id : string,
|
||||
name : string,
|
||||
avatar ?: string | null,
|
||||
initials : string
|
||||
}
|
||||
export type ChatConversationView = {
|
||||
id : string
|
||||
title : string | null
|
||||
is_group : boolean
|
||||
owner_id : string | null
|
||||
last_message_at : string | null
|
||||
metadata : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
memberNames ?: string;
|
||||
members ?: Array<MemberProfile>;
|
||||
avatarMembers ?: Array<MemberProfile>;
|
||||
unreadCount ?: number;
|
||||
}
|
||||
|
||||
export type ChatConversation = {
|
||||
id : string
|
||||
title : string | null
|
||||
is_group : boolean
|
||||
owner_id : string | null
|
||||
last_message_at : string | null
|
||||
metadata : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
}
|
||||
export type SendDeviceTextDownlinkParams = {
|
||||
conversationId : string
|
||||
createdBy : string
|
||||
text : string
|
||||
targetUserId ?: string | null
|
||||
qos ?: number | null
|
||||
retain ?: boolean | null
|
||||
}
|
||||
export type ChatMessage = {
|
||||
id : string
|
||||
conversation_id : string
|
||||
sender_id : string
|
||||
content : string
|
||||
content_type : string
|
||||
reply_to : string | null
|
||||
metadata : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
ingress_type ?: string | null
|
||||
sender_profile ?: UserOption | null
|
||||
}
|
||||
|
||||
export type ChatNotification = {
|
||||
id : string
|
||||
user_id : string
|
||||
conversation_id : string | null
|
||||
message_id : string | null
|
||||
type : string
|
||||
is_read : boolean
|
||||
created_at : string
|
||||
}
|
||||
|
||||
export type ChatParticipant = {
|
||||
id : string
|
||||
conversation_id : string
|
||||
user_id : string
|
||||
role : string
|
||||
joined_at : string
|
||||
last_read_at : string | null
|
||||
is_muted : boolean
|
||||
settings : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
}
|
||||
export type SendAudioMessageParams = {
|
||||
conversationId : string
|
||||
senderId : string
|
||||
s3Url : string
|
||||
durationMs ?: number | null
|
||||
mime ?: string | null
|
||||
sizeBytes ?: number | null
|
||||
}
|
||||
|
||||
export type ChatParticipantWithProfile = {
|
||||
id : string
|
||||
conversation_id : string
|
||||
|
||||
user_id : string
|
||||
role : string
|
||||
joined_at : string
|
||||
last_read_at : string | null
|
||||
is_muted : boolean
|
||||
settings : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
user ?: UserOption | null
|
||||
}
|
||||
|
||||
export type SubscriptionDisposer = {
|
||||
dispose : () => void
|
||||
}
|
||||
|
||||
export type UserOption = {
|
||||
id : string
|
||||
username ?: string | null
|
||||
nickname ?: string | null
|
||||
real_name ?: string | null
|
||||
email ?: string | null
|
||||
avatar_url ?: string | null
|
||||
phone ?: string | null
|
||||
}
|
||||
|
||||
export type CreateConversationPayload = {
|
||||
title ?: string | null
|
||||
isGroup ?: boolean
|
||||
memberIds ?: Array<string>
|
||||
}
|
||||
|
||||
async function ensureSupaSession() {
|
||||
try {
|
||||
const ok = await supaReady
|
||||
if (!ok) {
|
||||
console.warn('Supabase session not established; subsequent requests may fail')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Supabase session initialization error', err)
|
||||
}
|
||||
}
|
||||
|
||||
function extractRealtimeRecord(payload : any) : UTSJSONObject | null {
|
||||
try {
|
||||
if (payload == null) return null
|
||||
const wrapper = payload as UTSJSONObject
|
||||
const directNew = wrapper.get('new') as UTSJSONObject | null
|
||||
if (directNew != null) return directNew
|
||||
const directRecord = wrapper.get('record') as UTSJSONObject | null
|
||||
if (directRecord != null) return directRecord
|
||||
const dataSection = wrapper.get('data') as UTSJSONObject | null
|
||||
if (dataSection != null) {
|
||||
const dataRecord = dataSection.get('record') as UTSJSONObject | null
|
||||
if (dataRecord != null) return dataRecord
|
||||
const dataNew = dataSection.get('new') as UTSJSONObject | null
|
||||
if (dataNew != null) return dataNew
|
||||
}
|
||||
} catch (_) { }
|
||||
return null
|
||||
}
|
||||
|
||||
function toChatMessage(row : UTSJSONObject, senderProfile : UserOption | null) : ChatMessage | null {
|
||||
try {
|
||||
const id = row.getString('id') as string | null
|
||||
const conversationId = row.getString('conversation_id') as string | null
|
||||
const senderId = row.getString('sender_id') as string | null
|
||||
const content = row.getString('content') as string | null
|
||||
const contentType = row.getString('content_type') as string | null
|
||||
const createdAt = row.getString('created_at') as string | null
|
||||
const updatedAt = row.getString('updated_at') as string | null
|
||||
if (id == null || conversationId == null || senderId == null || content == null || contentType == null || createdAt == null || updatedAt == null) {
|
||||
return null
|
||||
}
|
||||
const replyToRaw = row.get('reply_to')
|
||||
const replyTo = typeof replyToRaw == 'string' ? replyToRaw : null
|
||||
const metadata = row.get('metadata') as UTSJSONObject | null
|
||||
const ingressRaw = row.get('ingress_type')
|
||||
const ingressType = typeof ingressRaw == 'string' ? ingressRaw : null
|
||||
return {
|
||||
id: id,
|
||||
conversation_id: conversationId,
|
||||
sender_id: senderId,
|
||||
content: content,
|
||||
content_type: contentType,
|
||||
reply_to: replyTo,
|
||||
metadata: metadata ?? null,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
ingress_type: ingressType,
|
||||
sender_profile: senderProfile ?? null
|
||||
}
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toChatNotification(row : UTSJSONObject) : ChatNotification | null {
|
||||
try {
|
||||
const id = row.getString('id') as string | null
|
||||
const userId = row.getString('user_id') as string | null
|
||||
const typeValue = row.getString('type') as string | null
|
||||
const createdAt = row.getString('created_at') as string | null
|
||||
if (id == null || userId == null || typeValue == null || createdAt == null) {
|
||||
return null
|
||||
}
|
||||
const conversationIdRaw = row.get('conversation_id')
|
||||
const conversationId = typeof conversationIdRaw == 'string' ? conversationIdRaw : null
|
||||
const messageIdRaw = row.get('message_id')
|
||||
const messageId = typeof messageIdRaw == 'string' ? messageIdRaw : null
|
||||
const isReadRaw = row.get('is_read')
|
||||
const isRead = typeof isReadRaw == 'boolean' ? isReadRaw : false
|
||||
return {
|
||||
id: id,
|
||||
user_id: userId,
|
||||
conversation_id: conversationId,
|
||||
message_id: messageId,
|
||||
type: typeValue,
|
||||
is_read: isRead,
|
||||
created_at: createdAt
|
||||
}
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatDataService {
|
||||
|
||||
private static rt : AkSupaRealtime | null = null
|
||||
private static initialized = false
|
||||
private static realtimeReady : Promise<void> | null = null
|
||||
private static lastRealtimeErrorToastAt = 0
|
||||
private static senderProfileCache = new Map<string, UserOption>()
|
||||
|
||||
private static notifyRealtimeError(err : any) {
|
||||
try {
|
||||
console.error('Realtime connection error', err)
|
||||
const now = Date.now()
|
||||
if (now - this.lastRealtimeErrorToastAt > 5000) {
|
||||
this.lastRealtimeErrorToastAt = now
|
||||
try {
|
||||
uni.showToast({ title: '实时通道连接失败', icon: 'none' })
|
||||
} catch (_) { }
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
private static getCachedSenderProfile(userId : string | null) : UserOption | null {
|
||||
if (typeof userId !== 'string' || userId == '') return null
|
||||
return this.senderProfileCache.get(userId) ?? null
|
||||
}
|
||||
|
||||
private static async ensureSenderProfiles(userIds : Array<string>) : Promise<void> {
|
||||
const ids = Array.from(new Set(userIds.filter((id) => typeof id == 'string' && id !== '')))
|
||||
if (ids.length == 0) return
|
||||
const missing : Array<string> = []
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const id = ids[i] as string
|
||||
if (!this.senderProfileCache.has(id)) {
|
||||
missing.push(id)
|
||||
}
|
||||
}
|
||||
if (missing.length == 0) return
|
||||
await ensureSupaSession()
|
||||
try {
|
||||
const res = await supa
|
||||
.from('ak_users')
|
||||
.select('id, username, nickname, real_name, email, avatar_url, phone', {})
|
||||
.in('id', missing as any[])
|
||||
.executeAs<UserOption>()
|
||||
if (!(res.status >= 200 && res.status < 300) || res.data == null) return
|
||||
const arr = Array.isArray(res.data) ? (res.data as any[]) : [res.data]
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i] as UTSJSONObject | null
|
||||
if (item == null) continue
|
||||
const id = item['id'] as string | null
|
||||
if (typeof id !== 'string' || id == '') continue
|
||||
const profile : UserOption = {
|
||||
id: id as string,
|
||||
username: item['username'] as string | null ?? null,
|
||||
nickname: item['nickname'] as string | null ?? null,
|
||||
real_name: item['real_name'] as string | null ?? null,
|
||||
email: item['email'] as string | null ?? null,
|
||||
avatar_url: item['avatar_url'] as string | null ?? null,
|
||||
phone: item['phone'] as string | null ?? null
|
||||
}
|
||||
this.senderProfileCache.set(id as string, profile)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ensureSenderProfiles error', err)
|
||||
}
|
||||
}
|
||||
|
||||
private static async ensureRealtime() {
|
||||
if (this.rt != null && this.rt.isOpen == true) {
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
if (this.realtimeReady != null) {
|
||||
await this.realtimeReady
|
||||
return
|
||||
}
|
||||
await ensureSupaSession()
|
||||
let token : string | null = null
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null) {
|
||||
token = session.session?.access_token
|
||||
}
|
||||
} catch (_) { }
|
||||
let resolveReady : (() => void) | null = null
|
||||
let rejectReady : ((reason : any) => void) | null = null
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = () => { resolve() }
|
||||
rejectReady = reject
|
||||
})
|
||||
this.realtimeReady = readyPromise
|
||||
this.rt = new AkSupaRealtime({
|
||||
url: WS_URL,
|
||||
channel: 'realtime:*',
|
||||
apikey: SUPA_KEY,
|
||||
token: token ?? null,
|
||||
onMessage: (_data : any) => { },
|
||||
onOpen: (_res : any) => {
|
||||
this.initialized = true
|
||||
const resolver = resolveReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.realtimeReady = null
|
||||
resolver?.()
|
||||
},
|
||||
onError: (err : any) => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.realtimeReady = null
|
||||
rejector?.(err)
|
||||
}
|
||||
},
|
||||
onClose: (_res : any) => {
|
||||
this.initialized = false
|
||||
this.rt = null
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.realtimeReady = null
|
||||
rejector?.(new Error('Realtime connection closed before ready'))
|
||||
}
|
||||
}
|
||||
})
|
||||
this.rt.connect()
|
||||
const timeoutMs = 8000
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.realtimeReady = null
|
||||
rejector?.(new Error('Realtime connection timeout'))
|
||||
}
|
||||
}, timeoutMs)
|
||||
try {
|
||||
await readyPromise
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
}
|
||||
|
||||
static async subscribeMessages(conversationId : string, onInsert : (msg : ChatMessage) => void) : Promise<SubscriptionDisposer> {
|
||||
try {
|
||||
await this.ensureRealtime()
|
||||
} catch (err) {
|
||||
this.notifyRealtimeError(err)
|
||||
this.closeRealtime()
|
||||
return { dispose: () => { } }
|
||||
}
|
||||
console.log('subscribeMessages',conversationId)
|
||||
const guard = { active: true }
|
||||
this.rt?.subscribePostgresChanges({
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'chat_messages',
|
||||
onChange: (payload) => {
|
||||
console.log('realtime payload',payload)
|
||||
try {
|
||||
if (guard.active != true || payload == null) return
|
||||
const newRow = extractRealtimeRecord(payload)
|
||||
if (newRow == null) { return }
|
||||
const convId = newRow.getString('conversation_id') as string | null
|
||||
if (convId != null && convId == conversationId) {
|
||||
const senderId = newRow.getString('sender_id') as string | null
|
||||
const emitMessage = (profile : UserOption | null) => {
|
||||
if (guard.active != true) return
|
||||
const message = toChatMessage(newRow, profile)
|
||||
if (message != null) onInsert(message)
|
||||
}
|
||||
if (senderId != null && senderId !== '') {
|
||||
const cachedProfile = this.getCachedSenderProfile(senderId)
|
||||
if (cachedProfile == null) {
|
||||
this.ensureSenderProfiles([senderId as string]).then(() => {
|
||||
if (guard.active != true) return
|
||||
const updatedProfile = this.getCachedSenderProfile(senderId)
|
||||
emitMessage(updatedProfile ?? null)
|
||||
})
|
||||
return
|
||||
}
|
||||
emitMessage(cachedProfile)
|
||||
} else {
|
||||
emitMessage(null)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('realtime payload handling error', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
return { dispose: () => { guard.active = false } }
|
||||
}
|
||||
|
||||
static async subscribeNotifications(userId : string, onInsert : (n : ChatNotification) => void) : Promise<SubscriptionDisposer> {
|
||||
try {
|
||||
await this.ensureRealtime()
|
||||
} catch (err) {
|
||||
this.notifyRealtimeError(err)
|
||||
this.closeRealtime()
|
||||
return { dispose: () => { } }
|
||||
}
|
||||
const guard = { active: true }
|
||||
this.rt?.subscribePostgresChanges({
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'chat_notifications',
|
||||
onChange: (payload : any) => {
|
||||
try {
|
||||
const rec = extractRealtimeRecord(payload)
|
||||
if (guard.active != true || rec == null) return
|
||||
const userIdStr = rec.getString('user_id') as string | null
|
||||
if (userIdStr != null && userIdStr == userId) {
|
||||
const notification = toChatNotification(rec)
|
||||
if (notification != null) onInsert(notification)
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
})
|
||||
return { dispose: () => { guard.active = false } }
|
||||
}
|
||||
|
||||
static closeRealtime() {
|
||||
try {
|
||||
this.rt?.close({})
|
||||
} catch (_) { }
|
||||
this.rt = null
|
||||
this.initialized = false
|
||||
this.realtimeReady = null
|
||||
}
|
||||
|
||||
static async listMyConversations(userId : string) : Promise<AkReqResponse<Array<ChatConversation>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_conversations')
|
||||
.select('*', {})
|
||||
.order('last_message_at', { ascending: false })
|
||||
.executeAs<ChatConversation>()
|
||||
return res as AkReqResponse<Array<ChatConversation>>
|
||||
}
|
||||
|
||||
static async getConversation(conversationId : string) : Promise<AkReqResponse<ChatConversation>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_conversations')
|
||||
.select('*', {})
|
||||
.eq('id', conversationId)
|
||||
.single()
|
||||
.executeAs<ChatConversation>()
|
||||
return res as AkReqResponse<ChatConversation>
|
||||
}
|
||||
|
||||
static async listMessages(conversationId : string, limit = 100) : Promise<AkReqResponse<Array<ChatMessage>>> {
|
||||
await ensureSupaSession()
|
||||
console.log('listMessages')
|
||||
const res = await supa
|
||||
.from('chat_messages')
|
||||
.select('*', {})
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(limit)
|
||||
.executeAs<ChatMessage>()
|
||||
try {
|
||||
const raw = res.data
|
||||
const list : Array<UTSJSONObject> = []
|
||||
if (Array.isArray(raw)) {
|
||||
for (const item of raw as any[]) {
|
||||
if (item != null) {
|
||||
list.push(item as UTSJSONObject)
|
||||
}
|
||||
}
|
||||
} else if (raw != null) {
|
||||
list.push(raw as UTSJSONObject)
|
||||
}
|
||||
|
||||
const senderIds : Array<string> = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = list[i]
|
||||
let senderId : string | null = null
|
||||
if (item != null) {
|
||||
senderId = item.getString('sender_id') as string | null
|
||||
if (senderId == null) {
|
||||
senderId = item['sender_id'] as string | null
|
||||
}
|
||||
}
|
||||
if (typeof senderId == 'string' && senderId !== '') {
|
||||
senderIds.push(senderId!!)
|
||||
}
|
||||
}
|
||||
if (senderIds.length > 0) {
|
||||
await this.ensureSenderProfiles(senderIds)
|
||||
for (let j = 0; j < list.length; j++) {
|
||||
const item = list[j]
|
||||
if (item != null) {
|
||||
let senderId : string | null = item.getString('sender_id') as string | null
|
||||
if (senderId == null) {
|
||||
senderId = item['sender_id'] as string | null
|
||||
}
|
||||
if (typeof senderId == 'string' && senderId !== '') {
|
||||
const profile = this.getCachedSenderProfile(senderId)
|
||||
if (profile != null) {
|
||||
item['sender_profile'] = profile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('listMessages profile hydrate error', err)
|
||||
}
|
||||
console.log('listMessages ',res,conversationId)
|
||||
return res as AkReqResponse<Array<ChatMessage>>
|
||||
|
||||
}
|
||||
|
||||
static async sendMessage(conversationId : string, senderId : string, content : string) : Promise<AkReqResponse<ChatMessage>> {
|
||||
await ensureSupaSession()
|
||||
const payload = {
|
||||
conversation_id: conversationId,
|
||||
sender_id: senderId,
|
||||
content,
|
||||
content_type: 'text',
|
||||
ingress_type: 'manual',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
} as UTSJSONObject
|
||||
const res = await supa
|
||||
.from('chat_messages')
|
||||
.insert(payload)
|
||||
.single()
|
||||
.executeAs<ChatMessage>()
|
||||
|
||||
return res as AkReqResponse<ChatMessage>
|
||||
|
||||
}
|
||||
|
||||
static async listNotifications(userId : string, limit = 50) : Promise<AkReqResponse<Array<ChatNotification>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_notifications')
|
||||
.select('*', {})
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
.executeAs<ChatNotification>()
|
||||
return res as AkReqResponse<Array<ChatNotification>>
|
||||
}
|
||||
|
||||
static async markConversationNotificationsRead(userId : string, conversationId : string) : Promise<AkReqResponse<Array<ChatNotification>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('type', 'message')
|
||||
.eq('is_read', false)
|
||||
.select('id', {})
|
||||
.executeAs<ChatNotification>()
|
||||
return res as AkReqResponse<Array<ChatNotification>>
|
||||
}
|
||||
|
||||
static async markNotificationRead(notificationId : string) : Promise<AkReqResponse<Array<ChatNotification>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('id', notificationId)
|
||||
.eq('is_read', false)
|
||||
.select('id', {})
|
||||
.executeAs<ChatNotification>()
|
||||
return res as AkReqResponse<Array<ChatNotification>>
|
||||
}
|
||||
|
||||
static async searchUsers(keyword : string, limit = 20) : Promise<AkReqResponse<Array<UserOption>>> {
|
||||
await ensureSupaSession()
|
||||
let query = supa
|
||||
.from('ak_users')
|
||||
.select('id, username, nickname, email', {})
|
||||
.limit(limit)
|
||||
const k = keyword.trim()
|
||||
if (k !== '') {
|
||||
query = query.or(`username.ilike.%${k}%,nickname.ilike.%${k}%,email.ilike.%${k}%`)
|
||||
}
|
||||
const res = await query.executeAs<UserOption>()
|
||||
return res as AkReqResponse<Array<UserOption>>
|
||||
}
|
||||
|
||||
// Create a conversation and add participants. The creator becomes owner.
|
||||
static async createConversation(ownerId : string, payload : CreateConversationPayload) : Promise<AkReqResponse<ChatConversation>> {
|
||||
await ensureSupaSession()
|
||||
const title = payload.title ?? null
|
||||
const isGroup = payload.isGroup ?? ((payload.memberIds?.length ?? 0) > 1)
|
||||
const memberIds = (payload.memberIds ?? []).filter((id) => id !== ownerId)
|
||||
// 1) create conversation
|
||||
const convRes = await supa
|
||||
.from('chat_conversations')
|
||||
.insert({ title, is_group: isGroup, owner_id: ownerId } as UTSJSONObject)
|
||||
.single()
|
||||
.executeAs<ChatConversation>()
|
||||
if (convRes.status != null && (convRes.status < 200 || convRes.status >= 300 || convRes.data == null)) {
|
||||
return convRes as AkReqResponse<ChatConversation>
|
||||
}
|
||||
const conv = convRes.data as ChatConversation
|
||||
// 2) insert owner participant first
|
||||
const ownerRow = { conversation_id: conv.id, user_id: ownerId, role: 'owner' }
|
||||
const ownerIns = await supa
|
||||
.from('chat_participants')
|
||||
.insert(ownerRow)
|
||||
.single()
|
||||
.executeAs<ChatParticipant>()
|
||||
if (!(ownerIns.status >= 200 && ownerIns.status < 300)) {
|
||||
// return conversation as created but warn via status
|
||||
return convRes as AkReqResponse<ChatConversation>
|
||||
}
|
||||
// 3) insert other members (if any) in bulk
|
||||
if (memberIds.length > 0) {
|
||||
const rows = memberIds.map((uid) => ({ conversation_id: conv.id, user_id: uid, role: 'member' }))
|
||||
await supa.from('chat_participants').insert(rows as any[] as UTSJSONObject[]).executeAs<ChatParticipant>()
|
||||
}
|
||||
return convRes as AkReqResponse<ChatConversation>
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Insert a downlink row to chat_mqtt_downlinks; gateway will publish to MQTT
|
||||
static async sendDeviceTextDownlink(params : SendDeviceTextDownlinkParams) : Promise<AkReqResponse<any>> {
|
||||
await ensureSupaSession()
|
||||
const row = {
|
||||
conversation_id: params.conversationId,
|
||||
target_user_id: params.targetUserId ?? null,
|
||||
topic: null, // let gateway derive topic by target_user_id
|
||||
payload: params.text,
|
||||
payload_encoding: 'utf8',
|
||||
qos: (params.qos ?? 1),
|
||||
retain: (params.retain ?? false),
|
||||
status: 'pending',
|
||||
scheduled_at: new Date().toISOString(),
|
||||
created_by: params.createdBy
|
||||
} as UTSJSONObject;
|
||||
const res = await supa
|
||||
.from('chat_mqtt_downlinks')
|
||||
.insert(row)
|
||||
.single()
|
||||
.executeAs<any>()
|
||||
return res
|
||||
}
|
||||
|
||||
// For 1:1 conversation, derive the peer id (the other participant)
|
||||
static async getPeerId(conversationId : string, myId : string) : Promise<string | null> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_participants')
|
||||
.select('*', {})
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('joined_at', { ascending: true })
|
||||
.limit(10)
|
||||
.executeAs<ChatParticipant>()
|
||||
if (!(res.status >= 200 && res.status < 300) || res.data == null) return null
|
||||
const arr = res.data as any[]
|
||||
if (arr.length == 2) {
|
||||
const a = arr[0] as UTSJSONObject;
|
||||
const b = arr[1] as UTSJSONObject
|
||||
const aId = a['user_id'] as string;
|
||||
const bId = b['user_id'] as string
|
||||
return aId == myId ? bId : (bId == myId ? aId : null)
|
||||
}
|
||||
// not strict 1:1
|
||||
return null
|
||||
}
|
||||
// Invite additional members to an existing conversation (owner/admin only)
|
||||
static async inviteMembers(conversationId : string, memberIds : Array<string>) : Promise<AkReqResponse<Array<ChatParticipant>>> {
|
||||
await ensureSupaSession()
|
||||
const ids = Array.from(new Set(memberIds))
|
||||
if (ids.length == 0) {
|
||||
// Simulate a 200 empty insert response
|
||||
return { status: 200, data: [] as ChatParticipant[], error: null, headers: {} } as AkReqResponse<Array<ChatParticipant>>
|
||||
}
|
||||
const rows = ids.map((uid) => ({ conversation_id: conversationId, user_id: uid, role: 'member' }))
|
||||
const res = await supa
|
||||
.from('chat_participants')
|
||||
.insert(rows as any[] as UTSJSONObject[])
|
||||
.executeAs<ChatParticipant>()
|
||||
return res as AkReqResponse<Array<ChatParticipant>>
|
||||
}
|
||||
|
||||
static async listParticipants(conversationId : string) : Promise<AkReqResponse<Array<ChatParticipant>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_participants')
|
||||
.select('*', {})
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('joined_at', { ascending: true })
|
||||
.executeAs<ChatParticipant>()
|
||||
return res as AkReqResponse<Array<ChatParticipant>>
|
||||
}
|
||||
|
||||
static async listParticipantsWithProfile(conversationId : string) : Promise<AkReqResponse<Array<ChatParticipantWithProfile>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_participants')
|
||||
.select('id, conversation_id, user_id, role, joined_at, last_read_at, is_muted, settings, created_at, updated_at, user:ak_users(id, nickname, username, email, real_name, avatar_url,phone)', {})
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('joined_at', { ascending: true })
|
||||
.executeAs<ChatParticipantWithProfile>()
|
||||
return res as AkReqResponse<Array<ChatParticipantWithProfile>>
|
||||
}
|
||||
|
||||
|
||||
static async sendAudioMessage(params : SendAudioMessageParams) : Promise<AkReqResponse<ChatMessage>> {
|
||||
await ensureSupaSession()
|
||||
const metadata = {
|
||||
duration_ms: params.durationMs ?? null,
|
||||
mime: params.mime ?? 'audio/mpeg',
|
||||
size: params.sizeBytes ?? null
|
||||
} as UTSJSONObject
|
||||
const payload = {
|
||||
conversation_id: params.conversationId,
|
||||
sender_id: params.senderId,
|
||||
content: params.s3Url,
|
||||
content_type: 'audio',
|
||||
metadata,
|
||||
ingress_type: 'manual',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
const res = await supa
|
||||
.from('chat_messages')
|
||||
.insert(payload)
|
||||
.single()
|
||||
.executeAs<ChatMessage>()
|
||||
|
||||
return res as AkReqResponse<ChatMessage>
|
||||
|
||||
}
|
||||
|
||||
// Simple audio playback helper using inner audio context (uni-app API)
|
||||
static async playAudio(url : string) : Promise<InnerAudioContext | null> {
|
||||
try {
|
||||
const cached = await MediaCacheService.getCachedPath(url)
|
||||
console.log(cached,url)
|
||||
const audio = uni.createInnerAudioContext()
|
||||
audio.src = url
|
||||
audio.autoplay = true
|
||||
audio.onError((e:ICreateInnerAudioContextFail) => {
|
||||
console.log('audio error', e.errCode, e.errMsg)
|
||||
})
|
||||
return audio
|
||||
} catch (e) {
|
||||
console.log('audio not supported', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Video caching helper: return a playable local path for <video> component
|
||||
static async resolveVideoSource(url : string) : Promise<string> {
|
||||
try {
|
||||
const cached = await MediaCacheService.getCachedPath(url)
|
||||
return cached
|
||||
} catch (_) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user