feat: Enhance mobile chat experience with responsive design and navigation
- Implement mobile-first design for the chat interface, optimizing touch interactions and navigation. - Introduce a peer list view that displays only peers until a selection is made, followed by a full-width chat view. - Add a back button for easy navigation and ensure touch-friendly elements for better usability. - Optimize message bubbles and avatars for mobile readability and visibility. - Update documentation to reflect new mobile-responsive features and navigation improvements.
This commit is contained in:
parent
37642ca48c
commit
87663d1d87
2 changed files with 162 additions and 13 deletions
|
|
@ -99,7 +99,17 @@ Response:
|
||||||
- Message timestamps
|
- Message timestamps
|
||||||
- Auto-scroll to latest messages
|
- Auto-scroll to latest messages
|
||||||
|
|
||||||
### 4. Navigation Features
|
### 4. Mobile-Responsive Design
|
||||||
|
- **Mobile-first approach**: Optimized for touch interactions
|
||||||
|
- **Peer list view**: Shows only peers list on mobile until a peer is selected
|
||||||
|
- **Full-width chat view**: When a peer is selected, switches to full-width chat
|
||||||
|
- **Back button**: Easy navigation back to peers list
|
||||||
|
- **Touch-friendly**: Larger touch targets and proper touch feedback
|
||||||
|
- **Responsive avatars**: Larger avatars on mobile for better visibility
|
||||||
|
- **Message bubbles**: Optimized width (75% max) for mobile readability
|
||||||
|
- **Keyboard-friendly**: Input stays visible when keyboard appears
|
||||||
|
|
||||||
|
### 5. Navigation Features
|
||||||
- Integrated into main navigation menu
|
- Integrated into main navigation menu
|
||||||
- Message icon for easy identification
|
- Message icon for easy identification
|
||||||
- Multi-language support
|
- Multi-language support
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,85 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Mobile: Peer List View -->
|
||||||
<div class="flex items-center justify-between p-4 border-b">
|
<div v-if="!selectedPeer || (isMobile && !showChat)" class="flex flex-col h-full">
|
||||||
<div class="flex items-center space-x-3">
|
<!-- Header -->
|
||||||
<h2 class="text-lg font-semibold">Chat</h2>
|
<div class="flex items-center justify-between p-4 border-b">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<h2 class="text-lg font-semibold">Chat</h2>
|
||||||
|
<Badge v-if="isConnected" variant="default" class="text-xs">
|
||||||
|
Connected
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else variant="secondary" class="text-xs">
|
||||||
|
Disconnected
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
||||||
|
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
||||||
|
<RefreshCw v-else class="h-4 w-4" />
|
||||||
|
<span class="hidden sm:inline ml-2">Refresh</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peer List -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<div class="p-4 border-b">
|
||||||
|
<h3 class="font-medium">Peers ({{ peers.length }})</h3>
|
||||||
|
</div>
|
||||||
|
<ScrollArea class="h-full">
|
||||||
|
<div class="p-2 space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="peer in peers"
|
||||||
|
:key="peer.user_id"
|
||||||
|
@click="selectPeer(peer)"
|
||||||
|
:class="[
|
||||||
|
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors touch-manipulation',
|
||||||
|
selectedPeer?.user_id === peer.user_id
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'hover:bg-muted active:bg-muted/80'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Avatar class="h-10 w-10 sm:h-8 sm:w-8">
|
||||||
|
<AvatarImage v-if="getPeerAvatar(peer)" :src="getPeerAvatar(peer)!" />
|
||||||
|
<AvatarFallback>{{ getPeerInitials(peer) }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium truncate">
|
||||||
|
{{ peer.username || 'Unknown User' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
|
{{ formatPubkey(peer.pubkey) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: Chat View -->
|
||||||
|
<div v-else-if="isMobile && showChat" class="flex flex-col h-full">
|
||||||
|
<!-- Chat Header with Back Button -->
|
||||||
|
<div class="flex items-center justify-between p-4 border-b">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
@click="goBackToPeers"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Avatar class="h-8 w-8">
|
||||||
|
<AvatarImage v-if="getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
|
||||||
|
<AvatarFallback>{{ getPeerInitials(selectedPeer) }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">{{ selectedPeer.username || 'Unknown User' }}</h3>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ formatPubkey(selectedPeer.pubkey) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Badge v-if="isConnected" variant="default" class="text-xs">
|
<Badge v-if="isConnected" variant="default" class="text-xs">
|
||||||
Connected
|
Connected
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -11,15 +87,54 @@
|
||||||
Disconnected
|
Disconnected
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
|
||||||
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
<!-- Messages -->
|
||||||
<RefreshCw v-else class="h-4 w-4" />
|
<ScrollArea class="flex-1 p-4">
|
||||||
Refresh Peers
|
<div class="space-y-4">
|
||||||
</Button>
|
<div
|
||||||
|
v-for="message in currentMessages"
|
||||||
|
:key="message.id"
|
||||||
|
:class="[
|
||||||
|
'flex',
|
||||||
|
message.sent ? 'justify-end' : 'justify-start'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'max-w-[75%] px-4 py-2 rounded-lg',
|
||||||
|
message.sent
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<p class="text-sm break-words">{{ message.content }}</p>
|
||||||
|
<p class="text-xs opacity-70 mt-1">
|
||||||
|
{{ formatTime(message.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="messagesEndRef" />
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<!-- Message Input -->
|
||||||
|
<div class="p-4 border-t bg-background">
|
||||||
|
<form @submit.prevent="sendMessage" class="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
v-model="messageInput"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
:disabled="!isConnected || !selectedPeer"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" :disabled="!isConnected || !selectedPeer || !messageInput.trim()">
|
||||||
|
<Send class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Desktop: Split View -->
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<div v-else class="flex flex-1 overflow-hidden">
|
||||||
<!-- Peer List -->
|
<!-- Peer List -->
|
||||||
<div class="w-80 border-r bg-muted/30">
|
<div class="w-80 border-r bg-muted/30">
|
||||||
<div class="p-4 border-b">
|
<div class="p-4 border-b">
|
||||||
|
|
@ -136,7 +251,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { Send, RefreshCw, MessageSquare } from 'lucide-vue-next'
|
import { Send, RefreshCw, MessageSquare, ArrowLeft } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -164,6 +279,21 @@ const selectedPeer = ref<Peer | null>(null)
|
||||||
const messageInput = ref('')
|
const messageInput = ref('')
|
||||||
const messagesEndRef = ref<HTMLDivElement | null>(null)
|
const messagesEndRef = ref<HTMLDivElement | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const showChat = ref(false)
|
||||||
|
|
||||||
|
// Mobile detection
|
||||||
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
// Check if device is mobile
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768 // md breakpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile navigation
|
||||||
|
const goBackToPeers = () => {
|
||||||
|
showChat.value = false
|
||||||
|
selectedPeer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// Nostr chat composable
|
// Nostr chat composable
|
||||||
const {
|
const {
|
||||||
|
|
@ -218,6 +348,11 @@ const selectPeer = async (peer: Peer) => {
|
||||||
selectedPeer.value = peer
|
selectedPeer.value = peer
|
||||||
messageInput.value = ''
|
messageInput.value = ''
|
||||||
|
|
||||||
|
// On mobile, show chat view
|
||||||
|
if (isMobile.value) {
|
||||||
|
showChat.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to messages from this peer
|
// Subscribe to messages from this peer
|
||||||
await subscribeToPeer(peer.pubkey)
|
await subscribeToPeer(peer.pubkey)
|
||||||
|
|
||||||
|
|
@ -274,11 +409,15 @@ const getPeerInitials = (peer: Peer) => {
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
|
||||||
await connect()
|
await connect()
|
||||||
await loadPeers()
|
await loadPeers()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
disconnect()
|
disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue