fixed code v2
This commit is contained in:
@@ -1,60 +1,22 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Client } from "@stomp/stompjs";
|
||||
import SockJS from "sockjs-client";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
|
||||
// src/hooks/useChatSocket.js
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useStompClient, parseStompMessage } from "./useStompClient";
|
||||
|
||||
export function useChatSocket(chatId, onMessage) {
|
||||
const clientRef = useRef(null);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
// Держим актуальный колбэк в ref, чтобы не пересоздавать соединение
|
||||
// при каждом ре-рендере родителя из-за нового onMessage
|
||||
useEffect(() => {
|
||||
onMessageRef.current = onMessage;
|
||||
}, [onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
try {
|
||||
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 на /)
|
||||
}
|
||||
}
|
||||
},
|
||||
const handleConnect = useCallback((client) => {
|
||||
client.subscribe(`/topic/chat/${chatId}`, (msg) => {
|
||||
parseStompMessage(msg, (body) => onMessageRef.current(body));
|
||||
});
|
||||
|
||||
client.activate();
|
||||
clientRef.current = client;
|
||||
|
||||
return () => client.deactivate();
|
||||
}, [chatId]);
|
||||
|
||||
const { clientRef, connected } = useStompClient(!!chatId, handleConnect);
|
||||
|
||||
/** @returns {boolean} true если сообщение реально ушло в сокет, false если соединения нет */
|
||||
const sendMessage = useCallback((content) => {
|
||||
if (!clientRef.current?.connected) return false;
|
||||
@@ -63,7 +25,7 @@ export function useChatSocket(chatId, onMessage) {
|
||||
body: JSON.stringify({ chatId, content }),
|
||||
});
|
||||
return true;
|
||||
}, [chatId]);
|
||||
}, [chatId, clientRef]);
|
||||
|
||||
return { connected, sendMessage };
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
// src/hooks/useDeleteChat.js
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -25,10 +37,12 @@ export function useDeleteChat() {
|
||||
}
|
||||
|
||||
clearChatUnread(chatId);
|
||||
return true;
|
||||
removePostForChat(chatId);
|
||||
return { success: true, error: null };
|
||||
} catch (err) {
|
||||
setError(err.message || t('chat.deleteError'));
|
||||
return false;
|
||||
const message = err.message || t('chat.deleteError');
|
||||
setError(message);
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
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 { Client } from "@stomp/stompjs";
|
||||
import SockJS from "sockjs-client";
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { useCallback } from "react";
|
||||
import { useStompClient, parseStompMessage } from "./useStompClient";
|
||||
import { useUnread } from "../context/UnreadContext";
|
||||
import { getActiveChatId } from "../stores/activeChatStore";
|
||||
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
|
||||
|
||||
export function useUnreadSocket(userId) {
|
||||
const { setChatUnread } = useUnread();
|
||||
const clientRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
const body = JSON.parse(msg.body);
|
||||
if (body.chatId === getActiveChatId()) return;
|
||||
setChatUnread(body.chatId, body.unreadCount);
|
||||
});
|
||||
},
|
||||
onStompError: async (frame) => {
|
||||
if (frame.headers?.message?.includes("Unauthorized")) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
} catch {
|
||||
// refresh не удался — общий логин-флоу разберётся при следующем apiFetch
|
||||
}
|
||||
}
|
||||
},
|
||||
const handleConnect = useCallback((client) => {
|
||||
client.subscribe(`/topic/user/${userId}/unread`, (msg) => {
|
||||
parseStompMessage(msg, (body) => {
|
||||
if (String(body.chatId) === getActiveChatId()) return;
|
||||
setChatUnread(body.chatId, body.unreadCount);
|
||||
});
|
||||
});
|
||||
|
||||
client.activate();
|
||||
clientRef.current = client;
|
||||
|
||||
return () => client.deactivate();
|
||||
}, [userId, setChatUnread]);
|
||||
|
||||
useStompClient(!!userId, handleConnect);
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
import {useState} from "react";
|
||||
import {API_URL} from "../config/authConstants";
|
||||
import {apiFetch} from "../api/apiClient";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_URL } from "../config/authConstants";
|
||||
import { apiFetch } from "../api/apiClient";
|
||||
|
||||
/**
|
||||
* useUpdateProfile — хук для обновления профиля пользователя.
|
||||
* Отправляет PUT /api/v1/users/{id} с FormData (поддерживает загрузку аватара).
|
||||
*
|
||||
* updateProfile возвращает { success, data, error } — data содержит
|
||||
* обновлённого пользователя при успехе, error — текст ошибки при неудаче.
|
||||
*/
|
||||
export function useUpdateProfile() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
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);
|
||||
setError("");
|
||||
|
||||
@@ -36,17 +47,19 @@ export function useUpdateProfile() {
|
||||
|
||||
if (!res.ok) {
|
||||
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) {
|
||||
setError(err.message || "Failed to save profile");
|
||||
return null;
|
||||
const message = err.message || t('profile.updateError');
|
||||
setError(message);
|
||||
return { success: false, data: null, error: message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return { updateProfile, loading, error };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
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 = "") {
|
||||
const mapRef = useRef(null);
|
||||
@@ -21,8 +21,6 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Игнорируем ответ, если пришёл более новый запрос — иначе
|
||||
// устаревший ответ может перезаписать адрес актуальных координат.
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
const found =
|
||||
@@ -104,7 +102,12 @@ export function useYandexMap(enabled, initialCoords = null, initialAddress = "")
|
||||
placemarkRef.current = null;
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user