diff --git a/backend/handler/chat.go b/backend/handler/chat.go index 28a4e30..637ae55 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/realtime" "github.com/freefire/jiang13-bbs/service" "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()}) 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, - } - } - h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ - Type: realtime.EventChatRecalled, - Data: data, - }) + h.broadcastChatRecalled(roomID, msg) // 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传 if !oversee && msg.SenderID != claims.ID { msg.Content = "" @@ -471,3 +453,80 @@ func (h *Handlers) ChatUnreadSummary(c *gin.Context) { } 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) + } + } +} diff --git a/backend/service/chat.go b/backend/service/chat.go index 9340d74..65776d1 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -117,7 +117,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er return s.membership(s.db, roomID, userID) } -// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位即未读) +// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送) func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 { out := make(map[uint]int64) 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"). 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). - 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"). Scan(&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 { return err } - return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID). + if err := tx.Model(&model.ChatRoom{}).Where("id = ?", roomID). Updates(map[string]interface{}{ "last_message_id": msg.ID, "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 { return nil, nil, err @@ -952,6 +958,137 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee 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 或超过最新则取最新) func (s *ChatService) MarkRead(userID, roomID, messageID uint) error { 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"). 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). - 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"). Scan(&rows).Error if err != nil { diff --git a/frontend/app/chat/ChatShell.tsx b/frontend/app/chat/ChatShell.tsx index 7a20ac6..969f7d8 100644 --- a/frontend/app/chat/ChatShell.tsx +++ b/frontend/app/chat/ChatShell.tsx @@ -18,10 +18,11 @@ import { apiCreateChatRoom, apiPinChatRoom, type ChatRoomView, + type ChatMessage, type User, } from "@/lib/api"; -import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP } from "@/lib/realtime"; -import type { RtChatUnreadData, RtChatMembershipData } from "@/lib/realtime"; +import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP, RT_CHAT_RECALLED } from "@/lib/realtime"; +import type { RtChatUnreadData, RtChatMembershipData, RtChatRecalledData } from "@/lib/realtime"; import { onDocumentVisible } from "@/lib/visibilityReconcile"; import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; @@ -111,13 +112,22 @@ export default function ChatShell({ room: ChatRoomView; } | null>(null); const longPressTimer = useRef | null>(null); + /** 本人刚发出的消息 ID:拉列表时若水位尚未跟上,避免把这条算成未读 */ + const lastSentIdByRoom = useRef>(new Map()); /** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */ const applyRooms = useCallback( (list: ChatRoomView[]) => { - const next = (list || []).map((r) => - activeRoomId && r.id === activeRoomId ? { ...r, unread_count: 0 } : r - ); + const next = (list || []).map((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)); }, [activeRoomId] @@ -152,9 +162,9 @@ export default function ChatShell({ [applyRooms] ); - // 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存) + // 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存;切房强制拉取以免把刚发出的消息当成未读) useEffect(() => { - void refreshRooms(); + void refreshRooms({ force: true }); }, [refreshRooms, activeRoomId]); // 从其他标签页切回时对账(10s 限频 + 上方 2s 合并) @@ -198,6 +208,78 @@ export default function ChatShell({ return off; }, [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(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(() => { const off = realtime.on(RT_CHAT_MEMBERSHIP, (data) => { if (!data) return; diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx index 3f2b24d..fd94223 100644 --- a/frontend/app/chat/[id]/ChatRoomClient.tsx +++ b/frontend/app/chat/[id]/ChatRoomClient.tsx @@ -48,6 +48,7 @@ import { canOverseeChat } from "@/lib/roles"; import { toast } from "@/lib/toast"; import { scrollBehavior } from "@/lib/motion"; import Avatar from "@/components/Avatar"; +import AutolinkText from "@/components/AutolinkText"; import ConfirmDialog from "@/components/ConfirmDialog"; import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu"; @@ -56,10 +57,11 @@ const MARK_READ_GAP_MS = 2_000; const markReadInflight = new Set(); const markReadLastAt = new Map(); -function requestMarkChatRead(roomId: number): void { +function requestMarkChatRead(roomId: number, opts?: { force?: boolean }): void { const now = Date.now(); 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); markReadLastAt.set(roomId, now); apiMarkChatRead(roomId, 0) @@ -235,6 +237,17 @@ function highlightMentions(content: string, members: ChatRoomMember[]) { return parts; } +/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */ +function renderChatContent(content: string, members: ChatRoomMember[]) { + return ( + <>{highlightMentions(seg, members)}} + /> + ); +} + /** 输入区镜像层:已确认的 @ 显示为飞书风 token */ function renderComposerMirror(content: string, tokens: Set) { if (!content) return null; @@ -405,10 +418,13 @@ export default function ChatRoomClient({ } }, [roomId, canView]); - const markReadToLatest = useCallback(() => { - if (!isRealMember) return; - requestMarkChatRead(roomId); - }, [roomId, isRealMember]); + const markReadToLatest = useCallback( + (opts?: { force?: boolean }) => { + if (!isRealMember) return; + requestMarkChatRead(roomId, opts); + }, + [roomId, isRealMember] + ); useEffect(() => { if (!canView) return; @@ -563,7 +579,11 @@ export default function ChatRoomClient({ return [...prev, message]; }); requestAnimationFrame(() => scrollToBottom(true)); - markReadToLatest(); + // 发送成功立刻同步侧栏预览,并强制推进已读(避开进房 MarkRead 的 2s 节流) + window.dispatchEvent( + new CustomEvent("j13-chat-sent", { detail: { roomId, message } }) + ); + markReadToLatest({ force: true }); } catch (e) { setContent(prevContent); setMentionTokens(prevTokens); @@ -1196,7 +1216,7 @@ export default function ChatRoomClient({ 已撤回
- {highlightMentions(m.content, members)} + {renderChatContent(m.content, members)}
@@ -1291,7 +1311,7 @@ export default function ChatRoomClient({ {m.reply_snap} )} - {highlightMentions(m.content, members)} + {renderChatContent(m.content, members)} diff --git a/frontend/components/AutolinkText.tsx b/frontend/components/AutolinkText.tsx new file mode 100644 index 0000000..0bfe6b2 --- /dev/null +++ b/frontend/components/AutolinkText.tsx @@ -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 ( + e.stopPropagation()} + onContextMenu={(e) => e.stopPropagation()} + > + {children} + + ); +} + +/** + * 纯文本自动识别 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 ( + + {p.value} + + ); + } + if (renderText) return {renderText(p.value)}; + return {p.value}; + })} + + ); +} diff --git a/frontend/components/PostLinkProvider.tsx b/frontend/components/PostLinkProvider.tsx new file mode 100644 index 0000000..1a6d75e --- /dev/null +++ b/frontend/components/PostLinkProvider.tsx @@ -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 {children}; +} + +export function usePostLinkNewTab() { + return useContext(PostLinkCtx); +} diff --git a/frontend/lib/linkify.ts b/frontend/lib/linkify.ts new file mode 100644 index 0000000..e533446 --- /dev/null +++ b/frontend/lib/linkify.ts @@ -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 }]; +}