3
.env
3
.env
@@ -1 +1,2 @@
|
|||||||
VITE_YANDEX_GEOCODER_KEY=c2d3c717-a109-4494-8863-7edbc32044e3
|
REACT_APP_YANDEX_GEOCODER_KEY=c2d3c717-a109-4494-8863-7edbc32044e3
|
||||||
|
REACT_APP_YANDEX_MAPS_KEY=d4696cc8-6a1f-4356-9219-108ac474093b
|
||||||
@@ -10,8 +10,7 @@
|
|||||||
content="Web site created using create-react-app"
|
content="Web site created using create-react-app"
|
||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
<script src="https://api-maps.yandex.ru/2.1/?apikey=d4696cc8-6a1f-4356-9219-108ac474093b&lang=ru_RU" type="text/javascript"></script>
|
<script defer src="https://api-maps.yandex.ru/2.1/?apikey=%REACT_APP_YANDEX_MAPS_KEY%&lang=ru_RU" type="text/javascript"></script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
|||||||
@@ -124,13 +124,12 @@ function ContactFields({ form, errors, onChange, onPhoneChange }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Submit button ──────────────────────────────────────────────────────────
|
// ─── Submit button ──────────────────────────────────────────────────────────
|
||||||
function SubmitButton({ loading, onSubmit }) {
|
function SubmitButton({ loading }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="f-m-submit"
|
className="f-m-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<div className="f-m-submit-row">
|
<div className="f-m-submit-row">
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ export default function ProfileModal({ onClose, initialData = {} }) {
|
|||||||
avatar: newAvatars.length > 0 ? newAvatars[0] : (keptExisting.length > 0 ? keptExisting[0] : null),
|
avatar: newAvatars.length > 0 ? newAvatars[0] : (keptExisting.length > 0 ? keptExisting[0] : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedUser = await updateProfile(user?.id, profileData);
|
const result = await updateProfile(user?.id, profileData);
|
||||||
|
if (result.success) {
|
||||||
if (updatedUser) {
|
updateUser(result.data);
|
||||||
updateUser(updatedUser);
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
// при неудаче result.error уже отражена в saveError через error state хука
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -116,9 +116,9 @@ export default function ProfileModal({ onClose, initialData = {} }) {
|
|||||||
onPhoneChange={handlePhoneChange}
|
onPhoneChange={handlePhoneChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{saveError && <p className="f-err-msg" style={{ marginBottom: 10 }}>{saveError}</p>}
|
{saveError && <p className="f-err-msg auth-form-error">{saveError}</p>}
|
||||||
|
|
||||||
<SubmitButton loading={compressing || saving} onSubmit={handleSubmit} />
|
<SubmitButton loading={compressing || saving} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ export function getImageUrl(filename) {
|
|||||||
return `${API_URL}/api/v1/files/${filename}`;
|
return `${API_URL}/api/v1/files/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SUPPORT_EMAIL = import.meta.env.VITE_SUPPORT_EMAIL || "support@nakhodka.app";
|
export const SUPPORT_EMAIL = process.env.REACT_APP_SUPPORT_EMAIL || "support@nakhodka.app";
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
// src/context/AuthContext.jsx
|
||||||
import { createContext, useContext, useState, useCallback } from "react";
|
import { createContext, useContext, useState, useCallback } from "react";
|
||||||
import { saveTokens, clearTokens, getAccessToken } from "../api/apiClient";
|
import { saveTokens, clearTokens, getAccessToken } from "../api/apiClient";
|
||||||
|
import { clearPostCache } from "../stores/chatPostStore";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
@@ -32,9 +34,15 @@ export function AuthProvider({ children }) {
|
|||||||
setToken(tokenStr);
|
setToken(tokenStr);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Очищает все данные аутентификации (access, refresh, user). */
|
/**
|
||||||
|
* Очищает все данные аутентификации (access, refresh, user) и модульные
|
||||||
|
* кеши, привязанные к сессии пользователя (например, chatPostStore), —
|
||||||
|
* иначе данные предыдущего пользователя могут "пережить" logout на этом
|
||||||
|
* же устройстве.
|
||||||
|
*/
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
|
clearPostCache();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1,60 +1,22 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
// src/hooks/useChatSocket.js
|
||||||
import { Client } from "@stomp/stompjs";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import SockJS from "sockjs-client";
|
import { useStompClient, parseStompMessage } from "./useStompClient";
|
||||||
import { API_URL } from "../config/authConstants";
|
|
||||||
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
|
|
||||||
|
|
||||||
export function useChatSocket(chatId, onMessage) {
|
export function useChatSocket(chatId, onMessage) {
|
||||||
const clientRef = useRef(null);
|
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
|
|
||||||
// Держим актуальный колбэк в ref, чтобы не пересоздавать соединение
|
|
||||||
// при каждом ре-рендере родителя из-за нового onMessage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onMessageRef.current = onMessage;
|
onMessageRef.current = onMessage;
|
||||||
}, [onMessage]);
|
}, [onMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleConnect = useCallback((client) => {
|
||||||
if (!chatId) return;
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
|
|
||||||
reconnectDelay: 3000,
|
|
||||||
beforeConnect: async () => {
|
|
||||||
client.connectHeaders = {
|
|
||||||
Authorization: `Bearer ${getAccessToken()}`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onConnect: () => {
|
|
||||||
setConnected(true);
|
|
||||||
client.subscribe(`/topic/chat/${chatId}`, (msg) => {
|
client.subscribe(`/topic/chat/${chatId}`, (msg) => {
|
||||||
try {
|
parseStompMessage(msg, (body) => onMessageRef.current(body));
|
||||||
const body = JSON.parse(msg.body);
|
|
||||||
onMessageRef.current(body);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse chat message:", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
onDisconnect: () => setConnected(false),
|
|
||||||
onStompError: async (frame) => {
|
|
||||||
if (frame.headers?.message?.includes("Unauthorized")) {
|
|
||||||
try {
|
|
||||||
await refreshAccessToken();
|
|
||||||
} catch {
|
|
||||||
// refresh тоже не удался — дальше решает общий логин-флоу (redirect на /)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
client.activate();
|
|
||||||
clientRef.current = client;
|
|
||||||
|
|
||||||
return () => client.deactivate();
|
|
||||||
}, [chatId]);
|
}, [chatId]);
|
||||||
|
|
||||||
|
const { clientRef, connected } = useStompClient(!!chatId, handleConnect);
|
||||||
|
|
||||||
/** @returns {boolean} true если сообщение реально ушло в сокет, false если соединения нет */
|
/** @returns {boolean} true если сообщение реально ушло в сокет, false если соединения нет */
|
||||||
const sendMessage = useCallback((content) => {
|
const sendMessage = useCallback((content) => {
|
||||||
if (!clientRef.current?.connected) return false;
|
if (!clientRef.current?.connected) return false;
|
||||||
@@ -63,7 +25,7 @@ export function useChatSocket(chatId, onMessage) {
|
|||||||
body: JSON.stringify({ chatId, content }),
|
body: JSON.stringify({ chatId, content }),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}, [chatId]);
|
}, [chatId, clientRef]);
|
||||||
|
|
||||||
return { connected, sendMessage };
|
return { connected, sendMessage };
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
|
// src/hooks/useDeleteChat.js
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
import { apiFetch } from "../api/apiClient";
|
import { apiFetch } from "../api/apiClient";
|
||||||
import { useUnread } from "../context/UnreadContext";
|
import { useUnread } from "../context/UnreadContext";
|
||||||
|
import { removePostForChat } from "../stores/chatPostStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useDeleteChat — хук для удаления чата.
|
||||||
|
*
|
||||||
|
* deleteChat возвращает { success, error } вместо голого boolean — так
|
||||||
|
* вызывающий код получает актуальный текст ошибки сразу, без риска прочитать
|
||||||
|
* устаревшее значение error state до его обновления React'ом (тот же паттерн,
|
||||||
|
* что в useDeletePost/useFavorites/usePostStatus).
|
||||||
|
*
|
||||||
|
* @returns {Object} { deleteChat, loading, error }
|
||||||
|
*/
|
||||||
export function useDeleteChat() {
|
export function useDeleteChat() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -25,10 +37,12 @@ export function useDeleteChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearChatUnread(chatId);
|
clearChatUnread(chatId);
|
||||||
return true;
|
removePostForChat(chatId);
|
||||||
|
return { success: true, error: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || t('chat.deleteError'));
|
const message = err.message || t('chat.deleteError');
|
||||||
return false;
|
setError(message);
|
||||||
|
return { success: false, error: message };
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/hooks/useStompClient.js
Normal file
74
src/hooks/useStompClient.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// src/hooks/useStompClient.js
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Client } from "@stomp/stompjs";
|
||||||
|
import SockJS from "sockjs-client";
|
||||||
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useStompClient — общая инфраструктура STOMP-подключения поверх SockJS.
|
||||||
|
* Инкапсулирует авторизацию (Bearer-токен + авто-refresh при "Unauthorized")
|
||||||
|
* и переподключение. Конкретные топики и обработку сообщений задаёт вызывающий
|
||||||
|
* хук через onConnect.
|
||||||
|
*
|
||||||
|
* @param {boolean} enabled — активировать соединение (например, есть ли userId/chatId)
|
||||||
|
* @param {(client: Client) => void} onConnect — вызывается при успешном коннекте,
|
||||||
|
* внутри обычно делают client.subscribe(...)
|
||||||
|
* @returns {{ clientRef: React.MutableRefObject, connected: boolean }}
|
||||||
|
*/
|
||||||
|
export function useStompClient(enabled, onConnect) {
|
||||||
|
const clientRef = useRef(null);
|
||||||
|
const onConnectRef = useRef(onConnect);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
// Держим актуальный колбэк в ref, чтобы не пересоздавать соединение
|
||||||
|
// при каждом ре-рендере родителя из-за нового onConnect
|
||||||
|
useEffect(() => {
|
||||||
|
onConnectRef.current = onConnect;
|
||||||
|
}, [onConnect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
|
||||||
|
reconnectDelay: 3000,
|
||||||
|
beforeConnect: () => {
|
||||||
|
client.connectHeaders = {
|
||||||
|
Authorization: `Bearer ${getAccessToken()}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onConnect: () => {
|
||||||
|
setConnected(true);
|
||||||
|
onConnectRef.current?.(client);
|
||||||
|
},
|
||||||
|
onDisconnect: () => setConnected(false),
|
||||||
|
onStompError: async (frame) => {
|
||||||
|
if (frame.headers?.message?.includes("Unauthorized")) {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken();
|
||||||
|
} catch {
|
||||||
|
// refresh не удался — общий логин-флоу разберётся при следующем apiFetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
client.activate();
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
return () => client.deactivate();
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { clientRef, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Безопасный парсинг тела STOMP-сообщения — не роняет подписчика на невалидном JSON. */
|
||||||
|
export function parseStompMessage(msg, onParsed) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(msg.body);
|
||||||
|
onParsed(body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse STOMP message:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +1,19 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback } from "react";
|
||||||
import { Client } from "@stomp/stompjs";
|
import { useStompClient, parseStompMessage } from "./useStompClient";
|
||||||
import SockJS from "sockjs-client";
|
|
||||||
import { API_URL } from "../config/authConstants";
|
|
||||||
import { useUnread } from "../context/UnreadContext";
|
import { useUnread } from "../context/UnreadContext";
|
||||||
import { getActiveChatId } from "../stores/activeChatStore";
|
import { getActiveChatId } from "../stores/activeChatStore";
|
||||||
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
|
|
||||||
|
|
||||||
export function useUnreadSocket(userId) {
|
export function useUnreadSocket(userId) {
|
||||||
const { setChatUnread } = useUnread();
|
const { setChatUnread } = useUnread();
|
||||||
const clientRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleConnect = useCallback((client) => {
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
|
|
||||||
reconnectDelay: 3000,
|
|
||||||
beforeConnect: () => {
|
|
||||||
client.connectHeaders = {
|
|
||||||
Authorization: `Bearer ${getAccessToken()}`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onConnect: () => {
|
|
||||||
client.subscribe(`/topic/user/${userId}/unread`, (msg) => {
|
client.subscribe(`/topic/user/${userId}/unread`, (msg) => {
|
||||||
const body = JSON.parse(msg.body);
|
parseStompMessage(msg, (body) => {
|
||||||
if (body.chatId === getActiveChatId()) return;
|
if (String(body.chatId) === getActiveChatId()) return;
|
||||||
setChatUnread(body.chatId, body.unreadCount);
|
setChatUnread(body.chatId, body.unreadCount);
|
||||||
});
|
});
|
||||||
},
|
|
||||||
onStompError: async (frame) => {
|
|
||||||
if (frame.headers?.message?.includes("Unauthorized")) {
|
|
||||||
try {
|
|
||||||
await refreshAccessToken();
|
|
||||||
} catch {
|
|
||||||
// refresh не удался — общий логин-флоу разберётся при следующем apiFetch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.activate();
|
|
||||||
clientRef.current = client;
|
|
||||||
|
|
||||||
return () => client.deactivate();
|
|
||||||
}, [userId, setChatUnread]);
|
}, [userId, setChatUnread]);
|
||||||
|
|
||||||
|
useStompClient(!!userId, handleConnect);
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
import {useState} from "react";
|
import { useState, useCallback } from "react";
|
||||||
import {API_URL} from "../config/authConstants";
|
import { useTranslation } from 'react-i18next';
|
||||||
import {apiFetch} from "../api/apiClient";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { apiFetch } from "../api/apiClient";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useUpdateProfile — хук для обновления профиля пользователя.
|
* useUpdateProfile — хук для обновления профиля пользователя.
|
||||||
* Отправляет PUT /api/v1/users/{id} с FormData (поддерживает загрузку аватара).
|
* Отправляет PUT /api/v1/users/{id} с FormData (поддерживает загрузку аватара).
|
||||||
|
*
|
||||||
|
* updateProfile возвращает { success, data, error } — data содержит
|
||||||
|
* обновлённого пользователя при успехе, error — текст ошибки при неудаче.
|
||||||
*/
|
*/
|
||||||
export function useUpdateProfile() {
|
export function useUpdateProfile() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const updateProfile = async (userId, profileData) => {
|
const updateProfile = useCallback(async (userId, profileData) => {
|
||||||
|
if (!userId) {
|
||||||
|
const message = t('profile.updateMissingUserId');
|
||||||
|
setError(message);
|
||||||
|
return { success: false, data: null, error: message };
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
@@ -36,17 +47,19 @@ export function useUpdateProfile() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.message || `Ошибка ${res.status}`);
|
throw new Error(data.message || t('profile.updateErrorWithStatus', { status: res.status }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return await res.json();
|
const updatedUser = await res.json();
|
||||||
|
return { success: true, data: updatedUser, error: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || "Failed to save profile");
|
const message = err.message || t('profile.updateError');
|
||||||
return null;
|
setError(message);
|
||||||
|
return { success: false, data: null, error: message };
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [t]);
|
||||||
|
|
||||||
return { updateProfile, loading, error };
|
return { updateProfile, loading, error };
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
const YANDEX_GEOCODER_API_KEY = import.meta.env.VITE_YANDEX_GEOCODER_KEY;
|
const YANDEX_GEOCODER_API_KEY = process.env.REACT_APP_YANDEX_GEOCODER_KEY;
|
||||||
|
|
||||||
export function useYandexMap(enabled, initialCoords = null, initialAddress = "") {
|
export function useYandexMap(enabled, initialCoords = null, initialAddress = "") {
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
@@ -21,8 +21,6 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
|||||||
)
|
)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Игнорируем ответ, если пришёл более новый запрос — иначе
|
|
||||||
// устаревший ответ может перезаписать адрес актуальных координат.
|
|
||||||
if (requestId !== requestIdRef.current) return;
|
if (requestId !== requestIdRef.current) return;
|
||||||
|
|
||||||
const found =
|
const found =
|
||||||
@@ -104,7 +102,12 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
|||||||
placemarkRef.current = null;
|
placemarkRef.current = null;
|
||||||
initializedRef.current = false;
|
initializedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [enabled, initialCoords, resolveAddress]);
|
// initialCoords намеренно не в deps — нужен только при первой инициализации,
|
||||||
|
// а initializedRef защищает от повторного запуска. Объект initialCoords
|
||||||
|
// пересоздаётся на каждый рендер родителя, поэтому включение его в deps
|
||||||
|
// приводило бы к пересозданию карты на каждый ввод в форме.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enabled, resolveAddress]);
|
||||||
|
|
||||||
return { mapRef, coords, address, addressRef };
|
return { mapRef, coords, address, addressRef };
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,10 @@
|
|||||||
"validateNameRequired": "Enter your first name",
|
"validateNameRequired": "Enter your first name",
|
||||||
"validateSurnameRequired": "Enter your last name",
|
"validateSurnameRequired": "Enter your last name",
|
||||||
"validateEmailInvalid": "Invalid email",
|
"validateEmailInvalid": "Invalid email",
|
||||||
"validatePhoneInvalid": "Invalid phone number"
|
"validatePhoneInvalid": "Invalid phone number",
|
||||||
|
"updateError": "Failed to save the profile",
|
||||||
|
"updateErrorWithStatus": "Failed to save profile (error {{status}})",
|
||||||
|
"updateMissingUserId": "Could not identify the user"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"titleRequired": "Enter a title",
|
"titleRequired": "Enter a title",
|
||||||
|
|||||||
@@ -235,7 +235,10 @@
|
|||||||
"validateNameRequired": "Введите имя",
|
"validateNameRequired": "Введите имя",
|
||||||
"validateSurnameRequired": "Введите фамилию",
|
"validateSurnameRequired": "Введите фамилию",
|
||||||
"validateEmailInvalid": "Некорректный email",
|
"validateEmailInvalid": "Некорректный email",
|
||||||
"validatePhoneInvalid": "Некорректный номер телефона"
|
"validatePhoneInvalid": "Некорректный номер телефона",
|
||||||
|
"updateError": "Не удалось сохранить профиль",
|
||||||
|
"updateErrorWithStatus": "Не удалось сохранить профиль (ошибка {{status}})",
|
||||||
|
"updateMissingUserId": "Не удалось определить пользователя"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"titleRequired": "Введите название",
|
"titleRequired": "Введите название",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useChats } from "../hooks/useChats";
|
import { useChats } from "../hooks/useChats";
|
||||||
@@ -8,6 +8,7 @@ import { getPostForChat } from "../stores/chatPostStore";
|
|||||||
import { useDeleteChat } from "../hooks/useDeleteChat";
|
import { useDeleteChat } from "../hooks/useDeleteChat";
|
||||||
import ChatHeader from "../components/chat/ChatHeader";
|
import ChatHeader from "../components/chat/ChatHeader";
|
||||||
import ChatListItem from "../components/chat/ChatListItem";
|
import ChatListItem from "../components/chat/ChatListItem";
|
||||||
|
import Toast from "../components/Toast";
|
||||||
|
|
||||||
export default function ChatListPage() {
|
export default function ChatListPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -16,6 +17,7 @@ export default function ChatListPage() {
|
|||||||
const { isAuthenticated } = useAuthContext();
|
const { isAuthenticated } = useAuthContext();
|
||||||
const { unreadMap } = useUnread();
|
const { unreadMap } = useUnread();
|
||||||
const { deleteChat } = useDeleteChat();
|
const { deleteChat } = useDeleteChat();
|
||||||
|
const [deleteError, setDeleteError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
@@ -25,8 +27,12 @@ export default function ChatListPage() {
|
|||||||
|
|
||||||
const handleDeleteChat = useCallback(async (chatId) => {
|
const handleDeleteChat = useCallback(async (chatId) => {
|
||||||
if (!window.confirm(t('chat.confirmDeleteChat'))) return;
|
if (!window.confirm(t('chat.confirmDeleteChat'))) return;
|
||||||
const ok = await deleteChat(chatId);
|
const result = await deleteChat(chatId);
|
||||||
if (ok) fetchChats();
|
if (result.success) {
|
||||||
|
fetchChats();
|
||||||
|
} else {
|
||||||
|
setDeleteError(result.error);
|
||||||
|
}
|
||||||
}, [t, deleteChat, fetchChats]);
|
}, [t, deleteChat, fetchChats]);
|
||||||
|
|
||||||
const goBack = () => navigate("/feed");
|
const goBack = () => navigate("/feed");
|
||||||
@@ -63,12 +69,21 @@ export default function ChatListPage() {
|
|||||||
chat={chat}
|
chat={chat}
|
||||||
post={getPostForChat(chat.id)}
|
post={getPostForChat(chat.id)}
|
||||||
unreadMap={unreadMap}
|
unreadMap={unreadMap}
|
||||||
|
t={t}
|
||||||
onOpen={openChat}
|
onOpen={openChat}
|
||||||
onDelete={handleDeleteChat}
|
onDelete={handleDeleteChat}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{deleteError && (
|
||||||
|
<Toast
|
||||||
|
message={deleteError}
|
||||||
|
type="error"
|
||||||
|
onDismiss={() => setDeleteError("")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useChats } from "../hooks/useChats";
|
import { useChats } from "../hooks/useChats";
|
||||||
import { useChatSocket } from "../hooks/useChatSocket";
|
import { useChatSocket } from "../hooks/useChatSocket";
|
||||||
import { getPostForChat } from "../stores/chatPostStore";
|
import { getPostForChat } from "../stores/chatPostStore";
|
||||||
|
import { sortMessages } from "../utils/chatDisplay";
|
||||||
import { setActiveChatId } from "../stores/activeChatStore";
|
import { setActiveChatId } from "../stores/activeChatStore";
|
||||||
import { useUnread } from "../context/UnreadContext";
|
import { useUnread } from "../context/UnreadContext";
|
||||||
import { useAuthContext } from "../context/AuthContext";
|
import { useAuthContext } from "../context/AuthContext";
|
||||||
@@ -33,7 +34,7 @@ export default function ChatPage() {
|
|||||||
const addMessage = useCallback((msg) => {
|
const addMessage = useCallback((msg) => {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
if (msg.id != null && prev.some((m) => m.id === msg.id)) return prev;
|
if (msg.id != null && prev.some((m) => m.id === msg.id)) return prev;
|
||||||
return [...prev, msg];
|
return sortMessages([...prev, msg]);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -53,11 +54,14 @@ export default function ChatPage() {
|
|||||||
.then((list) => {
|
.then((list) => {
|
||||||
// Мержим историю с уже пришедшими по сокету сообщениями,
|
// Мержим историю с уже пришедшими по сокету сообщениями,
|
||||||
// а не затираем их — иначе можно потерять сообщение, пришедшее
|
// а не затираем их — иначе можно потерять сообщение, пришедшее
|
||||||
// по WS, пока REST-запрос ещё letел.
|
// по WS, пока REST-запрос ещё летел. История приходит DESC (новые
|
||||||
|
// сверху), поэтому всегда сортируем по времени — иначе после
|
||||||
|
// обновления страницы все сообщения последнего собеседника
|
||||||
|
// оказываются наверху и идут подряд.
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const byId = new Map(list.map((m) => [m.id, m]));
|
const byId = new Map(list.map((m) => [m.id, m]));
|
||||||
const extra = prev.filter((m) => !byId.has(m.id));
|
const extra = prev.filter((m) => !byId.has(m.id));
|
||||||
return [...list, ...extra];
|
return sortMessages([...list, ...extra]);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => setLoadError(t('chat.loadError')));
|
.catch(() => setLoadError(t('chat.loadError')));
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
let activeChatId = null;
|
let activeChatId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простой модульный синглтон — НЕ реактивен. Не используйте getActiveChatId()
|
||||||
|
* напрямую в JSX компонента, рендер не обновится при изменении значения.
|
||||||
|
* Предназначен для чтения в императивном коде (WebSocket-колбэки и т.п.),
|
||||||
|
* где React-реактивность не нужна.
|
||||||
|
*/
|
||||||
export function setActiveChatId(id) {
|
export function setActiveChatId(id) {
|
||||||
activeChatId = id;
|
// Приводим к строке — chatId из useParams() всегда строка, а id из
|
||||||
|
// WS-сообщений сервера может прийти числом. Без нормализации сравнение
|
||||||
|
// через === в useUnreadSocket будет ложно негативным.
|
||||||
|
activeChatId = id != null ? String(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveChatId() {
|
export function getActiveChatId() {
|
||||||
|
|||||||
@@ -15,3 +15,13 @@ export function cachePostsFromChats(chats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Удаляет запись при удалении конкретного чата. */
|
||||||
|
export function removePostForChat(chatId) {
|
||||||
|
postByChatId.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полностью очищает кеш — вызывать при logout(), чтобы не хранить данные предыдущей сессии. */
|
||||||
|
export function clearPostCache() {
|
||||||
|
postByChatId.clear();
|
||||||
|
}
|
||||||
@@ -4,6 +4,26 @@ import { getImageUrl } from "../config/authConstants";
|
|||||||
* Резолвит данные для отображения одного чата в списке:
|
* Резолвит данные для отображения одного чата в списке:
|
||||||
* заголовок, картинку/эмодзи-аватар и счётчик непрочитанных.
|
* заголовок, картинку/эмодзи-аватар и счётчик непрочитанных.
|
||||||
*/
|
*/
|
||||||
|
/** Парсит ISO-дату без таймзоны ("2026-08-12T00:24:50.383077") в мс. Устойчив к Safari/старым браузерам. */
|
||||||
|
function toTimeMs(iso) {
|
||||||
|
if (!iso) return 0;
|
||||||
|
const parsed = Date.parse(iso);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/.exec(iso);
|
||||||
|
if (!m) return 0;
|
||||||
|
const [, y, mo, d, h, mi, s, frac = ""] = m;
|
||||||
|
const millis = Number(frac.padEnd(3, "0").slice(0, 3));
|
||||||
|
return new Date(+y, +mo - 1, +d, +h, +mi, +s, millis).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сортирует сообщения по времени создания (старые сверху), id — как страховка от одинаковых timestamp. */
|
||||||
|
export function sortMessages(messages) {
|
||||||
|
return [...messages].sort((a, b) => {
|
||||||
|
const t = toTimeMs(a.createdAt) - toTimeMs(b.createdAt);
|
||||||
|
return t !== 0 ? t : (a.id ?? 0) - (b.id ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getChatDisplayInfo(chat, post, unreadMap, t) {
|
export function getChatDisplayInfo(chat, post, unreadMap, t) {
|
||||||
const title = post?.title || chat.otherUser?.name || t('chat.user');
|
const title = post?.title || chat.otherUser?.name || t('chat.user');
|
||||||
const imgSrc = getImageUrl(post?.images?.[0]);
|
const imgSrc = getImageUrl(post?.images?.[0]);
|
||||||
|
|||||||
Reference in New Issue
Block a user