Initial vue frontend
This commit is contained in:
467
frontend-vue/src/composables/useAudio.ts
Normal file
467
frontend-vue/src/composables/useAudio.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
interface AudioRecording {
|
||||
blob: Blob | null
|
||||
duration: number
|
||||
isRecording: boolean
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
}
|
||||
|
||||
// Global audio state to ensure singleton behavior
|
||||
let audioSystemInitialized = false
|
||||
let soundsLoaded = false
|
||||
let globalAudioContext: AudioContext | null = null
|
||||
let globalSoundBuffers = new Map<string, AudioBuffer>()
|
||||
let globalWaterSounds: AudioBuffer[] = []
|
||||
let globalSentSounds: AudioBuffer[] = []
|
||||
|
||||
export function useAudio() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Audio Context (use global instance)
|
||||
const audioContext = ref<AudioContext | null>(globalAudioContext)
|
||||
|
||||
// Sound buffers (use global arrays)
|
||||
const soundBuffers = ref<Map<string, AudioBuffer>>(globalSoundBuffers)
|
||||
const waterSounds = ref<AudioBuffer[]>(globalWaterSounds)
|
||||
const sentSounds = ref<AudioBuffer[]>(globalSentSounds)
|
||||
|
||||
// Recording state
|
||||
const recording = ref<AudioRecording>({
|
||||
blob: null,
|
||||
duration: 0,
|
||||
isRecording: false,
|
||||
isPlaying: false,
|
||||
currentTime: 0
|
||||
})
|
||||
|
||||
// Media recorder
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let recordingChunks: Blob[] = []
|
||||
let recordingStartTime: number = 0
|
||||
let recordingInterval: number | null = null
|
||||
|
||||
// Text-to-speech state
|
||||
const isSpeaking = ref(false)
|
||||
const availableVoices = ref<SpeechSynthesisVoice[]>([])
|
||||
const selectedVoice = ref<SpeechSynthesisVoice | null>(null)
|
||||
|
||||
// Initialize audio context
|
||||
const initAudioContext = async () => {
|
||||
if (!globalAudioContext) {
|
||||
globalAudioContext = new AudioContext()
|
||||
audioContext.value = globalAudioContext
|
||||
}
|
||||
|
||||
if (globalAudioContext.state === 'suspended') {
|
||||
try {
|
||||
await globalAudioContext.resume()
|
||||
} catch (error) {
|
||||
console.warn('AudioContext resume failed, user interaction required:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load a single sound file
|
||||
const loadSound = async (url: string): Promise<AudioBuffer | null> => {
|
||||
try {
|
||||
if (!audioContext.value) {
|
||||
await initAudioContext()
|
||||
}
|
||||
|
||||
if (!audioContext.value) {
|
||||
// AudioContext creation failed (probably no user interaction yet)
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
return await audioContext.value.decodeAudioData(arrayBuffer)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load sound ${url}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Load all sound files
|
||||
const loadAllSounds = async () => {
|
||||
if (soundsLoaded) {
|
||||
console.log('Sounds already loaded, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting to load all sounds...')
|
||||
soundsLoaded = true
|
||||
|
||||
// Load basic sounds
|
||||
const basicSounds = {
|
||||
intro: '/sounds/intro.wav',
|
||||
login: '/sounds/login.wav',
|
||||
copy: '/sounds/copy.wav',
|
||||
uploadFailed: '/sounds/uploadfail.wav'
|
||||
}
|
||||
|
||||
for (const [name, url] of Object.entries(basicSounds)) {
|
||||
const buffer = await loadSound(url)
|
||||
if (buffer) {
|
||||
globalSoundBuffers.set(name, buffer)
|
||||
soundBuffers.value.set(name, buffer)
|
||||
}
|
||||
}
|
||||
|
||||
// Load water sounds (1-10)
|
||||
console.log('Loading water sounds...')
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const buffer = await loadSound(`/sounds/water${i}.wav`)
|
||||
if (buffer) {
|
||||
globalWaterSounds.push(buffer)
|
||||
waterSounds.value.push(buffer)
|
||||
console.log(`Loaded water sound ${i}`)
|
||||
} else {
|
||||
console.warn(`Failed to load water sound ${i}`)
|
||||
}
|
||||
}
|
||||
console.log(`Water sounds loaded: ${globalWaterSounds.length}/10, reactive: ${waterSounds.value.length}/10`)
|
||||
|
||||
// Load sent sounds (1-6)
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const buffer = await loadSound(`/sounds/sent${i}.wav`)
|
||||
if (buffer) {
|
||||
globalSentSounds.push(buffer)
|
||||
sentSounds.value.push(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All sounds loaded and ready to play')
|
||||
} catch (error) {
|
||||
console.error('Error loading sounds:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Play a sound buffer
|
||||
const playSoundBuffer = async (buffer: AudioBuffer) => {
|
||||
if (!appStore.settings.soundEnabled) return
|
||||
|
||||
try {
|
||||
await initAudioContext()
|
||||
if (!globalAudioContext) {
|
||||
console.error('AudioContext not initialized')
|
||||
return
|
||||
}
|
||||
const source = globalAudioContext.createBufferSource()
|
||||
source.buffer = buffer
|
||||
source.connect(globalAudioContext.destination)
|
||||
source.start(0)
|
||||
} catch (error) {
|
||||
console.error('Error playing sound:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Play specific sounds
|
||||
const playSound = async (name: string) => {
|
||||
const buffer = globalSoundBuffers.get(name)
|
||||
if (buffer) {
|
||||
await playSoundBuffer(buffer)
|
||||
} else {
|
||||
console.warn(`Sound ${name} not loaded`)
|
||||
}
|
||||
}
|
||||
|
||||
const playWater = async () => {
|
||||
console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
|
||||
if (globalWaterSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
||||
await playSoundBuffer(globalWaterSounds[randomIndex])
|
||||
} else {
|
||||
console.warn('Water sounds not loaded - trying to load them now')
|
||||
if (globalAudioContext) {
|
||||
await loadAllSounds()
|
||||
if (globalWaterSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
||||
await playSoundBuffer(globalWaterSounds[randomIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const playSent = async () => {
|
||||
if (globalSentSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
|
||||
await playSoundBuffer(globalSentSounds[randomIndex])
|
||||
} else {
|
||||
console.warn('Sent sounds not loaded')
|
||||
}
|
||||
}
|
||||
|
||||
// Voice recording
|
||||
const startRecording = async (): Promise<boolean> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: true
|
||||
}
|
||||
})
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
recordingChunks = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordingChunks.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(recordingChunks, { type: 'audio/webm;codecs=opus' })
|
||||
recording.value.blob = blob
|
||||
recording.value.isRecording = false
|
||||
|
||||
if (recordingInterval) {
|
||||
clearInterval(recordingInterval)
|
||||
recordingInterval = null
|
||||
}
|
||||
|
||||
// Stop all tracks to release microphone
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
recording.value.isRecording = true
|
||||
recording.value.duration = 0
|
||||
recordingStartTime = Date.now()
|
||||
|
||||
// Update duration every 100ms
|
||||
recordingInterval = setInterval(() => {
|
||||
recording.value.duration = (Date.now() - recordingStartTime) / 1000
|
||||
}, 100)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error)
|
||||
recording.value.isRecording = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder && recording.value.isRecording) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
const playRecording = async () => {
|
||||
if (!recording.value.blob) return false
|
||||
|
||||
try {
|
||||
const audio = new Audio(URL.createObjectURL(recording.value.blob))
|
||||
|
||||
recording.value.isPlaying = true
|
||||
recording.value.currentTime = 0
|
||||
|
||||
audio.ontimeupdate = () => {
|
||||
recording.value.currentTime = audio.currentTime
|
||||
}
|
||||
|
||||
audio.onended = () => {
|
||||
recording.value.isPlaying = false
|
||||
recording.value.currentTime = 0
|
||||
URL.revokeObjectURL(audio.src)
|
||||
}
|
||||
|
||||
await audio.play()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to play recording:', error)
|
||||
recording.value.isPlaying = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const clearRecording = () => {
|
||||
if (recording.value.blob) {
|
||||
URL.revokeObjectURL(URL.createObjectURL(recording.value.blob))
|
||||
}
|
||||
recording.value.blob = null
|
||||
recording.value.duration = 0
|
||||
recording.value.isPlaying = false
|
||||
recording.value.currentTime = 0
|
||||
}
|
||||
|
||||
// Text-to-speech functions
|
||||
const loadVoices = () => {
|
||||
const voices = speechSynthesis.getVoices()
|
||||
availableVoices.value = voices
|
||||
|
||||
// Select default voice (prefer English voices)
|
||||
if (!selectedVoice.value && voices.length > 0) {
|
||||
const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
|
||||
selectedVoice.value = englishVoice || voices[0]
|
||||
}
|
||||
}
|
||||
|
||||
const speak = (text: string, options: { rate?: number, pitch?: number, volume?: number } = {}) => {
|
||||
if (!appStore.settings.ttsEnabled) return Promise.resolve()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if ('speechSynthesis' in window) {
|
||||
// Stop any current speech
|
||||
speechSynthesis.cancel()
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
|
||||
// Set voice if available
|
||||
if (selectedVoice.value) {
|
||||
utterance.voice = selectedVoice.value
|
||||
}
|
||||
|
||||
// Apply options
|
||||
utterance.rate = options.rate || appStore.settings.ttsRate || 1
|
||||
utterance.pitch = options.pitch || appStore.settings.ttsPitch || 1
|
||||
utterance.volume = options.volume || appStore.settings.ttsVolume || 1
|
||||
|
||||
utterance.onstart = () => {
|
||||
isSpeaking.value = true
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
isSpeaking.value = false
|
||||
resolve()
|
||||
}
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
isSpeaking.value = false
|
||||
console.error('Speech synthesis error:', event.error)
|
||||
reject(new Error(`Speech synthesis failed: ${event.error}`))
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance)
|
||||
} else {
|
||||
reject(new Error('Speech synthesis not supported'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const stopSpeaking = () => {
|
||||
if ('speechSynthesis' in window) {
|
||||
speechSynthesis.cancel()
|
||||
isSpeaking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setVoice = (voice: SpeechSynthesisVoice) => {
|
||||
selectedVoice.value = voice
|
||||
appStore.updateSettings({ selectedVoiceURI: voice.voiceURI })
|
||||
}
|
||||
|
||||
// Announce message for accessibility
|
||||
const announceMessage = async (content: string, channel?: string) => {
|
||||
if (!appStore.settings.ttsEnabled) return
|
||||
|
||||
let textToSpeak = content
|
||||
if (channel) {
|
||||
textToSpeak = `New message in ${channel}: ${content}`
|
||||
}
|
||||
|
||||
try {
|
||||
await speak(textToSpeak)
|
||||
} catch (error) {
|
||||
console.error('Failed to announce message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Computed
|
||||
const canRecord = computed(() => {
|
||||
return navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||
})
|
||||
|
||||
const recordingDurationFormatted = computed(() => {
|
||||
const duration = recording.value.duration
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
// Initialize audio on first user interaction
|
||||
const initAudioOnUserGesture = async () => {
|
||||
if (!audioContext.value || audioContext.value.state === 'suspended') {
|
||||
await initAudioContext()
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize audio system (only once)
|
||||
const initializeAudioSystem = () => {
|
||||
if (!audioSystemInitialized) {
|
||||
audioSystemInitialized = true
|
||||
|
||||
// Set up user gesture listeners to initialize audio and load sounds
|
||||
const initializeAudio = async () => {
|
||||
console.log('User interaction detected, initializing audio system...')
|
||||
await initAudioOnUserGesture()
|
||||
await loadAllSounds() // Load sounds after user interaction
|
||||
console.log('Audio system initialized')
|
||||
document.removeEventListener('click', initializeAudio)
|
||||
document.removeEventListener('keydown', initializeAudio)
|
||||
}
|
||||
|
||||
document.addEventListener('click', initializeAudio, { once: true })
|
||||
document.addEventListener('keydown', initializeAudio, { once: true })
|
||||
|
||||
// Initialize voices for speech synthesis
|
||||
if ('speechSynthesis' in window) {
|
||||
loadVoices()
|
||||
// Voices may not be immediately available
|
||||
speechSynthesis.addEventListener('voiceschanged', loadVoices)
|
||||
|
||||
// Restore selected voice from settings
|
||||
if (appStore.settings.selectedVoiceURI) {
|
||||
const voices = speechSynthesis.getVoices()
|
||||
const savedVoice = voices.find(v => v.voiceURI === appStore.settings.selectedVoiceURI)
|
||||
if (savedVoice) {
|
||||
selectedVoice.value = savedVoice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize audio system when composable is first used
|
||||
initializeAudioSystem()
|
||||
|
||||
return {
|
||||
// State
|
||||
recording,
|
||||
canRecord,
|
||||
recordingDurationFormatted,
|
||||
isSpeaking: readonly(isSpeaking),
|
||||
availableVoices: readonly(availableVoices),
|
||||
selectedVoice: readonly(selectedVoice),
|
||||
|
||||
// Audio playback
|
||||
playSound,
|
||||
playWater,
|
||||
playSent,
|
||||
|
||||
// Voice recording
|
||||
startRecording,
|
||||
stopRecording,
|
||||
playRecording,
|
||||
clearRecording,
|
||||
|
||||
// Text-to-speech
|
||||
speak,
|
||||
stopSpeaking,
|
||||
setVoice,
|
||||
announceMessage,
|
||||
|
||||
// Audio context
|
||||
initAudioContext
|
||||
}
|
||||
}
|
93
frontend-vue/src/composables/useKeyboardShortcuts.ts
Normal file
93
frontend-vue/src/composables/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { onMounted, onUnmounted, ref, readonly } from 'vue'
|
||||
|
||||
interface ShortcutConfig {
|
||||
key: string
|
||||
ctrlKey?: boolean
|
||||
shiftKey?: boolean
|
||||
altKey?: boolean
|
||||
metaKey?: boolean
|
||||
handler: () => void
|
||||
preventDefault?: boolean
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const shortcuts = ref<Map<string, ShortcutConfig>>(new Map())
|
||||
const isListening = ref(false)
|
||||
|
||||
const getShortcutKey = (config: ShortcutConfig): string => {
|
||||
const parts = []
|
||||
if (config.ctrlKey) parts.push('ctrl')
|
||||
if (config.shiftKey) parts.push('shift')
|
||||
if (config.altKey) parts.push('alt')
|
||||
if (config.metaKey) parts.push('meta')
|
||||
parts.push(config.key.toLowerCase())
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// Skip shortcuts when focused on input/textarea elements
|
||||
const target = event.target as HTMLElement
|
||||
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
|
||||
return
|
||||
}
|
||||
|
||||
const config: ShortcutConfig = {
|
||||
key: event.key.toLowerCase(),
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
handler: () => {}
|
||||
}
|
||||
|
||||
const shortcutKey = getShortcutKey(config)
|
||||
const shortcut = shortcuts.value.get(shortcutKey)
|
||||
|
||||
if (shortcut) {
|
||||
if (shortcut.preventDefault !== false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
shortcut.handler()
|
||||
}
|
||||
}
|
||||
|
||||
const addShortcut = (config: ShortcutConfig) => {
|
||||
const key = getShortcutKey(config)
|
||||
shortcuts.value.set(key, config)
|
||||
}
|
||||
|
||||
const removeShortcut = (config: Omit<ShortcutConfig, 'handler'>) => {
|
||||
const key = getShortcutKey(config as ShortcutConfig)
|
||||
shortcuts.value.delete(key)
|
||||
}
|
||||
|
||||
const startListening = () => {
|
||||
if (!isListening.value) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
isListening.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const stopListening = () => {
|
||||
if (isListening.value) {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startListening()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopListening()
|
||||
})
|
||||
|
||||
return {
|
||||
addShortcut,
|
||||
removeShortcut,
|
||||
startListening,
|
||||
stopListening,
|
||||
isListening
|
||||
}
|
||||
}
|
178
frontend-vue/src/composables/useOfflineSync.ts
Normal file
178
frontend-vue/src/composables/useOfflineSync.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ref, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import type { UnsentMessage } from '@/types'
|
||||
|
||||
export function useOfflineSync() {
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
const isOnline = ref(navigator.onLine)
|
||||
const isSyncing = ref(false)
|
||||
let syncInterval: number | null = null
|
||||
|
||||
// Monitor online status
|
||||
const updateOnlineStatus = () => {
|
||||
const wasOnline = isOnline.value
|
||||
isOnline.value = navigator.onLine
|
||||
|
||||
if (!wasOnline && isOnline.value) {
|
||||
toastStore.success('Back online - syncing data...')
|
||||
syncUnsentMessages()
|
||||
} else if (wasOnline && !isOnline.value) {
|
||||
toastStore.info('You are offline - messages will be queued')
|
||||
}
|
||||
}
|
||||
|
||||
// Sync unsent messages when online
|
||||
const syncUnsentMessages = async () => {
|
||||
if (!isOnline.value || isSyncing.value || appStore.unsentMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isSyncing.value = true
|
||||
const failedMessages: UnsentMessage[] = []
|
||||
|
||||
for (const unsentMessage of appStore.unsentMessages) {
|
||||
try {
|
||||
const response = await apiService.createMessage(
|
||||
unsentMessage.channelId,
|
||||
unsentMessage.content
|
||||
)
|
||||
|
||||
// Message sent successfully - remove from unsent queue
|
||||
appStore.removeUnsentMessage(unsentMessage.id)
|
||||
|
||||
// Add to messages (will be handled by WebSocket event too)
|
||||
appStore.addMessage(response)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to sync message:', error)
|
||||
|
||||
// Increment retry count (create mutable copy)
|
||||
const mutableMessage = { ...unsentMessage, retries: unsentMessage.retries + 1 }
|
||||
|
||||
// If too many retries, give up
|
||||
if (mutableMessage.retries >= 3) {
|
||||
toastStore.error(`Failed to send message after 3 attempts: "${unsentMessage.content.substring(0, 50)}..."`)
|
||||
appStore.removeUnsentMessage(unsentMessage.id)
|
||||
} else {
|
||||
failedMessages.push(mutableMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update unsent messages with failed ones
|
||||
if (failedMessages.length > 0) {
|
||||
toastStore.error(`${failedMessages.length} messages failed to sync. Will retry...`)
|
||||
} else if (appStore.unsentMessages.length > 0) {
|
||||
toastStore.success('All offline messages synced!')
|
||||
}
|
||||
|
||||
isSyncing.value = false
|
||||
await appStore.saveState()
|
||||
}
|
||||
|
||||
// Queue message for sending when offline
|
||||
const queueMessage = async (channelId: number, content: string): Promise<string> => {
|
||||
const unsentMessage: UnsentMessage = {
|
||||
id: `unsent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
channelId,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
retries: 0
|
||||
}
|
||||
|
||||
appStore.addUnsentMessage(unsentMessage)
|
||||
await appStore.saveState()
|
||||
|
||||
// Try to send immediately if online
|
||||
if (isOnline.value) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
|
||||
return unsentMessage.id
|
||||
}
|
||||
|
||||
// Send message (online or queue for offline)
|
||||
const sendMessage = async (channelId: number, content: string): Promise<boolean> => {
|
||||
if (isOnline.value) {
|
||||
try {
|
||||
const response = await apiService.createMessage(channelId, content)
|
||||
appStore.addMessage(response)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to send message online:', error)
|
||||
// Fall back to queuing
|
||||
await queueMessage(channelId, content)
|
||||
toastStore.error('Failed to send message - queued for later')
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
await queueMessage(channelId, content)
|
||||
toastStore.info('Message queued for sending when online')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save state periodically
|
||||
const startAutoSave = () => {
|
||||
if (syncInterval) clearInterval(syncInterval)
|
||||
|
||||
syncInterval = setInterval(async () => {
|
||||
try {
|
||||
await appStore.saveState()
|
||||
|
||||
// Try to sync unsent messages if online
|
||||
if (isOnline.value && appStore.unsentMessages.length > 0) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error)
|
||||
}
|
||||
}, 10000) // Save every 10 seconds
|
||||
}
|
||||
|
||||
const stopAutoSave = () => {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
syncInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle beforeunload to save state
|
||||
const handleBeforeUnload = () => {
|
||||
appStore.saveState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Add event listeners
|
||||
window.addEventListener('online', updateOnlineStatus)
|
||||
window.addEventListener('offline', updateOnlineStatus)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
// Start auto-save
|
||||
startAutoSave()
|
||||
|
||||
// Initial sync if online and has unsent messages
|
||||
if (isOnline.value && appStore.unsentMessages.length > 0) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up
|
||||
window.removeEventListener('online', updateOnlineStatus)
|
||||
window.removeEventListener('offline', updateOnlineStatus)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
stopAutoSave()
|
||||
})
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isSyncing,
|
||||
sendMessage,
|
||||
syncUnsentMessages,
|
||||
queueMessage
|
||||
}
|
||||
}
|
152
frontend-vue/src/composables/useWebSocket.ts
Normal file
152
frontend-vue/src/composables/useWebSocket.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { websocketService } from '@/services/websocket'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
|
||||
|
||||
export function useWebSocket() {
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
const { announceMessage } = useAudio()
|
||||
|
||||
const handleMessageCreated = (data: any) => {
|
||||
console.log('WebSocket: Message created event received:', data)
|
||||
console.log('Original content:', JSON.stringify(data.content))
|
||||
// Transform the data to match our expected format
|
||||
const message: ExtendedMessage = {
|
||||
id: data.id,
|
||||
channel_id: parseInt(data.channelId), // Convert channelId string to channel_id number
|
||||
content: data.content,
|
||||
created_at: data.createdAt || new Date().toISOString(),
|
||||
file_id: data.fileId
|
||||
}
|
||||
console.log('WebSocket: Transformed message:', message)
|
||||
console.log('Transformed content:', JSON.stringify(message.content))
|
||||
appStore.addMessage(message)
|
||||
|
||||
// Announce new message for accessibility
|
||||
const channel = appStore.channels.find(c => c.id === message.channel_id)
|
||||
if (channel && appStore.settings.ttsEnabled) {
|
||||
announceMessage(message.content, channel.name)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessageUpdated = (data: { id: string, content: string }) => {
|
||||
appStore.updateMessage(parseInt(data.id), { content: data.content })
|
||||
}
|
||||
|
||||
const handleMessageDeleted = (data: { id: string }) => {
|
||||
appStore.removeMessage(parseInt(data.id))
|
||||
}
|
||||
|
||||
const handleFileUploaded = (data: FileAttachment) => {
|
||||
// Find the message and add the file to it
|
||||
const channelMessages = appStore.messages[data.channel_id] || []
|
||||
const messageIndex = channelMessages.findIndex(m => m.id === data.message_id)
|
||||
if (messageIndex !== -1) {
|
||||
const message = channelMessages[messageIndex]
|
||||
const updatedMessage = {
|
||||
...message,
|
||||
files: [...(message.files || []), data]
|
||||
}
|
||||
appStore.updateMessage(message.id, updatedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChannelCreated = (data: { channel: Channel }) => {
|
||||
appStore.addChannel(data.channel)
|
||||
toastStore.success(`Channel "${data.channel.name}" created`)
|
||||
}
|
||||
|
||||
const handleChannelDeleted = (data: { id: string }) => {
|
||||
const channelId = parseInt(data.id)
|
||||
const channel = appStore.channels.find(c => c.id === channelId)
|
||||
appStore.removeChannel(channelId)
|
||||
if (channel) {
|
||||
toastStore.info(`Channel "${channel.name}" was deleted`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChannelMerged = (data: { channelId: string, targetChannelId: string }) => {
|
||||
const sourceChannelId = parseInt(data.channelId)
|
||||
const targetChannelId = parseInt(data.targetChannelId)
|
||||
|
||||
const sourceChannel = appStore.channels.find(c => c.id === sourceChannelId)
|
||||
const targetChannel = appStore.channels.find(c => c.id === targetChannelId)
|
||||
|
||||
if (sourceChannel && targetChannel) {
|
||||
// Merge messages from source to target
|
||||
const sourceMessages = [...(appStore.messages[sourceChannelId] || [])]
|
||||
const targetMessages = [...(appStore.messages[targetChannelId] || [])]
|
||||
appStore.setMessages(targetChannelId, [...targetMessages, ...sourceMessages])
|
||||
|
||||
// Remove source channel
|
||||
appStore.removeChannel(sourceChannelId)
|
||||
|
||||
toastStore.success(`Channel "${sourceChannel.name}" merged into "${targetChannel.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChannelUpdated = (data: { id: string, name: string }) => {
|
||||
// Update channel in store (if we implement channel renaming)
|
||||
const channelId = parseInt(data.id)
|
||||
const channels = [...appStore.channels]
|
||||
const channelIndex = channels.findIndex(c => c.id === channelId)
|
||||
if (channelIndex !== -1) {
|
||||
channels[channelIndex] = { ...channels[channelIndex], name: data.name }
|
||||
appStore.setChannels(channels)
|
||||
}
|
||||
}
|
||||
|
||||
const setupEventHandlers = () => {
|
||||
websocketService.on('message-created', handleMessageCreated)
|
||||
websocketService.on('message-updated', handleMessageUpdated)
|
||||
websocketService.on('message-deleted', handleMessageDeleted)
|
||||
websocketService.on('file-uploaded', handleFileUploaded)
|
||||
websocketService.on('channel-created', handleChannelCreated)
|
||||
websocketService.on('channel-deleted', handleChannelDeleted)
|
||||
websocketService.on('channel-merged', handleChannelMerged)
|
||||
websocketService.on('channel-updated', handleChannelUpdated)
|
||||
|
||||
websocketService.on('connected', () => {
|
||||
console.log('WebSocket connected successfully')
|
||||
toastStore.success('Connected to server')
|
||||
})
|
||||
|
||||
websocketService.on('disconnected', () => {
|
||||
toastStore.error('Disconnected from server')
|
||||
})
|
||||
|
||||
websocketService.on('error', () => {
|
||||
toastStore.error('WebSocket connection error')
|
||||
})
|
||||
}
|
||||
|
||||
const removeEventHandlers = () => {
|
||||
websocketService.off('message-created', handleMessageCreated)
|
||||
websocketService.off('message-updated', handleMessageUpdated)
|
||||
websocketService.off('message-deleted', handleMessageDeleted)
|
||||
websocketService.off('file-uploaded', handleFileUploaded)
|
||||
websocketService.off('channel-created', handleChannelCreated)
|
||||
websocketService.off('channel-deleted', handleChannelDeleted)
|
||||
websocketService.off('channel-merged', handleChannelMerged)
|
||||
websocketService.off('channel-updated', handleChannelUpdated)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupEventHandlers()
|
||||
websocketService.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
removeEventHandlers()
|
||||
websocketService.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => websocketService.connect(),
|
||||
disconnect: () => websocketService.disconnect(),
|
||||
isConnected: () => websocketService.isConnected
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user