added media fixes, search, push updates, pwa, metrics. preparing release 1.0
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Avatar, { GroupAvatar } from './Avatar'
|
||||
import { IconClose, IconSearch } from './icons'
|
||||
import { api } from '../lib/api'
|
||||
import { formatListTime, peerForConversation, userTitle } from '../lib/format'
|
||||
import type { Conversation, SearchHit, User } from '../lib/types'
|
||||
|
||||
interface SearchDialogProps {
|
||||
onClose: () => void
|
||||
onSelect: (conversationId: number) => void
|
||||
conversations: Conversation[]
|
||||
peers: Record<number, number>
|
||||
directory: Map<number, User>
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
const MIN_QUERY = 2
|
||||
|
||||
export default function SearchDialog({
|
||||
onClose,
|
||||
onSelect,
|
||||
conversations,
|
||||
peers,
|
||||
directory,
|
||||
currentUser,
|
||||
}: SearchDialogProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchHit[]>([])
|
||||
const [searchedTerm, setSearchedTerm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const term = query.trim()
|
||||
if (term.length < MIN_QUERY) return
|
||||
let cancelled = false
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoading(true)
|
||||
api
|
||||
.searchMessages(term)
|
||||
.then((hits) => {
|
||||
if (cancelled) return
|
||||
setResults(hits)
|
||||
setSearchedTerm(term)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setResults([])
|
||||
setSearchedTerm(term)
|
||||
setError(err instanceof Error ? err.message : 'Не удалось выполнить поиск')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
}, 300)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const titleFor = (hit: SearchHit): string => {
|
||||
const conversation = conversations.find((item) => item.id === hit.conversation.id)
|
||||
if (conversation) {
|
||||
if (conversation.type === 'group') return conversation.name || `Группа #${conversation.id}`
|
||||
const peer = peerForConversation(conversation, peers, directory, currentUser.id)
|
||||
if (peer) return userTitle(peer)
|
||||
}
|
||||
if (hit.conversation.type === 'group') return hit.conversation.name || `Группа #${hit.conversation.id}`
|
||||
return hit.conversation.name || `Чат #${hit.conversation.id}`
|
||||
}
|
||||
|
||||
const isGroupHit = (hit: SearchHit): boolean => {
|
||||
const conversation = conversations.find((item) => item.id === hit.conversation.id)
|
||||
return (conversation?.type ?? hit.conversation.type) === 'group'
|
||||
}
|
||||
|
||||
const previewFor = (hit: SearchHit): string => {
|
||||
if (!hit.message.body && hit.message.media) return 'Вложение'
|
||||
return hit.message.body
|
||||
}
|
||||
|
||||
const trimmed = query.trim()
|
||||
const tooShort = trimmed.length < MIN_QUERY
|
||||
const settled = !tooShort && !loading && searchedTerm === trimmed
|
||||
const showEmpty = settled && !error && results.length === 0
|
||||
|
||||
const handlePick = (hit: SearchHit) => {
|
||||
onSelect(hit.conversation.id)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-0 backdrop-blur-sm sm:p-6 sm:pt-[12vh]">
|
||||
<button type="button" aria-label="Закрыть поиск" onClick={onClose} className="absolute inset-0 cursor-default" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="search-title"
|
||||
className="relative flex max-h-[80vh] w-full max-w-lg animate-rise flex-col overflow-hidden rounded-t-3xl border border-line bg-panel shadow-2xl sm:rounded-3xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line/80 px-5 py-4">
|
||||
<div>
|
||||
<h2 id="search-title" className="font-display text-base font-semibold text-fg">
|
||||
Поиск по сообщениям
|
||||
</h2>
|
||||
<p className="text-xs text-muted">Найдите текст в любом разговоре</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<IconClose className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4">
|
||||
<div className="relative">
|
||||
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
|
||||
<label htmlFor="message-search" className="sr-only">
|
||||
Поиск по сообщениям
|
||||
</label>
|
||||
<input
|
||||
id="message-search"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Введите минимум 2 символа"
|
||||
className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-slim flex-1 overflow-y-auto px-3 py-3">
|
||||
{tooShort && (
|
||||
<div className="px-6 py-12 text-center text-sm text-muted">
|
||||
Введите минимум {MIN_QUERY} символа, чтобы начать поиск
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!tooShort && loading && (
|
||||
<div className="space-y-1.5 p-1">
|
||||
{[0, 1, 2].map((row) => (
|
||||
<div key={row} className="flex items-center gap-3 rounded-xl p-2.5">
|
||||
<span className="h-10 w-10 animate-pulse-soft rounded-full bg-surface-2" />
|
||||
<span className="flex-1 space-y-2">
|
||||
<span className="block h-3 w-1/3 animate-pulse-soft rounded-full bg-surface-2" />
|
||||
<span className="block h-3 w-3/4 animate-pulse-soft rounded-full bg-surface-2/70" />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!tooShort && showEmpty && (
|
||||
<div className="px-6 py-12 text-center text-sm text-muted">Ничего не найдено</div>
|
||||
)}
|
||||
|
||||
{!tooShort && !loading && results.length > 0 && (
|
||||
<ul className="space-y-0.5">
|
||||
{results.map((hit) => {
|
||||
const title = titleFor(hit)
|
||||
const group = isGroupHit(hit)
|
||||
return (
|
||||
<li key={hit.message.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePick(hit)}
|
||||
className="flex w-full items-start gap-3 rounded-xl px-2.5 py-2.5 text-left transition hover:bg-surface-2/70"
|
||||
>
|
||||
{group ? (
|
||||
<GroupAvatar name={title} seed={hit.conversation.id} size="md" />
|
||||
) : (
|
||||
<Avatar name={title} seed={hit.conversation.id} size="md" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-semibold text-fg">{title}</span>
|
||||
<span className="shrink-0 text-[11px] text-faint">
|
||||
{formatListTime(hit.message.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 text-xs text-muted">{previewFor(hit)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ interface SidebarProps {
|
||||
typing: Record<number, boolean>
|
||||
onSelect: (id: number) => void
|
||||
onNewChat: () => void
|
||||
onSearch: () => void
|
||||
onRetry: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
@@ -47,6 +48,7 @@ export default function Sidebar({
|
||||
typing,
|
||||
onSelect,
|
||||
onNewChat,
|
||||
onSearch,
|
||||
onRetry,
|
||||
onLogout,
|
||||
}: SidebarProps) {
|
||||
@@ -91,14 +93,25 @@ export default function Sidebar({
|
||||
</span>
|
||||
<span className="font-display text-lg font-bold tracking-tight text-fg">Пульс</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
aria-label="Новый чат"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
|
||||
>
|
||||
<IconPlus className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearch}
|
||||
aria-label="Поиск по сообщениям"
|
||||
title="Поиск по сообщениям"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
|
||||
>
|
||||
<IconSearch className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
aria-label="Новый чат"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
|
||||
>
|
||||
<IconPlus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
|
||||
+27
-1
@@ -1,4 +1,14 @@
|
||||
import type { Contact, Conversation, Media, Member, Message, SendMessageResult, User } from './types'
|
||||
import type {
|
||||
Contact,
|
||||
Conversation,
|
||||
Media,
|
||||
Member,
|
||||
Message,
|
||||
PushSubscriptionPayload,
|
||||
SearchHit,
|
||||
SendMessageResult,
|
||||
User,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
@@ -147,6 +157,9 @@ export interface ApiClient {
|
||||
addReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
|
||||
removeReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
|
||||
uploadMedia(file: File): Promise<Media>
|
||||
searchMessages(query: string): Promise<SearchHit[]>
|
||||
subscribePush(subscription: PushSubscriptionPayload): Promise<void>
|
||||
unsubscribePush(endpoint: string): Promise<void>
|
||||
}
|
||||
|
||||
export const api: ApiClient = {
|
||||
@@ -259,4 +272,17 @@ export const api: ApiClient = {
|
||||
form.append('file', file)
|
||||
return requestForm<Media>('/media', form)
|
||||
},
|
||||
|
||||
searchMessages(query) {
|
||||
const params = new URLSearchParams({ q: query, limit: '50' })
|
||||
return request<SearchHit[]>(`/search/messages?${params.toString()}`)
|
||||
},
|
||||
|
||||
subscribePush(subscription) {
|
||||
return request<void>('/push/subscribe', { method: 'POST', body: subscription })
|
||||
},
|
||||
|
||||
unsubscribePush(endpoint) {
|
||||
return request<void>('/push/subscribe', { method: 'DELETE', body: { endpoint } })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { api } from './api'
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
const output = new Uint8Array(rawData.length)
|
||||
for (let index = 0; index < rawData.length; index += 1) {
|
||||
output[index] = rawData.charCodeAt(index)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function encodeKey(key: ArrayBuffer | null): string {
|
||||
if (!key) return ''
|
||||
const bytes = new Uint8Array(key)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
export async function ensurePushSubscription(): Promise<void> {
|
||||
const publicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||
if (!publicKey) return
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') return
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
}
|
||||
|
||||
await api.subscribePush({
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: encodeKey(subscription.getKey('p256dh')),
|
||||
auth: encodeKey(subscription.getKey('auth')),
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,23 @@ export interface SendMessageResult {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
message: Message
|
||||
conversation: {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PushSubscriptionPayload {
|
||||
endpoint: string
|
||||
keys: {
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WsMessageCreated {
|
||||
type: 'message.created'
|
||||
data: {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './auth/AuthContext'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import ChatWindow from '../components/ChatWindow'
|
||||
import NewChatDialog from '../components/NewChatDialog'
|
||||
import SearchDialog from '../components/SearchDialog'
|
||||
import { api } from '../lib/api'
|
||||
import { ensurePushSubscription } from '../lib/push'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { useConversations } from '../hooks/useConversations'
|
||||
import { useMessages } from '../hooks/useMessages'
|
||||
@@ -21,6 +23,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
const [presence, setPresence] = useState<Record<number, boolean>>({})
|
||||
const [typing, setTyping] = useState<Record<number, boolean>>({})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [focusTick, setFocusTick] = useState(0)
|
||||
const [membersVersion, setMembersVersion] = useState(0)
|
||||
|
||||
@@ -78,6 +81,10 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
void loadContacts()
|
||||
}, [loadContacts])
|
||||
|
||||
useEffect(() => {
|
||||
void ensurePushSubscription()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => setFocusTick((tick) => tick + 1)
|
||||
window.addEventListener('focus', onFocus)
|
||||
@@ -348,6 +355,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
typing={typing}
|
||||
onSelect={handleSelect}
|
||||
onNewChat={() => setDialogOpen(true)}
|
||||
onSearch={() => setSearchOpen(true)}
|
||||
onRetry={() => void reload()}
|
||||
onLogout={() => void logout()}
|
||||
/>
|
||||
@@ -386,6 +394,17 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
onContactsChanged={() => void loadContacts()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchOpen && (
|
||||
<SearchDialog
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onSelect={handleSelect}
|
||||
conversations={conversations}
|
||||
peers={peers}
|
||||
directory={directory}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { precacheAndRoute } from 'workbox-precaching'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
interface PushPayload {
|
||||
title?: string
|
||||
body?: string
|
||||
conversation_id?: number
|
||||
message_id?: number
|
||||
}
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return
|
||||
let payload: PushPayload
|
||||
try {
|
||||
payload = event.data.json() as PushPayload
|
||||
} catch {
|
||||
payload = { body: event.data.text() }
|
||||
}
|
||||
const title = payload.title ?? 'Пульс'
|
||||
const body = payload.body ?? 'Новое сообщение'
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { conversation_id: payload.conversation_id, message_id: payload.message_id },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if ('focus' in client) return client.focus()
|
||||
}
|
||||
return self.clients.openWindow('/')
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import './service-worker'
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_VAPID_PUBLIC_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user