frontend: messages, should work
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { User } from '../lib/types'
|
||||||
|
|
||||||
|
export interface AuthContextValue {
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
register: (username: string, displayName: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refreshMe: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
api
|
||||||
|
.me()
|
||||||
|
.then((me) => {
|
||||||
|
if (!cancelled) setUser(me)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setUser(null)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
const me = await api.login(username, password)
|
||||||
|
setUser(me)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const register = useCallback(async (username: string, displayName: string, password: string) => {
|
||||||
|
const me = await api.register(username, displayName, password)
|
||||||
|
setUser(me)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await api.logout()
|
||||||
|
} finally {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshMe = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const me = await api.me()
|
||||||
|
setUser(me)
|
||||||
|
} catch {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({ user, loading, login, register, logout, refreshMe }),
|
||||||
|
[user, loading, login, register, logout, refreshMe],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) throw new Error('useAuth должен использоваться внутри AuthProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { Conversation } from '../lib/types'
|
||||||
|
|
||||||
|
function activityTime(conversation: Conversation): number {
|
||||||
|
const value = conversation.last_message?.created_at ?? conversation.created_at
|
||||||
|
const time = Date.parse(value)
|
||||||
|
return Number.isNaN(time) ? 0 : time
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortConversations(list: Conversation[]): Conversation[] {
|
||||||
|
return [...list].sort((a, b) => activityTime(b) - activityTime(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseConversationsResult {
|
||||||
|
conversations: Conversation[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
reload: () => Promise<void>
|
||||||
|
add: (conversation: Conversation) => void
|
||||||
|
patch: (id: number, updater: (conversation: Conversation) => Conversation) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversations(enabled = true): UseConversationsResult {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||||
|
const [loading, setLoading] = useState(enabled)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const list = await api.listConversations()
|
||||||
|
setConversations(sortConversations(list))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось загрузить чаты')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) void reload()
|
||||||
|
}, [enabled, reload])
|
||||||
|
|
||||||
|
const add = useCallback((conversation: Conversation) => {
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.some((item) => item.id === conversation.id) ? prev : sortConversations([conversation, ...prev]),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const patch = useCallback((id: number, updater: (conversation: Conversation) => Conversation) => {
|
||||||
|
setConversations((prev) => sortConversations(prev.map((item) => (item.id === id ? updater(item) : item))))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { conversations, loading, error, reload, add, patch }
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { ClientMessage, Message } from '../lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export interface UseMessagesResult {
|
||||||
|
messages: ClientMessage[]
|
||||||
|
loading: boolean
|
||||||
|
loadingOlder: boolean
|
||||||
|
hasMore: boolean
|
||||||
|
error: string | null
|
||||||
|
loadOlder: () => Promise<void>
|
||||||
|
upsert: (message: Message, tempId?: string) => void
|
||||||
|
addOptimistic: (message: ClientMessage) => void
|
||||||
|
markSending: (tempId: string) => void
|
||||||
|
failOptimistic: (tempId: string) => void
|
||||||
|
markReadUpTo: (messageId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessages(conversationId: number | null, currentUserId: number): UseMessagesResult {
|
||||||
|
const [messages, setMessages] = useState<ClientMessage[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const messagesRef = useRef(messages)
|
||||||
|
const hasMoreRef = useRef(hasMore)
|
||||||
|
const loadingOlderRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesRef.current = messages
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasMoreRef.current = hasMore
|
||||||
|
}, [hasMore])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversationId === null) {
|
||||||
|
setMessages([])
|
||||||
|
setHasMore(false)
|
||||||
|
setError(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setMessages([])
|
||||||
|
|
||||||
|
api
|
||||||
|
.listMessages(conversationId, undefined, PAGE_SIZE)
|
||||||
|
.then((list) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setMessages([...list].reverse())
|
||||||
|
setHasMore(list.length >= PAGE_SIZE)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : 'Не удалось загрузить сообщения')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [conversationId])
|
||||||
|
|
||||||
|
const loadOlder = useCallback(async () => {
|
||||||
|
if (conversationId === null || loadingOlderRef.current || !hasMoreRef.current) return
|
||||||
|
const oldest = messagesRef.current.find((message) => message.id > 0)
|
||||||
|
if (!oldest) return
|
||||||
|
|
||||||
|
loadingOlderRef.current = true
|
||||||
|
setLoadingOlder(true)
|
||||||
|
try {
|
||||||
|
const list = await api.listMessages(conversationId, oldest.id, PAGE_SIZE)
|
||||||
|
if (list.length < PAGE_SIZE) setHasMore(false)
|
||||||
|
const older = [...list].reverse()
|
||||||
|
setMessages((prev) => {
|
||||||
|
const known = new Set(prev.map((message) => message.id))
|
||||||
|
return [...older.filter((message) => !known.has(message.id)), ...prev]
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
loadingOlderRef.current = false
|
||||||
|
} finally {
|
||||||
|
loadingOlderRef.current = false
|
||||||
|
setLoadingOlder(false)
|
||||||
|
}
|
||||||
|
}, [conversationId])
|
||||||
|
|
||||||
|
const upsert = useCallback((message: Message, tempId?: string) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (message.id > 0) {
|
||||||
|
const byId = prev.findIndex((item) => item.id === message.id)
|
||||||
|
if (byId !== -1) {
|
||||||
|
return prev.map((item, index) => (index === byId ? { ...message } : item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tempId) {
|
||||||
|
const byTemp = prev.findIndex((item) => item.temp_id === tempId)
|
||||||
|
if (byTemp !== -1) {
|
||||||
|
return prev.map((item, index) => (index === byTemp ? { ...message } : item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...prev, { ...message }]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addOptimistic = useCallback((message: ClientMessage) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (message.temp_id && prev.some((item) => item.temp_id === message.temp_id)) return prev
|
||||||
|
return [...prev, message]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const markSending = useCallback((tempId: string) => {
|
||||||
|
setMessages((prev) => prev.map((item) => (item.temp_id === tempId ? { ...item, status: 'sending' } : item)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const failOptimistic = useCallback((tempId: string) => {
|
||||||
|
setMessages((prev) => prev.map((item) => (item.temp_id === tempId ? { ...item, status: 'error' } : item)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const markReadUpTo = useCallback(
|
||||||
|
(messageId: number) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.sender_id === currentUserId && item.id > 0 && item.id <= messageId && !item.read_by_peer
|
||||||
|
? { ...item, read_by_peer: true }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[currentUserId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
loading,
|
||||||
|
loadingOlder,
|
||||||
|
hasMore,
|
||||||
|
error,
|
||||||
|
loadOlder,
|
||||||
|
upsert,
|
||||||
|
addOptimistic,
|
||||||
|
markSending,
|
||||||
|
failOptimistic,
|
||||||
|
markReadUpTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { RealtimeClient } from '../lib/ws'
|
||||||
|
import type { WsEvent } from '../lib/types'
|
||||||
|
|
||||||
|
export function useRealtime(handler: (event: WsEvent) => void): RealtimeClient {
|
||||||
|
const [client] = useState(() => new RealtimeClient())
|
||||||
|
const handlerRef = useRef(handler)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handlerRef.current = handler
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = client.onEvent((event) => handlerRef.current(event))
|
||||||
|
client.connect()
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { Contact, Conversation, Message, SendMessageResult, User } from './types'
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1'
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends ApiError {
|
||||||
|
constructor(message = 'Требуется вход') {
|
||||||
|
super(message, 401)
|
||||||
|
this.name = 'UnauthorizedError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: string
|
||||||
|
body?: unknown
|
||||||
|
authRetry?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshInFlight: Promise<void> | null = null
|
||||||
|
|
||||||
|
async function refreshSession(): Promise<void> {
|
||||||
|
const pending = refreshInFlight
|
||||||
|
if (pending) return pending
|
||||||
|
const promise = fetch(`${API_BASE}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new UnauthorizedError()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshInFlight = null
|
||||||
|
})
|
||||||
|
refreshInFlight = promise
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(res: Response): Promise<string> {
|
||||||
|
const fallback = `Ошибка запроса (${res.status})`
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
if (!text) return fallback
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text)
|
||||||
|
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
|
||||||
|
const value = (parsed as { error: unknown }).error
|
||||||
|
if (typeof value === 'string' && value.trim()) return value
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(res: Response): Promise<T> {
|
||||||
|
const text = await res.text()
|
||||||
|
if (!text) return undefined as T
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
} catch {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const { method = 'GET', body, authRetry = true } = options
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401 && authRetry) {
|
||||||
|
try {
|
||||||
|
await refreshSession()
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedError()
|
||||||
|
}
|
||||||
|
return request<T>(path, { ...options, authRetry: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(await readError(res), res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return readJson<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiClient {
|
||||||
|
register(username: string, displayName: string, password: string): Promise<User>
|
||||||
|
login(username: string, password: string): Promise<User>
|
||||||
|
logout(): Promise<void>
|
||||||
|
refresh(): Promise<void>
|
||||||
|
me(): Promise<User>
|
||||||
|
searchUsers(query: string): Promise<User[]>
|
||||||
|
listContacts(): Promise<Contact[]>
|
||||||
|
addContact(userId: number): Promise<void>
|
||||||
|
removeContact(userId: number): Promise<void>
|
||||||
|
listConversations(): Promise<Conversation[]>
|
||||||
|
createDM(userId: number): Promise<Conversation>
|
||||||
|
listMessages(conversationId: number, before?: number, limit?: number): Promise<Message[]>
|
||||||
|
sendMessage(conversationId: number, body: string, tempId: string): Promise<SendMessageResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api: ApiClient = {
|
||||||
|
register(username, displayName, password) {
|
||||||
|
return request<User>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username, display_name: displayName, password },
|
||||||
|
authRetry: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
login(username, password) {
|
||||||
|
return request<User>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username, password },
|
||||||
|
authRetry: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
return request<void>('/auth/logout', { method: 'POST', authRetry: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
return request<void>('/auth/refresh', { method: 'POST', authRetry: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
me() {
|
||||||
|
return request<User>('/users/me')
|
||||||
|
},
|
||||||
|
|
||||||
|
searchUsers(query) {
|
||||||
|
return request<User[]>(`/users?q=${encodeURIComponent(query)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
listContacts() {
|
||||||
|
return request<Contact[]>('/contacts')
|
||||||
|
},
|
||||||
|
|
||||||
|
addContact(userId) {
|
||||||
|
return request<void>('/contacts', { method: 'POST', body: { user_id: userId } })
|
||||||
|
},
|
||||||
|
|
||||||
|
removeContact(userId) {
|
||||||
|
return request<void>(`/contacts/${userId}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
|
||||||
|
listConversations() {
|
||||||
|
return request<Conversation[]>('/conversations')
|
||||||
|
},
|
||||||
|
|
||||||
|
createDM(userId) {
|
||||||
|
return request<Conversation>('/conversations', { method: 'POST', body: { user_id: userId } })
|
||||||
|
},
|
||||||
|
|
||||||
|
listMessages(conversationId, before, limit = 50) {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
|
if (before !== undefined) params.set('before', String(before))
|
||||||
|
return request<Message[]>(`/conversations/${conversationId}/messages?${params.toString()}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage(conversationId, body, tempId) {
|
||||||
|
const message = await request<Message>(`/conversations/${conversationId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { body, temp_id: tempId },
|
||||||
|
})
|
||||||
|
return { temp_id: tempId, message }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Conversation, User } from './types'
|
||||||
|
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#5b8cff',
|
||||||
|
'#34d399',
|
||||||
|
'#f59e0b',
|
||||||
|
'#f472b6',
|
||||||
|
'#a78bfa',
|
||||||
|
'#22d3ee',
|
||||||
|
'#fb7185',
|
||||||
|
'#4ade80',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function avatarColor(seed: number | string): string {
|
||||||
|
const numeric = typeof seed === 'number' ? seed : Array.from(seed).reduce((sum, ch) => sum + ch.charCodeAt(0), 0)
|
||||||
|
return AVATAR_COLORS[Math.abs(numeric) % AVATAR_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initials(user: Pick<User, 'display_name' | 'username'>): string {
|
||||||
|
const name = (user.display_name || user.username || '?').trim()
|
||||||
|
const parts = name.split(/\s+/).filter(Boolean)
|
||||||
|
const first = parts[0] ?? ''
|
||||||
|
if (parts.length <= 1) return first.slice(0, 2).toUpperCase()
|
||||||
|
const last = parts[parts.length - 1] ?? ''
|
||||||
|
return (first.charAt(0) + last.charAt(0)).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userTitle(user: Pick<User, 'display_name' | 'username'>): string {
|
||||||
|
return user.display_name || user.username
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(value: string): Date | null {
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(value: string): string {
|
||||||
|
const date = toDate(value)
|
||||||
|
if (!date) return ''
|
||||||
|
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatListTime(value: string): string {
|
||||||
|
const date = toDate(value)
|
||||||
|
if (!date) return ''
|
||||||
|
const now = new Date()
|
||||||
|
if (date.toDateString() === now.toDateString()) return formatTime(value)
|
||||||
|
const yesterday = new Date(now)
|
||||||
|
yesterday.setDate(now.getDate() - 1)
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) return 'вчера'
|
||||||
|
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDayDivider(value: string): string {
|
||||||
|
const date = toDate(value)
|
||||||
|
if (!date) return ''
|
||||||
|
const now = new Date()
|
||||||
|
if (date.toDateString() === now.toDateString()) return 'Сегодня'
|
||||||
|
const yesterday = new Date(now)
|
||||||
|
yesterday.setDate(now.getDate() - 1)
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) return 'Вчера'
|
||||||
|
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameDay(a: string, b: string): boolean {
|
||||||
|
const first = toDate(a)
|
||||||
|
const second = toDate(b)
|
||||||
|
if (!first || !second) return false
|
||||||
|
return first.toDateString() === second.toDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesBetween(a: string, b: string): number {
|
||||||
|
const first = toDate(a)
|
||||||
|
const second = toDate(b)
|
||||||
|
if (!first || !second) return 0
|
||||||
|
return Math.abs(first.getTime() - second.getTime()) / 60000
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peerForConversation(
|
||||||
|
conversation: Conversation,
|
||||||
|
peers: Record<number, number>,
|
||||||
|
directory: Map<number, User>,
|
||||||
|
currentUserId: number,
|
||||||
|
): User | null {
|
||||||
|
let peerId = peers[conversation.id]
|
||||||
|
if (peerId === undefined && conversation.last_message && conversation.last_message.sender_id !== currentUserId) {
|
||||||
|
peerId = conversation.last_message.sender_id
|
||||||
|
}
|
||||||
|
if (peerId === undefined) return null
|
||||||
|
return directory.get(peerId) ?? null
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
avatar: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
user: User
|
||||||
|
added_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: number
|
||||||
|
conversation_id: number
|
||||||
|
sender_id: number
|
||||||
|
body: string
|
||||||
|
created_at: string
|
||||||
|
edited_at?: string | null
|
||||||
|
deleted_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageStatus = 'sending' | 'error'
|
||||||
|
|
||||||
|
export interface ClientMessage extends Message {
|
||||||
|
temp_id?: string
|
||||||
|
status?: MessageStatus
|
||||||
|
read_by_peer?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
created_at: string
|
||||||
|
last_message?: Message | null
|
||||||
|
unread_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageResult {
|
||||||
|
temp_id: string
|
||||||
|
message: Message
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsMessageCreated {
|
||||||
|
type: 'message.created'
|
||||||
|
data: {
|
||||||
|
temp_id?: string
|
||||||
|
conversation_id: number
|
||||||
|
message_id: number
|
||||||
|
sender_id: number
|
||||||
|
body: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsTyping {
|
||||||
|
type: 'typing'
|
||||||
|
data: {
|
||||||
|
conversation_id: number
|
||||||
|
user_id: number
|
||||||
|
is_typing: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsPresence {
|
||||||
|
type: 'presence'
|
||||||
|
data: {
|
||||||
|
user_id: number
|
||||||
|
status: 'online' | 'offline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsRead {
|
||||||
|
type: 'read'
|
||||||
|
data: {
|
||||||
|
conversation_id: number
|
||||||
|
user_id: number
|
||||||
|
message_id: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WsEvent = WsMessageCreated | WsTyping | WsPresence | WsRead
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { WsEvent } from './types'
|
||||||
|
|
||||||
|
export type WsEventType = WsEvent['type']
|
||||||
|
|
||||||
|
type Handler<T extends WsEventType> = (event: Extract<WsEvent, { type: T }>) => void
|
||||||
|
|
||||||
|
interface OutgoingTyping {
|
||||||
|
type: 'typing'
|
||||||
|
data: { conversation_id: number; is_typing: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutgoingRead {
|
||||||
|
type: 'read'
|
||||||
|
data: { conversation_id: number; message_id: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_TYPES: readonly WsEventType[] = ['message.created', 'typing', 'presence', 'read']
|
||||||
|
|
||||||
|
function isWsEvent(value: unknown): value is WsEvent {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
const type = (value as { type?: unknown }).type
|
||||||
|
return typeof type === 'string' && (EVENT_TYPES as readonly string[]).includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsUrl(): string {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return `${protocol}//${window.location.host}/ws`
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RealtimeClient {
|
||||||
|
private readonly url: string
|
||||||
|
private readonly handlers = new Map<WsEventType, Set<(event: WsEvent) => void>>()
|
||||||
|
private readonly listeners = new Set<(event: WsEvent) => void>()
|
||||||
|
private socket: WebSocket | null = null
|
||||||
|
private reconnectTimer: number | null = null
|
||||||
|
private reconnectAttempts = 0
|
||||||
|
private shouldReconnect = false
|
||||||
|
|
||||||
|
constructor(url?: string) {
|
||||||
|
this.url = url ?? buildWsUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (typeof WebSocket === 'undefined') return
|
||||||
|
if (
|
||||||
|
this.socket &&
|
||||||
|
(this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shouldReconnect = true
|
||||||
|
const socket = new WebSocket(this.url)
|
||||||
|
this.socket = socket
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event: MessageEvent) => {
|
||||||
|
if (typeof event.data === 'string') this.dispatch(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (this.socket === socket) this.socket = null
|
||||||
|
if (this.shouldReconnect) this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on<T extends WsEventType>(type: T, handler: Handler<T>): () => void {
|
||||||
|
let group = this.handlers.get(type)
|
||||||
|
if (!group) {
|
||||||
|
group = new Set()
|
||||||
|
this.handlers.set(type, group)
|
||||||
|
}
|
||||||
|
const fn = handler as (event: WsEvent) => void
|
||||||
|
group.add(fn)
|
||||||
|
return () => {
|
||||||
|
group?.delete(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(listener: (event: WsEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTyping(conversationId: number, isTyping: boolean): void {
|
||||||
|
const payload: OutgoingTyping = {
|
||||||
|
type: 'typing',
|
||||||
|
data: { conversation_id: conversationId, is_typing: isTyping },
|
||||||
|
}
|
||||||
|
this.send(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRead(conversationId: number, messageId: number): void {
|
||||||
|
const payload: OutgoingRead = {
|
||||||
|
type: 'read',
|
||||||
|
data: { conversation_id: conversationId, message_id: messageId },
|
||||||
|
}
|
||||||
|
this.send(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.shouldReconnect = false
|
||||||
|
if (this.reconnectTimer !== null) {
|
||||||
|
window.clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
const socket = this.socket
|
||||||
|
this.socket = null
|
||||||
|
if (socket) {
|
||||||
|
socket.onopen = null
|
||||||
|
socket.onmessage = null
|
||||||
|
socket.onerror = null
|
||||||
|
socket.onclose = null
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(payload: OutgoingTyping | OutgoingRead): void {
|
||||||
|
const socket = this.socket
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatch(raw: string): void {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isWsEvent(parsed)) return
|
||||||
|
this.listeners.forEach((listener) => listener(parsed))
|
||||||
|
this.handlers.get(parsed.type)?.forEach((handler) => handler(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimer !== null) return
|
||||||
|
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000)
|
||||||
|
this.reconnectAttempts += 1
|
||||||
|
this.reconnectTimer = window.setTimeout(() => {
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user