fix(chat): 发送后切会话不再误标未读,管理端撤回实时同步到对话页
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/freefire/jiang13-bbs/realtime"
|
"github.com/freefire/jiang13-bbs/realtime"
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -387,26 +388,7 @@ func (h *Handlers) RecallChatMessage(c *gin.Context) {
|
|||||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := gin.H{
|
h.broadcastChatRecalled(roomID, msg)
|
||||||
"id": msg.ID,
|
|
||||||
"room_id": roomID,
|
|
||||||
"sender_id": msg.SenderID,
|
|
||||||
"recalled_by": msg.RecalledBy,
|
|
||||||
"recalled_at": msg.RecalledAt,
|
|
||||||
}
|
|
||||||
if msg.Recaller != nil && msg.Recaller.ID > 0 {
|
|
||||||
data["recaller"] = gin.H{
|
|
||||||
"id": msg.Recaller.ID,
|
|
||||||
"username": msg.Recaller.Username,
|
|
||||||
"nickname": msg.Recaller.Nickname,
|
|
||||||
"avatar": msg.Recaller.Avatar,
|
|
||||||
"role": msg.Recaller.Role,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
|
||||||
Type: realtime.EventChatRecalled,
|
|
||||||
Data: data,
|
|
||||||
})
|
|
||||||
// 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传
|
// 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传
|
||||||
if !oversee && msg.SenderID != claims.ID {
|
if !oversee && msg.SenderID != claims.ID {
|
||||||
msg.Content = ""
|
msg.Content = ""
|
||||||
@@ -471,3 +453,80 @@ func (h *Handlers) ChatUnreadSummary(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"total": total, "rooms": rooms})
|
c.JSON(http.StatusOK, gin.H{"total": total, "rooms": rooms})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminChatMessages 管理端消息监管队列;room_type=group|direct
|
||||||
|
func (h *Handlers) AdminChatMessages(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
q := c.Query("q")
|
||||||
|
roomType := c.Query("room_type")
|
||||||
|
msgs, total, err := h.Chat.AdminListMessages(page, q, roomType)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "房间类型无效" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载聊天消息失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"messages": msgs, "total": total, "page": page})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRecallChatMessage 管理端撤回群聊消息(staff;监管者可撤任意)
|
||||||
|
func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
|
||||||
|
claims := middleware.CurrentUser(c)
|
||||||
|
roomID, err := strconv.ParseUint(c.Param("roomId"), 10, 64)
|
||||||
|
if err != nil || roomID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "房间 ID 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mid, err := strconv.ParseUint(c.Param("mid"), 10, 64)
|
||||||
|
if err != nil || mid == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oversee := h.chatOversee(claims.ID)
|
||||||
|
// 非监管 staff:仅允许撤回自己发送的(与前台一致)
|
||||||
|
msg, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.broadcastChatRecalled(uint(roomID), msg)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true, "id": msg.ID, "room_id": roomID, "recalled_at": msg.RecalledAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastChatRecalled 会话内撤回与管理端撤回共用:先落库再推 chat:{roomID}
|
||||||
|
func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage) {
|
||||||
|
if h.Hub == nil || msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := gin.H{
|
||||||
|
"id": msg.ID,
|
||||||
|
"room_id": roomID,
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
"recalled_by": msg.RecalledBy,
|
||||||
|
"recalled_at": msg.RecalledAt,
|
||||||
|
}
|
||||||
|
if msg.Recaller != nil && msg.Recaller.ID > 0 {
|
||||||
|
data["recaller"] = gin.H{
|
||||||
|
"id": msg.Recaller.ID,
|
||||||
|
"username": msg.Recaller.Username,
|
||||||
|
"nickname": msg.Recaller.Nickname,
|
||||||
|
"avatar": msg.Recaller.Avatar,
|
||||||
|
"role": msg.Recaller.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
||||||
|
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
||||||
|
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览
|
||||||
|
if h.Chat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if memberIDs, err := h.Chat.ListMemberIDs(roomID); err == nil {
|
||||||
|
for _, uid := range memberIDs {
|
||||||
|
h.Hub.BroadcastUser(uid, env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er
|
|||||||
return s.membership(s.db, roomID, userID)
|
return s.membership(s.db, roomID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位即未读)
|
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送)
|
||||||
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
||||||
out := make(map[uint]int64)
|
out := make(map[uint]int64)
|
||||||
if userID == 0 || len(roomIDs) == 0 {
|
if userID == 0 || len(roomIDs) == 0 {
|
||||||
@@ -127,7 +127,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
|||||||
s.db.Table("chat_messages AS m").
|
s.db.Table("chat_messages AS m").
|
||||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||||
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs).
|
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID).
|
||||||
Group("m.room_id").
|
Group("m.room_id").
|
||||||
Scan(&rows)
|
Scan(&rows)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
@@ -793,11 +793,17 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo
|
|||||||
if err := tx.Create(msg).Error; err != nil {
|
if err := tx.Create(msg).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
if err := tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"last_message_id": msg.ID,
|
"last_message_id": msg.ID,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now(),
|
||||||
}).Error
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 发送者同步推进已读水位,避免切房时把刚发出的消息算成未读
|
||||||
|
return tx.Model(&model.ChatRoomMember{}).
|
||||||
|
Where("room_id = ? AND user_id = ? AND last_read_message_id < ?", roomID, senderID, msg.ID).
|
||||||
|
Update("last_read_message_id", msg.ID).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -952,6 +958,137 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
return &msg, nil
|
return &msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminChatParticipant 管理端私聊双方摘要
|
||||||
|
type AdminChatParticipant struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChatMessageItem 管理端消息监管列表项(群聊 / 大厅 / 私聊)
|
||||||
|
type AdminChatMessageItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
RoomID uint `json:"room_id"`
|
||||||
|
RoomName string `json:"room_name"`
|
||||||
|
RoomType string `json:"room_type"` // group | direct
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
SenderID uint `json:"sender_id"`
|
||||||
|
SenderName string `json:"sender_name"`
|
||||||
|
SenderAvatar string `json:"sender_avatar"`
|
||||||
|
Participants []AdminChatParticipant `json:"participants,omitempty"` // 私聊双方
|
||||||
|
Content string `json:"content"`
|
||||||
|
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListMessages 按房间类型分页:group=大厅+群聊,direct=私聊;支持正文/房间/参与者搜索
|
||||||
|
func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
|
||||||
|
return nil, 0, errors.New("房间类型无效")
|
||||||
|
}
|
||||||
|
const pageSize = 15
|
||||||
|
q := s.db.Model(&model.ChatMessage{}).
|
||||||
|
Joins("JOIN chat_rooms ON chat_rooms.id = chat_messages.room_id").
|
||||||
|
Joins("LEFT JOIN users ON users.id = chat_messages.sender_id").
|
||||||
|
Where("chat_rooms.deleted_at IS NULL AND chat_messages.deleted_at IS NULL").
|
||||||
|
Where("chat_rooms.room_type = ?", roomType)
|
||||||
|
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||||
|
like := "%" + kw + "%"
|
||||||
|
q = q.Where(
|
||||||
|
`chat_messages.content ILIKE ? OR chat_rooms.name ILIKE ? OR users.nickname ILIKE ? OR users.username ILIKE ?
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM chat_room_members crm
|
||||||
|
JOIN users mu ON mu.id = crm.user_id
|
||||||
|
WHERE crm.room_id = chat_messages.room_id
|
||||||
|
AND (mu.nickname ILIKE ? OR mu.username ILIKE ?)
|
||||||
|
)`,
|
||||||
|
like, like, like, like, like, like,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var msgs []model.ChatMessage
|
||||||
|
if err := q.Preload("Sender").
|
||||||
|
Order("chat_messages.id DESC").
|
||||||
|
Offset((page - 1) * pageSize).
|
||||||
|
Limit(pageSize).
|
||||||
|
Find(&msgs).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
roomIDs := make([]uint, 0, len(msgs))
|
||||||
|
seen := map[uint]struct{}{}
|
||||||
|
for _, m := range msgs {
|
||||||
|
if _, ok := seen[m.RoomID]; !ok {
|
||||||
|
seen[m.RoomID] = struct{}{}
|
||||||
|
roomIDs = append(roomIDs, m.RoomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roomMap := map[uint]model.ChatRoom{}
|
||||||
|
if len(roomIDs) > 0 {
|
||||||
|
var rooms []model.ChatRoom
|
||||||
|
s.db.Where("id IN ?", roomIDs).Find(&rooms)
|
||||||
|
for _, r := range rooms {
|
||||||
|
roomMap[r.ID] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 私聊:预加载双方,供前端叠放头像与「甲 · 乙」会话条
|
||||||
|
participantsByRoom := map[uint][]AdminChatParticipant{}
|
||||||
|
if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 {
|
||||||
|
var members []model.ChatRoomMember
|
||||||
|
s.db.Where("room_id IN ?", roomIDs).Preload("User").Find(&members)
|
||||||
|
for i := range members {
|
||||||
|
name := members[i].User.Nickname
|
||||||
|
if name == "" {
|
||||||
|
name = members[i].User.Username
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "?"
|
||||||
|
}
|
||||||
|
participantsByRoom[members[i].RoomID] = append(participantsByRoom[members[i].RoomID], AdminChatParticipant{
|
||||||
|
ID: members[i].UserID, Name: name, Avatar: members[i].User.Avatar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]AdminChatMessageItem, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
r := roomMap[m.RoomID]
|
||||||
|
name := r.Name
|
||||||
|
parts := participantsByRoom[r.ID]
|
||||||
|
if r.IsDefault {
|
||||||
|
name = "全站大厅"
|
||||||
|
} else if r.RoomType == model.ChatRoomTypeDirect {
|
||||||
|
names := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
names = append(names, p.Name)
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
name = strings.Join(names, " · ")
|
||||||
|
} else if name == "" {
|
||||||
|
name = "私聊"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item := AdminChatMessageItem{
|
||||||
|
ID: m.ID, RoomID: m.RoomID, RoomName: name, RoomType: r.RoomType, IsDefault: r.IsDefault,
|
||||||
|
SenderID: m.SenderID, Content: m.Content, RecalledAt: m.RecalledAt, CreatedAt: m.CreatedAt,
|
||||||
|
Participants: parts,
|
||||||
|
}
|
||||||
|
if m.Sender.ID > 0 {
|
||||||
|
item.SenderName = m.Sender.Nickname
|
||||||
|
if item.SenderName == "" {
|
||||||
|
item.SenderName = m.Sender.Username
|
||||||
|
}
|
||||||
|
item.SenderAvatar = m.Sender.Avatar
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkRead 把已读水位推进到指定消息(单调不减;messageID=0 或超过最新则取最新)
|
// MarkRead 把已读水位推进到指定消息(单调不减;messageID=0 或超过最新则取最新)
|
||||||
func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
|
func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
|
||||||
room, err := s.getRoom(roomID)
|
room, err := s.getRoom(roomID)
|
||||||
@@ -975,7 +1112,7 @@ func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
|||||||
err := s.db.Table("chat_messages AS m").
|
err := s.db.Table("chat_messages AS m").
|
||||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||||
Where("m.id > rm.last_read_message_id AND m.deleted_at IS NULL AND m.recalled_at IS NULL").
|
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID).
|
||||||
Group("m.room_id").
|
Group("m.room_id").
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import {
|
|||||||
apiCreateChatRoom,
|
apiCreateChatRoom,
|
||||||
apiPinChatRoom,
|
apiPinChatRoom,
|
||||||
type ChatRoomView,
|
type ChatRoomView,
|
||||||
|
type ChatMessage,
|
||||||
type User,
|
type User,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP } from "@/lib/realtime";
|
import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP, RT_CHAT_RECALLED } from "@/lib/realtime";
|
||||||
import type { RtChatUnreadData, RtChatMembershipData } from "@/lib/realtime";
|
import type { RtChatUnreadData, RtChatMembershipData, RtChatRecalledData } from "@/lib/realtime";
|
||||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
@@ -111,13 +112,22 @@ export default function ChatShell({
|
|||||||
room: ChatRoomView;
|
room: ChatRoomView;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
/** 本人刚发出的消息 ID:拉列表时若水位尚未跟上,避免把这条算成未读 */
|
||||||
|
const lastSentIdByRoom = useRef<Map<number, number>>(new Map());
|
||||||
|
|
||||||
/** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */
|
/** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */
|
||||||
const applyRooms = useCallback(
|
const applyRooms = useCallback(
|
||||||
(list: ChatRoomView[]) => {
|
(list: ChatRoomView[]) => {
|
||||||
const next = (list || []).map((r) =>
|
const next = (list || []).map((r) => {
|
||||||
activeRoomId && r.id === activeRoomId ? { ...r, unread_count: 0 } : r
|
if (activeRoomId && r.id === activeRoomId) {
|
||||||
);
|
return { ...r, unread_count: 0 };
|
||||||
|
}
|
||||||
|
const sentId = lastSentIdByRoom.current.get(r.id);
|
||||||
|
if (sentId && (r.last_message_id || 0) <= sentId && (r.unread_count || 0) > 0) {
|
||||||
|
return { ...r, unread_count: 0 };
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
setRooms(sortRooms(next));
|
setRooms(sortRooms(next));
|
||||||
},
|
},
|
||||||
[activeRoomId]
|
[activeRoomId]
|
||||||
@@ -152,9 +162,9 @@ export default function ChatShell({
|
|||||||
[applyRooms]
|
[applyRooms]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存)
|
// 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存;切房强制拉取以免把刚发出的消息当成未读)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshRooms();
|
void refreshRooms({ force: true });
|
||||||
}, [refreshRooms, activeRoomId]);
|
}, [refreshRooms, activeRoomId]);
|
||||||
|
|
||||||
// 从其他标签页切回时对账(10s 限频 + 上方 2s 合并)
|
// 从其他标签页切回时对账(10s 限频 + 上方 2s 合并)
|
||||||
@@ -198,6 +208,78 @@ export default function ChatShell({
|
|||||||
return off;
|
return off;
|
||||||
}, [activeRoomId, refreshRooms]);
|
}, [activeRoomId, refreshRooms]);
|
||||||
|
|
||||||
|
// 本人刚发出的消息:立刻更新侧栏预览并清角标(发送者收不到 chat:unread)
|
||||||
|
useEffect(() => {
|
||||||
|
const onSent = (ev: Event) => {
|
||||||
|
const detail = (ev as CustomEvent<{ roomId?: number; message?: ChatMessage }>).detail;
|
||||||
|
if (!detail?.roomId || !detail.message) return;
|
||||||
|
const { roomId, message } = detail;
|
||||||
|
lastSentIdByRoom.current.set(roomId, message.id);
|
||||||
|
setRooms((prev) => {
|
||||||
|
const idx = prev.findIndex((r) => r.id === roomId);
|
||||||
|
if (idx < 0) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
const cur = next[idx];
|
||||||
|
next[idx] = {
|
||||||
|
...cur,
|
||||||
|
unread_count: 0,
|
||||||
|
last_message_id: message.id,
|
||||||
|
last_message: message,
|
||||||
|
updated_at: message.created_at || cur.updated_at,
|
||||||
|
};
|
||||||
|
return sortRooms(next);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("j13-chat-sent", onSent);
|
||||||
|
return () => window.removeEventListener("j13-chat-sent", onSent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 撤回(含管理端):若撤回的是侧栏预览那条,实时换成撤回文案
|
||||||
|
useEffect(() => {
|
||||||
|
const off = realtime.on<RtChatRecalledData>(RT_CHAT_RECALLED, (data) => {
|
||||||
|
if (!data?.room_id || !data.id) return;
|
||||||
|
setRooms((prev) => {
|
||||||
|
const idx = prev.findIndex((r) => r.id === data.room_id);
|
||||||
|
if (idx < 0) return prev;
|
||||||
|
const cur = prev[idx];
|
||||||
|
if (cur.last_message?.id !== data.id && cur.last_message_id !== data.id) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = {
|
||||||
|
...cur,
|
||||||
|
last_message: {
|
||||||
|
id: data.id,
|
||||||
|
room_id: data.room_id,
|
||||||
|
sender_id: data.sender_id || cur.last_message?.sender_id || 0,
|
||||||
|
content: "",
|
||||||
|
created_at: cur.last_message?.created_at || new Date().toISOString(),
|
||||||
|
recalled_at: data.recalled_at || new Date().toISOString(),
|
||||||
|
recalled_by: data.recalled_by,
|
||||||
|
sender: cur.last_message?.sender || ({
|
||||||
|
id: data.sender_id || 0,
|
||||||
|
username: "",
|
||||||
|
nickname: "",
|
||||||
|
avatar: "",
|
||||||
|
role: "user",
|
||||||
|
} as User),
|
||||||
|
recaller: data.recaller
|
||||||
|
? {
|
||||||
|
id: data.recaller.id,
|
||||||
|
username: data.recaller.username,
|
||||||
|
nickname: data.recaller.nickname,
|
||||||
|
avatar: data.recaller.avatar,
|
||||||
|
role: data.recaller.role || "user",
|
||||||
|
}
|
||||||
|
: cur.last_message?.recaller,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return off;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = realtime.on<RtChatMembershipData>(RT_CHAT_MEMBERSHIP, (data) => {
|
const off = realtime.on<RtChatMembershipData>(RT_CHAT_MEMBERSHIP, (data) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { canOverseeChat } from "@/lib/roles";
|
|||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { scrollBehavior } from "@/lib/motion";
|
import { scrollBehavior } from "@/lib/motion";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
|
import AutolinkText from "@/components/AutolinkText";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||||
|
|
||||||
@@ -56,10 +57,11 @@ const MARK_READ_GAP_MS = 2_000;
|
|||||||
const markReadInflight = new Set<number>();
|
const markReadInflight = new Set<number>();
|
||||||
const markReadLastAt = new Map<number, number>();
|
const markReadLastAt = new Map<number, number>();
|
||||||
|
|
||||||
function requestMarkChatRead(roomId: number): void {
|
function requestMarkChatRead(roomId: number, opts?: { force?: boolean }): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const last = markReadLastAt.get(roomId) || 0;
|
const last = markReadLastAt.get(roomId) || 0;
|
||||||
if (markReadInflight.has(roomId) || now - last < MARK_READ_GAP_MS) return;
|
if (markReadInflight.has(roomId)) return;
|
||||||
|
if (!opts?.force && now - last < MARK_READ_GAP_MS) return;
|
||||||
markReadInflight.add(roomId);
|
markReadInflight.add(roomId);
|
||||||
markReadLastAt.set(roomId, now);
|
markReadLastAt.set(roomId, now);
|
||||||
apiMarkChatRead(roomId, 0)
|
apiMarkChatRead(roomId, 0)
|
||||||
@@ -235,6 +237,17 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
|
||||||
|
function renderChatContent(content: string, members: ChatRoomMember[]) {
|
||||||
|
return (
|
||||||
|
<AutolinkText
|
||||||
|
text={content}
|
||||||
|
forceNewTab
|
||||||
|
renderText={(seg) => <>{highlightMentions(seg, members)}</>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 输入区镜像层:已确认的 @ 显示为飞书风 token */
|
/** 输入区镜像层:已确认的 @ 显示为飞书风 token */
|
||||||
function renderComposerMirror(content: string, tokens: Set<string>) {
|
function renderComposerMirror(content: string, tokens: Set<string>) {
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
@@ -405,10 +418,13 @@ export default function ChatRoomClient({
|
|||||||
}
|
}
|
||||||
}, [roomId, canView]);
|
}, [roomId, canView]);
|
||||||
|
|
||||||
const markReadToLatest = useCallback(() => {
|
const markReadToLatest = useCallback(
|
||||||
|
(opts?: { force?: boolean }) => {
|
||||||
if (!isRealMember) return;
|
if (!isRealMember) return;
|
||||||
requestMarkChatRead(roomId);
|
requestMarkChatRead(roomId, opts);
|
||||||
}, [roomId, isRealMember]);
|
},
|
||||||
|
[roomId, isRealMember]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canView) return;
|
if (!canView) return;
|
||||||
@@ -563,7 +579,11 @@ export default function ChatRoomClient({
|
|||||||
return [...prev, message];
|
return [...prev, message];
|
||||||
});
|
});
|
||||||
requestAnimationFrame(() => scrollToBottom(true));
|
requestAnimationFrame(() => scrollToBottom(true));
|
||||||
markReadToLatest();
|
// 发送成功立刻同步侧栏预览,并强制推进已读(避开进房 MarkRead 的 2s 节流)
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("j13-chat-sent", { detail: { roomId, message } })
|
||||||
|
);
|
||||||
|
markReadToLatest({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setContent(prevContent);
|
setContent(prevContent);
|
||||||
setMentionTokens(prevTokens);
|
setMentionTokens(prevTokens);
|
||||||
@@ -1196,7 +1216,7 @@ export default function ChatRoomClient({
|
|||||||
已撤回
|
已撤回
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "var(--ink-2)" }}>
|
<div style={{ color: "var(--ink-2)" }}>
|
||||||
{highlightMentions(m.content, members)}
|
{renderChatContent(m.content, members)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1291,7 +1311,7 @@ export default function ChatRoomClient({
|
|||||||
{m.reply_snap}
|
{m.reply_snap}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{highlightMentions(m.content, members)}
|
{renderChatContent(m.content, members)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
62
frontend/components/AutolinkText.tsx
Normal file
62
frontend/components/AutolinkText.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { usePostLinkNewTab } from "@/components/PostLinkProvider";
|
||||||
|
import { linkifyParts } from "@/lib/linkify";
|
||||||
|
|
||||||
|
function AutolinkAnchor({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
openInNewTab,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
openInNewTab: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="j13-autolink break-all"
|
||||||
|
style={{ color: "var(--accent)", textDecoration: "underline" }}
|
||||||
|
{...(openInNewTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onContextMenu={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯文本自动识别 URL。
|
||||||
|
* forceNewTab:聊天等场景始终新开;否则读 PostLinkProvider(post_link_new_tab)。
|
||||||
|
*/
|
||||||
|
export default function AutolinkText({
|
||||||
|
text,
|
||||||
|
renderText,
|
||||||
|
forceNewTab = false,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
renderText?: (segment: string) => ReactNode;
|
||||||
|
forceNewTab?: boolean;
|
||||||
|
}) {
|
||||||
|
const settingNewTab = usePostLinkNewTab();
|
||||||
|
const openInNewTab = forceNewTab || settingNewTab;
|
||||||
|
const parts = linkifyParts(text);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((p, i) => {
|
||||||
|
const key = `l-${i}`;
|
||||||
|
if (p.type === "url") {
|
||||||
|
return (
|
||||||
|
<AutolinkAnchor key={key} href={p.href} openInNewTab={openInNewTab}>
|
||||||
|
{p.value}
|
||||||
|
</AutolinkAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (renderText) return <span key={key}>{renderText(p.value)}</span>;
|
||||||
|
return <span key={key}>{p.value}</span>;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/components/PostLinkProvider.tsx
Normal file
20
frontend/components/PostLinkProvider.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
const PostLinkCtx = createContext(true);
|
||||||
|
|
||||||
|
/** 帖内 Markdown 外链是否新标签打开(来自公开站点设置) */
|
||||||
|
export default function PostLinkProvider({
|
||||||
|
openInNewTab,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
openInNewTab: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <PostLinkCtx.Provider value={openInNewTab}>{children}</PostLinkCtx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePostLinkNewTab() {
|
||||||
|
return useContext(PostLinkCtx);
|
||||||
|
}
|
||||||
55
frontend/lib/linkify.ts
Normal file
55
frontend/lib/linkify.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 纯文本 URL 切分(QQ 风格:识别 http(s):// 与 www.)
|
||||||
|
* 仅产出 http/https,拒绝其它协议。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const URL_RE = /(?:https?:\/\/|www\.)[^\s\u4e00-\u9fff<>"'`]+/gi;
|
||||||
|
|
||||||
|
/** 常见尾随标点从 URL 剥离(中英) */
|
||||||
|
const TRAIL_PUNCT = /[.,;:!?。,;:!?、)\]}》〉』」"'`]+$/;
|
||||||
|
|
||||||
|
export type LinkifyPart =
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "url"; value: string; href: string };
|
||||||
|
|
||||||
|
function toHref(raw: string): string | null {
|
||||||
|
const href = /^www\./i.test(raw) ? `https://${raw}` : raw;
|
||||||
|
try {
|
||||||
|
const u = new URL(href);
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
return u.href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function peel(raw: string): { core: string; trail: string } {
|
||||||
|
const m = raw.match(TRAIL_PUNCT);
|
||||||
|
if (!m) return { core: raw, trail: "" };
|
||||||
|
return { core: raw.slice(0, -m[0].length), trail: m[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将纯文本切成文本段与可安全打开的链接段 */
|
||||||
|
export function linkifyParts(text: string): LinkifyPart[] {
|
||||||
|
if (!text) return [];
|
||||||
|
const parts: LinkifyPart[] = [];
|
||||||
|
let last = 0;
|
||||||
|
URL_RE.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = URL_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > last) {
|
||||||
|
parts.push({ type: "text", value: text.slice(last, m.index) });
|
||||||
|
}
|
||||||
|
const { core, trail } = peel(m[0]);
|
||||||
|
const href = core ? toHref(core) : null;
|
||||||
|
if (href && core) {
|
||||||
|
parts.push({ type: "url", value: core, href });
|
||||||
|
if (trail) parts.push({ type: "text", value: trail });
|
||||||
|
} else {
|
||||||
|
parts.push({ type: "text", value: m[0] });
|
||||||
|
}
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) parts.push({ type: "text", value: text.slice(last) });
|
||||||
|
return parts.length ? parts : [{ type: "text", value: text }];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user