827 lines
25 KiB
Plaintext
827 lines
25 KiB
Plaintext
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
|
|
}
|
|
}
|
|
} |