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',
 | 
				
			||||||
      { 'message--unsent': isUnsent }
 | 
					      { 'message--unsent': isUnsent }
 | 
				
			||||||
    ]"
 | 
					    ]"
 | 
				
			||||||
 | 
					    ref="rootEl"
 | 
				
			||||||
    :data-message-id="message.id"
 | 
					    :data-message-id="message.id"
 | 
				
			||||||
    :tabindex="tabindex || -1"
 | 
					    :tabindex="tabindex || -1"
 | 
				
			||||||
    :aria-label="messageAriaLabel"
 | 
					    :aria-label="messageAriaLabel"
 | 
				
			||||||
@@ -33,10 +34,12 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup lang="ts">
 | 
					<script setup lang="ts">
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { computed, ref, nextTick } from 'vue'
 | 
				
			||||||
import { useAudio } from '@/composables/useAudio'
 | 
					import { useAudio } from '@/composables/useAudio'
 | 
				
			||||||
import { useToastStore } from '@/stores/toast'
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
import { useAppStore } from '@/stores/app'
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import { syncService } from '@/services/sync'
 | 
				
			||||||
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
 | 
					import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
 | 
				
			||||||
import FileAttachment from './FileAttachment.vue'
 | 
					import FileAttachment from './FileAttachment.vue'
 | 
				
			||||||
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
 | 
					import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
 | 
				
			||||||
@@ -57,6 +60,17 @@ const { speak, playSound } = useAudio()
 | 
				
			|||||||
const toastStore = useToastStore()
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
const appStore = useAppStore()
 | 
					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
 | 
					// Check if message has a file attachment
 | 
				
			||||||
const hasFileAttachment = computed(() => {
 | 
					const hasFileAttachment = computed(() => {
 | 
				
			||||||
  return 'fileId' in props.message && !!props.message.fileId
 | 
					  return 'fileId' in props.message && !!props.message.fileId
 | 
				
			||||||
@@ -160,6 +174,92 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      toastStore.info('Text-to-speech is disabled')
 | 
					      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>
 | 
					</script>
 | 
				
			||||||
@@ -239,4 +339,4 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
    color: #a0aec0;
 | 
					    color: #a0aec0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</style>
 | 
					</style>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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> {
 | 
					  async syncChannelMessages(channelId: number): Promise<void> {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
@@ -19,55 +23,35 @@ export class SyncService {
 | 
				
			|||||||
      // Get server messages
 | 
					      // Get server messages
 | 
				
			||||||
      const serverResponse = await apiService.getMessages(channelId)
 | 
					      const serverResponse = await apiService.getMessages(channelId)
 | 
				
			||||||
      const serverMessages = serverResponse.messages
 | 
					      const serverMessages = serverResponse.messages
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      // Get local messages
 | 
					      console.log(`Server has ${serverMessages.length} messages, replacing local set for channel ${channelId}`)
 | 
				
			||||||
      const localMessages = appStore.messages[channelId] || []
 | 
					
 | 
				
			||||||
      
 | 
					      // Transform and sort server messages only (pruning locals not on server)
 | 
				
			||||||
      console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
 | 
					      const normalizedServerMessages: ExtendedMessage[] = serverMessages
 | 
				
			||||||
      
 | 
					        .map((msg: any) => {
 | 
				
			||||||
      // Merge messages using a simple strategy:
 | 
					          const transformedMsg: ExtendedMessage = {
 | 
				
			||||||
      // 1. Create a map of all messages by ID
 | 
					            id: msg.id,
 | 
				
			||||||
      // 2. Server messages take precedence (they may have been updated)
 | 
					            channel_id: msg.channelId || msg.channel_id,
 | 
				
			||||||
      // 3. Keep local messages that don't exist on server (may be unsent)
 | 
					            content: msg.content,
 | 
				
			||||||
      
 | 
					            created_at: msg.createdAt || msg.created_at,
 | 
				
			||||||
      const messageMap = new Map<number, ExtendedMessage>()
 | 
					            file_id: msg.fileId || msg.file_id,
 | 
				
			||||||
      
 | 
					            // Map the flattened file fields from backend
 | 
				
			||||||
      // Add local messages first
 | 
					            fileId: msg.fileId,
 | 
				
			||||||
      localMessages.forEach(msg => {
 | 
					            filePath: msg.filePath,
 | 
				
			||||||
        if (typeof msg.id === 'number') {
 | 
					            fileType: msg.fileType,
 | 
				
			||||||
          messageMap.set(msg.id, msg)
 | 
					            fileSize: msg.fileSize,
 | 
				
			||||||
        }
 | 
					            originalName: msg.originalName,
 | 
				
			||||||
      })
 | 
					            fileCreatedAt: msg.fileCreatedAt
 | 
				
			||||||
      
 | 
					          }
 | 
				
			||||||
      // Add/update with server messages (server wins for conflicts)
 | 
					          console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
 | 
				
			||||||
      serverMessages.forEach((msg: any) => {
 | 
					          return transformedMsg
 | 
				
			||||||
        // Transform server message format to match our types
 | 
					        })
 | 
				
			||||||
        const transformedMsg: ExtendedMessage = {
 | 
					        .sort((a: ExtendedMessage, b: ExtendedMessage) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
 | 
				
			||||||
          id: msg.id,
 | 
					
 | 
				
			||||||
          channel_id: msg.channelId || msg.channel_id,
 | 
					      console.log(`Pruned + normalized result: ${normalizedServerMessages.length} messages`)
 | 
				
			||||||
          content: msg.content,
 | 
					
 | 
				
			||||||
          created_at: msg.createdAt || msg.created_at,
 | 
					      // Update local storage with server truth
 | 
				
			||||||
          file_id: msg.fileId || msg.file_id,
 | 
					      appStore.setMessages(channelId, normalizedServerMessages)
 | 
				
			||||||
          // Map the flattened file fields from backend
 | 
					 | 
				
			||||||
          fileId: msg.fileId,
 | 
					 | 
				
			||||||
          filePath: msg.filePath,
 | 
					 | 
				
			||||||
          fileType: msg.fileType,
 | 
					 | 
				
			||||||
          fileSize: msg.fileSize,
 | 
					 | 
				
			||||||
          originalName: msg.originalName,
 | 
					 | 
				
			||||||
          fileCreatedAt: msg.fileCreatedAt
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
 | 
					 | 
				
			||||||
        messageMap.set(msg.id, transformedMsg)
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // 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(`Merged result: ${mergedMessages.length} messages`)
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      // Update local storage
 | 
					 | 
				
			||||||
      appStore.setMessages(channelId, mergedMessages)
 | 
					 | 
				
			||||||
      await appStore.saveState()
 | 
					      await appStore.saveState()
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
@@ -302,4 +286,4 @@ export class SyncService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const syncService = new SyncService()
 | 
					export const syncService = new SyncService()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -71,9 +71,22 @@ export const useAppStore = defineStore('app', () => {
 | 
				
			|||||||
    if (!messages.value[message.channel_id]) {
 | 
					    if (!messages.value[message.channel_id]) {
 | 
				
			||||||
      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
 | 
					    // Note: Auto-save is now handled by the sync service to avoid excessive I/O
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -175,4 +188,4 @@ export const useAppStore = defineStore('app', () => {
 | 
				
			|||||||
    loadState,
 | 
					    loadState,
 | 
				
			||||||
    saveState
 | 
					    saveState
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user