Add custom URL support to vue frontend
This commit is contained in:
@@ -145,6 +145,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Account</h3>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label>Current Server</label>
|
||||||
|
<div class="server-info">
|
||||||
|
{{ currentServerUrl || 'Default' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="handleLogout"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
@click="showResetConfirm = true"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Reset All Data
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,14 +192,42 @@
|
|||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Reset Data Confirmation Dialog -->
|
||||||
|
<div v-if="showResetConfirm" class="confirm-overlay">
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<h3>Reset All Data</h3>
|
||||||
|
<p>This will permanently delete all local data including messages, settings, and authentication. This cannot be undone.</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="showResetConfirm = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
@click="handleResetData"
|
||||||
|
:loading="isResetting"
|
||||||
|
>
|
||||||
|
Reset All Data
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
|
import { clear } from 'idb-keyval'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import type { AppSettings } from '@/types'
|
import type { AppSettings } from '@/types'
|
||||||
|
|
||||||
@@ -176,12 +235,19 @@ const emit = defineEmits<{
|
|||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const { availableVoices, speak, setVoice } = useAudio()
|
const { availableVoices, speak, setVoice } = useAudio()
|
||||||
|
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
const isResetting = ref(false)
|
||||||
|
const showResetConfirm = ref(false)
|
||||||
const selectedVoiceURI = ref('')
|
const selectedVoiceURI = ref('')
|
||||||
|
|
||||||
|
// Computed property for current server URL
|
||||||
|
const currentServerUrl = computed(() => authStore.serverUrl)
|
||||||
const localSettings = reactive<AppSettings>({
|
const localSettings = reactive<AppSettings>({
|
||||||
soundEnabled: true,
|
soundEnabled: true,
|
||||||
speechEnabled: true,
|
speechEnabled: true,
|
||||||
@@ -229,6 +295,43 @@ const handleSave = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await authStore.clearAuth()
|
||||||
|
toastStore.success('Logged out successfully')
|
||||||
|
emit('close')
|
||||||
|
router.push('/auth')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error)
|
||||||
|
toastStore.error('Logout failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetData = async () => {
|
||||||
|
isResetting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear all IndexedDB data
|
||||||
|
await clear()
|
||||||
|
|
||||||
|
// Clear stores
|
||||||
|
await authStore.clearAuth()
|
||||||
|
appStore.$reset()
|
||||||
|
|
||||||
|
toastStore.success('All data has been reset')
|
||||||
|
showResetConfirm.value = false
|
||||||
|
emit('close')
|
||||||
|
|
||||||
|
// Redirect to auth page
|
||||||
|
router.push('/auth')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reset failed:', error)
|
||||||
|
toastStore.error('Failed to reset data')
|
||||||
|
} finally {
|
||||||
|
isResetting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Copy current settings to local state
|
// Copy current settings to local state
|
||||||
Object.assign(localSettings, appStore.settings)
|
Object.assign(localSettings, appStore.settings)
|
||||||
@@ -340,6 +443,63 @@ onMounted(() => {
|
|||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 1rem;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog p {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode */
|
/* Dark mode */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.setting-group h3 {
|
.setting-group h3 {
|
||||||
@@ -360,5 +520,25 @@ onMounted(() => {
|
|||||||
.form-actions {
|
.form-actions {
|
||||||
border-top-color: #374151;
|
border-top-color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog h3 {
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog p {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@@ -1,12 +1,14 @@
|
|||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { websocketService } from '@/services/websocket'
|
import { websocketService } from '@/services/websocket'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
|
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
|
||||||
|
|
||||||
export function useWebSocket() {
|
export function useWebSocket() {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const { announceMessage } = useAudio()
|
const { announceMessage } = useAudio()
|
||||||
|
|
||||||
@@ -157,6 +159,11 @@ export function useWebSocket() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Set custom server URL if available
|
||||||
|
if (authStore.serverUrl) {
|
||||||
|
websocketService.setServerUrl(authStore.serverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
setupEventHandlers()
|
setupEventHandlers()
|
||||||
websocketService.connect()
|
websocketService.connect()
|
||||||
})
|
})
|
||||||
|
@@ -9,6 +9,11 @@ class ApiService {
|
|||||||
console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
|
console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBaseUrl(url: string) {
|
||||||
|
this.baseUrl = url
|
||||||
|
console.log('API service base URL set:', url)
|
||||||
|
}
|
||||||
|
|
||||||
private getHeaders(): HeadersInit {
|
private getHeaders(): HeadersInit {
|
||||||
return {
|
return {
|
||||||
'Authorization': this.token,
|
'Authorization': this.token,
|
||||||
|
@@ -6,20 +6,35 @@ class WebSocketService {
|
|||||||
private maxReconnectAttempts = 5
|
private maxReconnectAttempts = 5
|
||||||
private reconnectInterval = 1000
|
private reconnectInterval = 1000
|
||||||
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
|
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
|
||||||
|
private customServerUrl: string | null = null
|
||||||
|
|
||||||
|
setServerUrl(url: string) {
|
||||||
|
this.customServerUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// In development, connect to backend server (port 3000)
|
// Determine WebSocket URL
|
||||||
// In production, use same host as frontend
|
let wsUrl: string
|
||||||
const isDev = import.meta.env.DEV
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
if (this.customServerUrl) {
|
||||||
const host = isDev ? 'localhost:3000' : window.location.host
|
// Use custom server URL
|
||||||
const wsUrl = `${protocol}//${host}`
|
const url = new URL(this.customServerUrl)
|
||||||
|
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
wsUrl = `${protocol}//${url.host}`
|
||||||
|
} else {
|
||||||
|
// Use default behavior
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = isDev ? 'localhost:3000' : window.location.host
|
||||||
|
wsUrl = `${protocol}//${host}`
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('Connecting to WebSocket:', wsUrl)
|
||||||
this.ws = new WebSocket(wsUrl)
|
this.ws = new WebSocket(wsUrl)
|
||||||
this.setupEventListeners()
|
this.setupEventListeners()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@@ -4,27 +4,58 @@ import { get, set } from 'idb-keyval'
|
|||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(null)
|
const token = ref<string | null>(null)
|
||||||
|
const serverUrl = ref<string | null>(null)
|
||||||
const isAuthenticated = ref(false)
|
const isAuthenticated = ref(false)
|
||||||
|
|
||||||
const setToken = async (newToken: string) => {
|
// Get default server URL based on environment
|
||||||
|
const getDefaultServerUrl = () => {
|
||||||
|
return import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const setToken = async (newToken: string, customServerUrl?: string) => {
|
||||||
token.value = newToken
|
token.value = newToken
|
||||||
isAuthenticated.value = true
|
isAuthenticated.value = true
|
||||||
await set('auth_token', newToken)
|
|
||||||
|
// Set server URL or use default
|
||||||
|
const urlToUse = customServerUrl || getDefaultServerUrl()
|
||||||
|
serverUrl.value = urlToUse
|
||||||
|
|
||||||
|
// Save both token and server URL
|
||||||
|
await Promise.all([
|
||||||
|
set('auth_token', newToken),
|
||||||
|
set('server_url', urlToUse)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const setServerUrl = async (url: string) => {
|
||||||
|
serverUrl.value = url
|
||||||
|
await set('server_url', url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAuth = async () => {
|
const clearAuth = async () => {
|
||||||
token.value = null
|
token.value = null
|
||||||
|
serverUrl.value = null
|
||||||
isAuthenticated.value = false
|
isAuthenticated.value = false
|
||||||
await set('auth_token', null)
|
await Promise.all([
|
||||||
|
set('auth_token', null),
|
||||||
|
set('server_url', null)
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const storedToken = await get('auth_token')
|
const [storedToken, storedServerUrl] = await Promise.all([
|
||||||
|
get('auth_token'),
|
||||||
|
get('server_url')
|
||||||
|
])
|
||||||
|
|
||||||
if (storedToken) {
|
if (storedToken) {
|
||||||
|
// Set server URL or use default
|
||||||
|
const urlToUse = storedServerUrl || getDefaultServerUrl()
|
||||||
|
serverUrl.value = urlToUse
|
||||||
|
|
||||||
// Verify token with backend
|
// Verify token with backend
|
||||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
const response = await fetch(`${urlToUse}/check-token`, {
|
||||||
const response = await fetch(`${baseUrl}/check-token`, {
|
|
||||||
headers: { Authorization: storedToken }
|
headers: { Authorization: storedToken }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,15 +73,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticate = async (authToken: string): Promise<boolean> => {
|
const authenticate = async (authToken: string, customServerUrl?: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
const urlToUse = customServerUrl || getDefaultServerUrl()
|
||||||
const response = await fetch(`${baseUrl}/check-token`, {
|
const response = await fetch(`${urlToUse}/check-token`, {
|
||||||
headers: { Authorization: authToken }
|
headers: { Authorization: authToken }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
await setToken(authToken)
|
await setToken(authToken, urlToUse)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
await clearAuth()
|
await clearAuth()
|
||||||
@@ -65,10 +96,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
|
serverUrl,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
setToken,
|
setToken,
|
||||||
|
setServerUrl,
|
||||||
clearAuth,
|
clearAuth,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
authenticate
|
authenticate,
|
||||||
|
getDefaultServerUrl
|
||||||
}
|
}
|
||||||
})
|
})
|
@@ -84,6 +84,7 @@ export interface AppSettings {
|
|||||||
selectedVoiceURI: string | null
|
selectedVoiceURI: string | null
|
||||||
defaultChannelId: number | null
|
defaultChannelId: number | null
|
||||||
theme: 'light' | 'dark' | 'auto'
|
theme: 'light' | 'dark' | 'auto'
|
||||||
|
serverUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio Types
|
// Audio Types
|
||||||
|
@@ -7,6 +7,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="handleAuth" class="auth-form">
|
<form @submit.prevent="handleAuth" class="auth-form">
|
||||||
|
<BaseInput
|
||||||
|
v-model="serverUrl"
|
||||||
|
type="url"
|
||||||
|
label="Server URL (optional)"
|
||||||
|
:placeholder="defaultServerUrl"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
|
||||||
<BaseInput
|
<BaseInput
|
||||||
v-model="token"
|
v-model="token"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -47,10 +55,14 @@ const toastStore = useToastStore()
|
|||||||
const { playSound } = useAudio()
|
const { playSound } = useAudio()
|
||||||
|
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
|
const serverUrl = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const tokenInput = ref()
|
const tokenInput = ref()
|
||||||
|
|
||||||
|
// Get default server URL for placeholder
|
||||||
|
const defaultServerUrl = authStore.getDefaultServerUrl()
|
||||||
|
|
||||||
const handleAuth = async () => {
|
const handleAuth = async () => {
|
||||||
if (!token.value.trim()) return
|
if (!token.value.trim()) return
|
||||||
|
|
||||||
@@ -58,18 +70,20 @@ const handleAuth = async () => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await authStore.authenticate(token.value.trim())
|
// Use custom server URL if provided, otherwise use default
|
||||||
|
const customUrl = serverUrl.value.trim() || undefined
|
||||||
|
const success = await authStore.authenticate(token.value.trim(), customUrl)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await playSound('login')
|
await playSound('login')
|
||||||
toastStore.success('Authentication successful!')
|
toastStore.success('Authentication successful!')
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} else {
|
} else {
|
||||||
error.value = 'Invalid authentication token'
|
error.value = 'Invalid authentication token or server URL'
|
||||||
tokenInput.value?.focus()
|
tokenInput.value?.focus()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = 'Authentication failed. Please try again.'
|
error.value = 'Authentication failed. Please check your token and server URL.'
|
||||||
console.error('Auth error:', err)
|
console.error('Auth error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
@@ -158,10 +158,13 @@ const toastStore = useToastStore()
|
|||||||
const { sendMessage: sendMessageOffline } = useOfflineSync()
|
const { sendMessage: sendMessageOffline } = useOfflineSync()
|
||||||
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
|
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
|
||||||
|
|
||||||
// Set up services - ensure token is properly set
|
// Set up services - ensure token and URL are properly set
|
||||||
if (authStore.token) {
|
if (authStore.token) {
|
||||||
apiService.setToken(authStore.token)
|
apiService.setToken(authStore.token)
|
||||||
}
|
}
|
||||||
|
if (authStore.serverUrl) {
|
||||||
|
apiService.setBaseUrl(authStore.serverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const messagesContainer = ref()
|
const messagesContainer = ref()
|
||||||
|
Reference in New Issue
Block a user