diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 8794c49..acad0c4 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -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
removeReaction(conversationId: number, messageId: number, emoji: string): Promise
uploadMedia(file: File): Promise
+ searchMessages(query: string): Promise
+ subscribePush(subscription: PushSubscriptionPayload): Promise
+ unsubscribePush(endpoint: string): Promise
}
export const api: ApiClient = {
@@ -259,4 +272,17 @@ export const api: ApiClient = {
form.append('file', file)
return requestForm('/media', form)
},
+
+ searchMessages(query) {
+ const params = new URLSearchParams({ q: query, limit: '50' })
+ return request(`/search/messages?${params.toString()}`)
+ },
+
+ subscribePush(subscription) {
+ return request('/push/subscribe', { method: 'POST', body: subscription })
+ },
+
+ unsubscribePush(endpoint) {
+ return request('/push/subscribe', { method: 'DELETE', body: { endpoint } })
+ },
}
diff --git a/web/src/lib/push.ts b/web/src/lib/push.ts
new file mode 100644
index 0000000..6c7e95d
--- /dev/null
+++ b/web/src/lib/push.ts
@@ -0,0 +1,50 @@
+import { api } from './api'
+
+function urlBase64ToUint8Array(base64String: string): Uint8Array {
+ 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 {
+ 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
+ }
+}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 5c3236a..e581c35 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -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: {
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 6b8f334..365663f 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -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(
diff --git a/web/src/screens/ChatApp.tsx b/web/src/screens/ChatApp.tsx
index b03260d..0dae751 100644
--- a/web/src/screens/ChatApp.tsx
+++ b/web/src/screens/ChatApp.tsx
@@ -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>({})
const [typing, setTyping] = useState>({})
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 && (
+ setSearchOpen(false)}
+ onSelect={handleSelect}
+ conversations={conversations}
+ peers={peers}
+ directory={directory}
+ currentUser={user}
+ />
+ )}
)
}
diff --git a/web/src/service-worker.ts b/web/src/service-worker.ts
new file mode 100644
index 0000000..18584c1
--- /dev/null
+++ b/web/src/service-worker.ts
@@ -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('/')
+ }),
+ )
+})
diff --git a/web/src/sw.ts b/web/src/sw.ts
new file mode 100644
index 0000000..5cd960c
--- /dev/null
+++ b/web/src/sw.ts
@@ -0,0 +1 @@
+import './service-worker'
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 0000000..989b9fe
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///