Compare commits
	
		
			2 Commits
		
	
	
		
			cf15a0f9c2
			...
			22b8392fd5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 22b8392fd5 | |||
| 9948d1c25b | 
							
								
								
									
										228
									
								
								frontend-vue/-
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								frontend-vue/-
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,228 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="base-textarea">
 | 
			
		||||
    <label v-if="label" :for="textareaId" class="base-textarea__label">
 | 
			
		||||
      {{ label }}
 | 
			
		||||
      <span v-if="required" class="base-textarea__required">*</span>
 | 
			
		||||
    </label>
 | 
			
		||||
    
 | 
			
		||||
    <div class="base-textarea__wrapper">
 | 
			
		||||
      <textarea
 | 
			
		||||
        :id="textareaId"
 | 
			
		||||
        ref="textareaRef"
 | 
			
		||||
        :value="modelValue"
 | 
			
		||||
        :placeholder="placeholder"
 | 
			
		||||
        :disabled="disabled"
 | 
			
		||||
        :readonly="readonly"
 | 
			
		||||
        :required="required"
 | 
			
		||||
        :rows="rows"
 | 
			
		||||
        :maxlength="maxlength"
 | 
			
		||||
        :aria-invalid="error ? 'true' : 'false'"
 | 
			
		||||
        :aria-describedby="error ? `${textareaId}-error` : undefined"
 | 
			
		||||
        :class="[
 | 
			
		||||
          'base-textarea__field',
 | 
			
		||||
          { 'base-textarea__field--error': error }
 | 
			
		||||
        ]"
 | 
			
		||||
        @input="handleInput"
 | 
			
		||||
        @blur="$emit('blur', $event)"
 | 
			
		||||
        @focus="$emit('focus', $event)"
 | 
			
		||||
        @keydown="handleKeydown"
 | 
			
		||||
        @keyup="$emit('keyup', $event)"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <div v-if="showCharCount && maxlength" class="base-textarea__char-count">
 | 
			
		||||
      {{ modelValue.length }}/{{ maxlength }}
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <div v-if="error" :id="`${textareaId}-error`" class="base-textarea__error">
 | 
			
		||||
      {{ error }}
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <div v-else-if="helpText" class="base-textarea__help">
 | 
			
		||||
      {{ helpText }}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, computed } from 'vue'
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  modelValue: string
 | 
			
		||||
  label?: string
 | 
			
		||||
  placeholder?: string
 | 
			
		||||
  disabled?: boolean
 | 
			
		||||
  readonly?: boolean
 | 
			
		||||
  required?: boolean
 | 
			
		||||
  rows?: number
 | 
			
		||||
  maxlength?: number
 | 
			
		||||
  showCharCount?: boolean
 | 
			
		||||
  error?: string
 | 
			
		||||
  helpText?: string
 | 
			
		||||
  id?: string
 | 
			
		||||
  autoResize?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<Props>(), {
 | 
			
		||||
  disabled: false,
 | 
			
		||||
  readonly: false,
 | 
			
		||||
  required: false,
 | 
			
		||||
  rows: 3,
 | 
			
		||||
  showCharCount: false,
 | 
			
		||||
  autoResize: false
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
  'update:modelValue': [value: string]
 | 
			
		||||
  blur: [event: FocusEvent]
 | 
			
		||||
  focus: [event: FocusEvent]
 | 
			
		||||
  keydown: [event: KeyboardEvent]
 | 
			
		||||
  keyup: [event: KeyboardEvent]
 | 
			
		||||
  submit: []
 | 
			
		||||
}>()
 | 
			
		||||
 | 
			
		||||
const textareaRef = ref<HTMLTextAreaElement>()
 | 
			
		||||
const textareaId = computed(() => props.id || `textarea-${Math.random().toString(36).substr(2, 9)}`)
 | 
			
		||||
 | 
			
		||||
