added media fixes, search, push updates, pwa, metrics. preparing release 1.0
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 23:26:15 +03:00
parent 855a62755c
commit 6fc6d977bf
59 changed files with 6823 additions and 113 deletions
+1
View File
@@ -0,0 +1 @@
VITE_VAPID_PUBLIC_KEY=
+4867 -1
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -13,7 +13,9 @@
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3"
"tailwindcss": "^4.3.3",
"vite-plugin-pwa": "^1.3.0",
"workbox-precaching": "^7.4.1"
},
"devDependencies": {
"@types/node": "^24.13.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+210
View File
@@ -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>
)
}
+21 -8
View File
@@ -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
View File
@@ -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 } })
},
}
+50
View File
@@ -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
}
}
+17
View File
@@ -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: {
+3
View File
@@ -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>
+19
View File
@@ -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>
)
}
+44
View File
@@ -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('/')
}),
)
})
+1
View File
@@ -0,0 +1 @@
import './service-worker'
+10
View File
@@ -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
}
+2 -1
View File
@@ -22,5 +22,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/service-worker.ts", "src/sw.ts"]
}
+2 -1
View File
@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.sw.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.sw.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "WebWorker"],
"module": "esnext",
"types": [],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/sw.ts", "src/service-worker.ts"]
}
+27 -1
View File
@@ -1,10 +1,36 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'autoUpdate',
manifest: {
name: 'Messenger',
short_name: 'Messenger',
theme_color: '#0f1115',
background_color: '#0f1115',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
devOptions: {
enabled: true,
type: 'module',
},
}),
],
server: {
proxy: {
'/api': {