const handleInput = (event: Event) => {
 | 
			
		||||
  const target = event.target as HTMLTextAreaElement
 | 
			
		||||
  emit('update:modelValue', target.value)
 | 
			
		||||
  
 | 
			
		||||
  if (props.autoResize) {
 | 
			
		||||
    autoResize(target)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const handleKeydown = (event: KeyboardEvent) => {
 | 
			
		||||
  emit('keydown', event)
 | 
			
		||||
  
 | 
			
		||||
  // Submit on Ctrl+Enter or Cmd+Enter
 | 
			
		||||
  if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
 | 
			
		||||
    event.preventDefault()
 | 
			
		||||
    emit('submit')
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const autoResize = (textarea: HTMLTextAreaElement) => {
 | 
			
		||||
  textarea.style.height = 'auto'
 | 
			
		||||
  textarea.style.height = textarea.scrollHeight + 'px'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const focus = () => {
 | 
			
		||||
  textareaRef.value?.focus()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const selectAll = () => {
 | 
			
		||||
  textareaRef.value?.select()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
defineExpose({
 | 
			
		||||
  focus,
 | 
			
		||||
  selectAll,
 | 
			
		||||
  textareaRef
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.base-textarea {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  gap: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__label {
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  color: #374151;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__required {
 | 
			
		||||
  color: #ef4444;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__wrapper {
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__field {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  padding: 0.75rem 1rem;
 | 
			
		||||
  border: 1px solid #d1d5db;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  font-size: 1rem;
 | 
			
		||||
  font-family: inherit;
 | 
			
		||||
  background-color: #ffffff;
 | 
			
		||||
  color: #111827;
 | 
			
		||||
  transition: all 0.2s ease;
 | 
			
		||||
  outline: none;
 | 
			
		||||
  resize: vertical;
 | 
			
		||||
  min-height: 3rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__field:focus {
 | 
			
		||||
  border-color: #646cff;
 | 
			
		||||
  box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__field:disabled {
 | 
			
		||||
  background-color: #f9fafb;
 | 
			
		||||
  color: #9ca3af;
 | 
			
		||||
  cursor: not-allowed;
 | 
			
		||||
  resize: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__field:readonly {
 | 
			
		||||
  background-color: #f9fafb;
 | 
			
		||||
  cursor: default;
 | 
			
		||||
  resize: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__field--error {
 | 
			
		||||
  border-color: #ef4444;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__field--error:focus {
 | 
			
		||||
  border-color: #ef4444;
 | 
			
		||||
  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__char-count {
 | 
			
		||||
  font-size: 0.75rem;
 | 
			
		||||
  color: #6b7280;
 | 
			
		||||
  text-align: right;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__error {
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  color: #ef4444;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.base-textarea__help {
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  color: #6b7280;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Dark mode */
 | 
			
		||||
@media (prefers-color-scheme: dark) {
 | 
			
		||||
  .base-textarea__label {
 | 
			
		||||
    color: rgba(255, 255, 255, 0.87);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .base-textarea__field {
 | 
			
		||||
    background-color: #374151;
 | 
			
		||||
    color: rgba(255, 255, 255, 0.87);
 | 
			
		||||
    border-color: #4b5563;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .base-textarea__field:disabled,
 | 
			
		||||
  .base-textarea__field:readonly {
 | 
			
		||||
    background-color: #1f2937;
 | 
			
		||||
    color: #9ca3af;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .base-textarea__help,
 | 
			
		||||
  .base-textarea__char-count {
 | 
			
		||||
    color: #9ca3af;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -4,6 +4,7 @@
 | 
			
		||||
      'message',
 | 
			
		||||
      { 'message--unsent': isUnsent }
 | 
			
		||||
    ]"
 | 
			
		||||
    ref="rootEl"
 | 
			
		||||
    :data-message-id="message.id"
 | 
			
		||||
    :tabindex="tabindex || -1"
 | 
			
		||||
    :aria-label="messageAriaLabel"
 | 
			
		||||
@@ -33,10 +34,12 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { computed } from 'vue'
 | 
			
		||||
import { computed, ref, nextTick } from 'vue'
 | 
			
		||||
import { useAudio } from '@/composables/useAudio'
 | 
			
		||||
import { useToastStore } from '@/stores/toast'
 | 
			
		||||
import { useAppStore } from '@/stores/app'
 | 
			
		||||
import { apiService } from '@/services/api'
 | 
			
		||||
import { syncService } from '@/services/sync'
 | 
			
		||||
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
 | 
			
		||||
import FileAttachment from './FileAttachment.vue'
 | 
			
		||||
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
 | 
			
		||||
@@ -57,6 +60,17 @@ const { speak, playSound } = useAudio()
 | 
			
		||||
const toastStore = useToastStore()
 | 
			
		||||
const appStore = useAppStore()
 | 
			
		||||
 | 
			
		||||
// Root element ref for DOM-based focus management
 | 
			
		||||
const rootEl = ref<HTMLElement | null>(null)
 | 
			
		||||
 | 
			
		||||
// Fallback: focus the chat input textarea
 | 
			
		||||
const focusFallbackToInput = () => {
 | 
			
		||||
  const inputEl = document.querySelector('.message-input .base-textarea__field') as HTMLElement | null
 | 
			
		||||
  if (inputEl) {
 | 
			
		||||
    inputEl.focus()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Check if message has a file attachment
 | 
			
		||||
const hasFileAttachment = computed(() => {
 | 
			
		||||
  return 'fileId' in props.message && !!props.message.fileId
 | 
			
		||||
@@ -160,6 +174,92 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
			
		||||
    } else {
 | 
			
		||||
      toastStore.info('Text-to-speech is disabled')
 | 
			
		||||
    }
 | 
			
		||||
  } else if (event.key === 'Delete') {
 | 
			
		||||
    event.preventDefault()
 | 
			
		||||
    handleDelete()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Delete current message (supports sent and unsent)
 | 
			
		||||
const handleDelete = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    // Capture neighboring elements before removal
 | 
			
		||||
    const current = rootEl.value
 | 
			
		||||
    const prevEl = (current?.previousElementSibling as HTMLElement | null) || null
 | 
			
		||||
    const nextEl = (current?.nextElementSibling as HTMLElement | null) || null
 | 
			
		||||
    const isFirst = !prevEl
 | 
			
		||||
    const targetToFocus = isFirst ? nextEl : prevEl
 | 
			
		||||
 | 
			
		||||
    if (props.isUnsent) {
 | 
			
		||||
      // Unsent local message
 | 
			
		||||
      const unsent = props.message as UnsentMessage
 | 
			
		||||
      appStore.removeUnsentMessage(unsent.id)
 | 
			
		||||
      toastStore.success('Unsent message removed')
 | 
			
		||||
      // focus the closest message
 | 
			
		||||
      await nextTick()
 | 
			
		||||
      if (targetToFocus && document.contains(targetToFocus)) {
 | 
			
		||||
        if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
 | 
			
		||||
        targetToFocus.focus()
 | 
			
		||||
      } else {
 | 
			
		||||
        focusFallbackToInput()
 | 
			
		||||
      }
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Sent message: optimistic removal, then server delete
 | 
			
		||||
    const msg = props.message as ExtendedMessage
 | 
			
		||||
 | 
			
		||||
    // Capture original position for potential rollback
 | 
			
		||||
    const channelMessages = appStore.messages[msg.channel_id] || []
 | 
			
		||||
    const originalIndex = channelMessages.findIndex(m => m.id === msg.id)
 | 
			
		||||
 | 
			
		||||
    // Optimistically remove from local state for snappy UI
 | 
			
		||||
    appStore.removeMessage(msg.id)
 | 
			
		||||
 | 
			
		||||
    // Focus the closest message immediately after local removal
 | 
			
		||||
    await nextTick()
 | 
			
		||||
    if (targetToFocus && document.contains(targetToFocus)) {
 | 
			
		||||
      if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
 | 
			
		||||
      targetToFocus.focus()
 | 
			
		||||
    } else {
 | 
			
		||||
      focusFallbackToInput()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await apiService.deleteMessage(msg.channel_id, msg.id)
 | 
			
		||||
      // Attempt to sync the channel to reconcile with server state
 | 
			
		||||
      try {
 | 
			
		||||
        await syncService.syncChannelMessages(msg.channel_id)
 | 
			
		||||
      } catch (syncError) {
 | 
			
		||||
        console.warn('Post-delete sync failed; continuing with local state.', syncError)
 | 
			
		||||
      }
 | 
			
		||||
      toastStore.success('Message deleted')
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      // Rollback local removal on failure
 | 
			
		||||
      if (originalIndex !== -1) {
 | 
			
		||||
        const list = appStore.messages[msg.channel_id] || []
 | 
			
		||||
        list.splice(Math.min(originalIndex, list.length), 0, msg)
 | 
			
		||||
      }
 | 
			
		||||
      await nextTick()
 | 
			
		||||
      const restoredEl = document.querySelector(`[data-message-id="${msg.id}"]`) as HTMLElement | null
 | 
			
		||||
      if (restoredEl) {
 | 
			
		||||
        if (!restoredEl.hasAttribute('tabindex')) restoredEl.setAttribute('tabindex', '-1')
 | 
			
		||||
        restoredEl.focus()
 | 
			
		||||
      }
 | 
			
		||||
      throw error
 | 
			
		||||
    }
 | 
			
		||||
    // focus the closest message
 | 
			
		||||
    await nextTick()
 | 
			
		||||
    if (targetToFocus && document.contains(targetToFocus)) {
 | 
			
		||||
      if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
 | 
			
		||||
      targetToFocus.focus()
 | 
			
		||||
    } else {
 | 
			
		||||
      focusFallbackToInput()
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to delete message:', error)
 | 
			
		||||
    toastStore.error('Failed to delete message')
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,11 @@ export class SyncService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Sync messages for a channel: merge server data with local data
 | 
			
		||||
   * Sync messages for a channel: replace local data with server data
 | 
			
		||||
   *
 | 
			
		||||
   * Prunes any local messages that are no longer present on the server
 | 
			
		||||
   * instead of keeping them around. We still keep unsent messages in the
 | 
			
		||||
   * separate unsent queue handled elsewhere.
 | 
			
		||||
   */
 | 
			
		||||
  async syncChannelMessages(channelId: number): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
@@ -20,28 +24,11 @@ export class SyncService {
 | 
			
		||||
      const serverResponse = await apiService.getMessages(channelId)
 | 
			
		||||
      const serverMessages = serverResponse.messages
 | 
			
		||||
 | 
			
		||||
      // Get local messages
 | 
			
		||||
      const localMessages = appStore.messages[channelId] || []
 | 
			
		||||
      console.log(`Server has ${serverMessages.length} messages, replacing local set for channel ${channelId}`)
 | 
			
		||||
 | 
			
		||||
      console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
 | 
			
		||||
      
 | 
			
		||||
      // Merge messages using a simple strategy:
 | 
			
		||||
      // 1. Create a map of all messages by ID
 | 
			
		||||
      // 2. Server messages take precedence (they may have been updated)
 | 
			
		||||
      // 3. Keep local messages that don't exist on server (may be unsent)
 | 
			
		||||
      
 | 
			
		||||
      const messageMap = new Map<number, ExtendedMessage>()
 | 
			
		||||
      
 | 
			
		||||
      // Add local messages first
 | 
			
		||||
      localMessages.forEach(msg => {
 | 
			
		||||
        if (typeof msg.id === 'number') {
 | 
			
		||||
          messageMap.set(msg.id, msg)
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      
 | 
			
		||||
      // Add/update with server messages (server wins for conflicts)
 | 
			
		||||
      serverMessages.forEach((msg: any) => {
 | 
			
		||||
        // Transform server message format to match our types
 | 
			
		||||
      // Transform and sort server messages only (pruning locals not on server)
 | 
			
		||||
      const normalizedServerMessages: ExtendedMessage[] = serverMessages
 | 
			
		||||
        .map((msg: any) => {
 | 
			
		||||
          const transformedMsg: ExtendedMessage = {
 | 
			
		||||
            id: msg.id,
 | 
			
		||||
            channel_id: msg.channelId || msg.channel_id,
 | 
			
		||||
@@ -57,17 +44,14 @@ export class SyncService {
 | 
			
		||||
            fileCreatedAt: msg.fileCreatedAt
 | 
			
		||||
          }
 | 
			
		||||
          console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
 | 
			
		||||
        messageMap.set(msg.id, transformedMsg)
 | 
			
		||||
          return transformedMsg
 | 
			
		||||
        })
 | 
			
		||||
        .sort((a: ExtendedMessage, b: ExtendedMessage) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
 | 
			
		||||
 | 
			
		||||
      // Convert back to array, sorted by creation time
 | 
			
		||||
      const mergedMessages = Array.from(messageMap.values())
 | 
			
		||||
        .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
 | 
			
		||||
      console.log(`Pruned + normalized result: ${normalizedServerMessages.length} messages`)
 | 
			
		||||
 | 
			
		||||
      console.log(`Merged result: ${mergedMessages.length} messages`)
 | 
			
		||||
      
 | 
			
		||||
      // Update local storage
 | 
			
		||||
      appStore.setMessages(channelId, mergedMessages)
 | 
			
		||||
      // Update local storage with server truth
 | 
			
		||||
      appStore.setMessages(channelId, normalizedServerMessages)
 | 
			
		||||
      await appStore.saveState()
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
 
 | 
			
		||||
@@ -71,8 +71,21 @@ export const useAppStore = defineStore('app', () => {
 | 
			
		||||
    if (!messages.value[message.channel_id]) {
 | 
			
		||||
      messages.value[message.channel_id] = []
 | 
			
		||||
    }
 | 
			
		||||
    messages.value[message.channel_id].push(message)
 | 
			
		||||
    console.log('Store: Messages for channel', message.channel_id, 'now has', messages.value[message.channel_id].length, 'messages')
 | 
			
		||||
 | 
			
		||||
    const channelMessages = messages.value[message.channel_id]
 | 
			
		||||
    const existingIndex = channelMessages.findIndex(m => m.id === message.id)
 | 
			
		||||
 | 
			
		||||
    if (existingIndex !== -1) {
 | 
			
		||||
      // Upsert: update existing to avoid duplicates from WebSocket vs sync
 | 
			
		||||
      channelMessages[existingIndex] = { ...channelMessages[existingIndex], ...message }
 | 
			
		||||
    } else {
 | 
			
		||||
      channelMessages.push(message)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Keep chronological order by created_at
 | 
			
		||||
    channelMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
 | 
			
		||||
 | 
			
		||||
    console.log('Store: Messages for channel', message.channel_id, 'now has', channelMessages.length, 'messages')
 | 
			
		||||
 | 
			
		||||
    // Note: Auto-save is now handled by the sync service to avoid excessive I/O
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user