From a4e7e51f5017179221920c65a0bcb5a2b1d1498d Mon Sep 17 00:00:00 2001 From: cloudwithax Date: Sun, 15 Feb 2026 21:36:37 -0500 Subject: [PATCH] chore: checkpoint current local changes --- src/components/chat/DMSearchPanel.tsx | 418 ++++++++++++++++++++++++++ src/components/chat/Message.tsx | 15 +- src/components/chat/MessageInput.tsx | 2 - src/components/layout/DMChatArea.tsx | 88 +++++- src/components/layout/DMSidebar.tsx | 3 +- src/lib/mentions.ts | 20 +- src/lib/notifications.ts | 7 +- src/styles/app.css | 27 ++ 8 files changed, 556 insertions(+), 24 deletions(-) create mode 100644 src/components/chat/DMSearchPanel.tsx diff --git a/src/components/chat/DMSearchPanel.tsx b/src/components/chat/DMSearchPanel.tsx new file mode 100644 index 0000000..67fb502 --- /dev/null +++ b/src/components/chat/DMSearchPanel.tsx @@ -0,0 +1,418 @@ +import type { Component } from "solid-js"; +import { createSignal, createMemo, onMount, Show, For } from "solid-js"; +import { + Search, + X, + User, + Calendar, + Image, + AtSign, + Link, + FileText, + ChevronDown, + ChevronUp, + Loader2, +} from "lucide-solid"; +import type { ChatMessage, DirectMessage } from "../../lib/types"; +import { formatTime, formatDaySeparator } from "../../lib/utils"; +import { extractMentions } from "../../lib/mentions"; +import * as tauri from "../../lib/tauri"; + +// regex patterns for detecting media in message content +const IMAGE_REGEX = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)(\?[^\s]*)?$/i; +const VIDEO_REGEX = /\.(mp4|webm|mov|avi|mkv)(\?[^\s]*)?$/i; +const LINK_REGEX = /https?:\/\/[^\s]+/i; +const FILE_REGEX = /\.(pdf|doc|docx|xls|xlsx|zip|rar|7z|tar|gz)(\?[^\s]*)?$/i; + +// upper bound so we pull the entire conversation from disk +const ALL_MESSAGES_LIMIT = 1_000_000; + +type MediaFilter = "images" | "videos" | "links" | "files"; +type FilterFrom = "anyone" | "me" | "them"; + +interface DMSearchPanelProps { + peerId: string; + myPeerId: string; + peerName: string; + onClose: () => void; + onJumpToMessage: (messageId: string, allMessages: DirectMessage[]) => void; +} + +const DMSearchPanel: Component = (props) => { + const [query, setQuery] = createSignal(""); + const [fromFilter, setFromFilter] = createSignal("anyone"); + const [mediaFilter, setMediaFilter] = createSignal(null); + const [mentionsOnly, setMentionsOnly] = createSignal(false); + const [dateAfter, setDateAfter] = createSignal(""); + const [dateBefore, setDateBefore] = createSignal(""); + const [showFilters, setShowFilters] = createSignal(false); + + // full conversation loaded from disk for searching + const [allMessages, setAllMessages] = createSignal([]); + const [loading, setLoading] = createSignal(true); + + let inputRef: HTMLInputElement | undefined; + + // load entire conversation history from disk on mount + onMount(async () => { + try { + const msgs = await tauri.getDMMessages( + props.peerId, + undefined, + ALL_MESSAGES_LIMIT, + ); + setAllMessages(msgs); + } catch (e) { + console.error("failed to load all dm messages for search:", e); + } finally { + setLoading(false); + // focus after loading completes + inputRef?.focus(); + } + }); + + // adapt DirectMessage[] to a searchable shape + const searchableMessages = createMemo((): ChatMessage[] => + allMessages().map((m) => ({ + id: m.id, + channel_id: `dm_${props.peerId}`, + author_id: m.from_peer, + author_name: m.from_display_name, + content: m.content, + timestamp: m.timestamp, + edited: false, + })), + ); + + const hasActiveFilters = createMemo(() => { + return ( + fromFilter() !== "anyone" || + mediaFilter() !== null || + mentionsOnly() || + dateAfter() !== "" || + dateBefore() !== "" + ); + }); + + const filteredMessages = createMemo(() => { + const q = query().toLowerCase().trim(); + const from = fromFilter(); + const media = mediaFilter(); + const mentions = mentionsOnly(); + const after = dateAfter(); + const before = dateBefore(); + + // no search or filters active, return nothing + if (!q && !hasActiveFilters()) return []; + + const afterTs = after ? new Date(after).getTime() : null; + const beforeTs = before + ? new Date(before).getTime() + 86_400_000 + : null; + + return searchableMessages().filter((msg) => { + // text query + if (q && !msg.content.toLowerCase().includes(q)) return false; + + // from filter + if (from === "me" && msg.author_id !== props.myPeerId) return false; + if (from === "them" && msg.author_id === props.myPeerId) return false; + + // date range + if (afterTs && msg.timestamp < afterTs) return false; + if (beforeTs && msg.timestamp > beforeTs) return false; + + // media type + if (media) { + const content = msg.content.trim(); + if (media === "images" && !IMAGE_REGEX.test(content)) return false; + if (media === "videos" && !VIDEO_REGEX.test(content)) return false; + if (media === "links" && !LINK_REGEX.test(content)) return false; + if (media === "files" && !FILE_REGEX.test(content)) return false; + } + + // mentions only + if (mentions && extractMentions(msg.content).length === 0) return false; + + return true; + }); + }); + + function clearAllFilters() { + setQuery(""); + setFromFilter("anyone"); + setMediaFilter(null); + setMentionsOnly(false); + setDateAfter(""); + setDateBefore(""); + } + + function handleJump(messageId: string) { + props.onJumpToMessage(messageId, allMessages()); + } + + // highlight matching text in a result snippet + function highlightMatch(text: string): string { + const q = query().trim(); + if (!q) return escapeHtml(truncate(text, 120)); + + const escaped = escapeHtml(truncate(text, 120)); + const regex = new RegExp( + `(${escapeRegex(escapeHtml(q))})`, + "gi", + ); + return escaped.replace( + regex, + '$1', + ); + } + + return ( +
+ {/* search input row */} +
+ + } + > + + + setQuery(e.currentTarget.value)} + disabled={loading()} + class="flex-1 bg-transparent text-[14px] text-white placeholder:text-white/30 outline-none disabled:opacity-50" + /> + + + {filteredMessages().length} result{filteredMessages().length !== 1 ? "s" : ""} + + + + +
+ + {/* filter chips */} + +
+ {/* from filter */} +
+ + from + +
+ setFromFilter("anyone")} + icon={} + label="anyone" + /> + setFromFilter("me")} + icon={} + label="me" + /> + setFromFilter("them")} + icon={} + label={props.peerName} + /> +
+
+ + {/* media type filter */} +
+ + type + +
+ + setMediaFilter((v) => (v === "images" ? null : "images")) + } + icon={} + label="images" + /> + + setMediaFilter((v) => (v === "videos" ? null : "videos")) + } + icon={} + label="videos" + /> + + setMediaFilter((v) => (v === "links" ? null : "links")) + } + icon={} + label="links" + /> + + setMediaFilter((v) => (v === "files" ? null : "files")) + } + icon={} + label="files" + /> + setMentionsOnly((v) => !v)} + icon={} + label="mentions" + /> +
+
+ + {/* date range */} +
+ + date + +
+
+ + setDateAfter(e.currentTarget.value)} + class="bg-gray-800 text-[12px] font-mono text-white/60 px-2 py-1 border border-white/10 outline-none focus:border-orange transition-colors duration-200 [color-scheme:dark]" + placeholder="after" + /> +
+ to +
+ setDateBefore(e.currentTarget.value)} + class="bg-gray-800 text-[12px] font-mono text-white/60 px-2 py-1 border border-white/10 outline-none focus:border-orange transition-colors duration-200 [color-scheme:dark]" + placeholder="before" + /> +
+
+
+ + {/* clear all */} + + + +
+
+ + {/* search results */} + +
+ 0} + fallback={ +
+ no messages found +
+ } + > + + {(msg) => ( + + )} + +
+
+
+
+ ); +}; + +// reusable filter chip +interface FilterChipProps { + active: boolean; + onClick: () => void; + icon: any; + label: string; +} + +const FilterChip: Component = (props) => ( + +); + +// utilities +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function truncate(str: string, max: number): string { + if (str.length <= max) return str; + return str.slice(0, max) + "..."; +} + +export default DMSearchPanel; diff --git a/src/components/chat/Message.tsx b/src/components/chat/Message.tsx index ecc605c..ec872c7 100644 --- a/src/components/chat/Message.tsx +++ b/src/components/chat/Message.tsx @@ -7,6 +7,7 @@ import type { MediaKind } from "../../lib/markdown"; import { removeMessage } from "../../stores/messages"; import { activeCommunityId } from "../../stores/communities"; import { identity } from "../../stores/identity"; +import { isMentioned } from "../../lib/mentions"; import Avatar from "../common/Avatar"; import Lightbox from "../common/Lightbox"; import { openProfileCard } from "../../stores/ui"; @@ -36,6 +37,13 @@ const Message: Component = (props) => { getStandaloneMediaKind(props.message.content), ); + // check if the current user is mentioned in this message + const mentionsMe = createMemo(() => { + const user = currentUser(); + if (!user) return false; + return isMentioned(props.message.content, user.peer_id); + }); + const [lightboxOpen, setLightboxOpen] = createSignal(false); const isOwner = () => { @@ -106,9 +114,10 @@ const Message: Component = (props) => { return (
= (props) => { if (props.mentionPeers) { const items: MentionItem[] = []; for (const peer of props.mentionPeers) { - if (currentUser && peer.id === currentUser.peer_id) continue; if (!q || peer.name.toLowerCase().includes(q)) { items.push({ id: peer.id, @@ -97,7 +96,6 @@ const MessageInput: Component = (props) => { } for (const member of memberList) { - if (currentUser && member.peer_id === currentUser.peer_id) continue; if (!q || member.display_name.toLowerCase().includes(q)) { items.push({ id: member.peer_id, diff --git a/src/components/layout/DMChatArea.tsx b/src/components/layout/DMChatArea.tsx index 472131c..babc27b 100644 --- a/src/components/layout/DMChatArea.tsx +++ b/src/components/layout/DMChatArea.tsx @@ -1,17 +1,21 @@ import type { Component } from "solid-js"; -import { Show, createMemo } from "solid-js"; -import { AtSign } from "lucide-solid"; +import { Show, createMemo, createSignal } from "solid-js"; +import { Phone, Pin, Search } from "lucide-solid"; import { activeDMConversation, dmMessages, dmTypingPeers, + setDMMessages, } from "../../stores/dms"; import { onlinePeerIds } from "../../stores/members"; +import { identity } from "../../stores/identity"; import MessageList from "../chat/MessageList"; import MessageInput from "../chat/MessageInput"; import TypingIndicator from "../chat/TypingIndicator"; +import DMSearchPanel from "../chat/DMSearchPanel"; import Avatar from "../common/Avatar"; -import type { ChatMessage } from "../../lib/types"; +import IconButton from "../common/IconButton"; +import type { ChatMessage, DirectMessage } from "../../lib/types"; interface DMChatAreaProps { onSendDM: (content: string) => void; @@ -19,6 +23,7 @@ interface DMChatAreaProps { } const DMChatArea: Component = (props) => { + const [searchOpen, setSearchOpen] = createSignal(false); const dm = () => activeDMConversation(); // adapt DirectMessage[] to ChatMessage[] so the existing MessageList works @@ -42,6 +47,31 @@ const DMChatArea: Component = (props) => { return "offline"; }); + // scroll to a message by id, loading full history into the store if needed + function handleJumpToMessage( + messageId: string, + allMessages: DirectMessage[], + ) { + const alreadyLoaded = dmMessages().some((m) => m.id === messageId); + + if (!alreadyLoaded) { + // replace the store with the full history so the target is in the dom + setDMMessages(allMessages); + } + + // wait for the dom to update then scroll and highlight + requestAnimationFrame(() => { + const el = document.querySelector( + `[data-message-id="${messageId}"]`, + ) as HTMLElement | null; + if (!el) return; + + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("dusk-msg-search-highlight"); + setTimeout(() => el.classList.remove("dusk-msg-search-highlight"), 2000); + }); + } + // typing indicator names const typingNames = createMemo(() => { const typing = dmTypingPeers(); @@ -57,24 +87,49 @@ const DMChatArea: Component = (props) => { {/* dm header */}
-
+
- + {dm()!.display_name} - - {peerStatus()} -
+ +
+ setSearchOpen((v) => !v)} + > + + + + + + + + +
+ {/* search panel */} + + setSearchOpen(false)} + onJumpToMessage={handleJumpToMessage} + /> + + {/* conversation history */} 0} @@ -113,6 +168,15 @@ const DMChatArea: Component = (props) => { name: dm()!.display_name, status: onlinePeerIds().has(dm()!.peer_id) ? "Online" : "Offline", }, + ...(identity() + ? [ + { + id: identity()!.peer_id, + name: identity()!.display_name, + status: "Online" as const, + }, + ] + : []), ]} /> diff --git a/src/components/layout/DMSidebar.tsx b/src/components/layout/DMSidebar.tsx index 8e03abe..0aec985 100644 --- a/src/components/layout/DMSidebar.tsx +++ b/src/components/layout/DMSidebar.tsx @@ -1,6 +1,7 @@ import type { Component } from "solid-js"; import { For, Show, createSignal } from "solid-js"; import { MessageCircle, Search, X, Plus, Group, Users } from "lucide-solid"; +import { resolveMentionsPlainText } from "../../lib/mentions"; import { dmConversations, activeDMPeerId, @@ -170,7 +171,7 @@ const DMSidebar: Component = () => {

- {dm.last_message} + {resolveMentionsPlainText(dm.last_message!)}

diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts index b78fcaa..a07a214 100644 --- a/src/lib/mentions.ts +++ b/src/lib/mentions.ts @@ -1,5 +1,6 @@ import { members } from "../stores/members"; import { knownPeers } from "../stores/directory"; +import { identity } from "../stores/identity"; // matches mention tokens in the wire format: <@peer_id> or <@everyone> // peer ids are base58-encoded multihash strings (alphanumeric) @@ -28,12 +29,16 @@ export function isMentioned(content: string, peerId: string): boolean { return mentions.includes(peerId) || mentions.includes("everyone"); } -// resolve a peer id to a display name by checking community members -// first, then the global peer directory as a fallback +// resolve a peer id to a display name by checking the current user, +// community members, then the global peer directory as fallbacks export function resolveMentionName(peerId: string): string { if (peerId === "everyone") return "everyone"; - // check active community members first + // check if this is the current user's own peer id + const self = identity(); + if (self && self.peer_id === peerId) return self.display_name; + + // check active community members const memberList = members(); const member = memberList.find((m) => m.peer_id === peerId); if (member) return member.display_name; @@ -51,6 +56,15 @@ export function resolveMentionName(peerId: string): string { return peerId; } +// replace mention tokens in raw content with plain-text @name form +// used for notification bodies, message previews, and anywhere html isnt needed +export function resolveMentionsPlainText(content: string): string { + return content.replace( + new RegExp(MENTION_REGEX.source, "g"), + (_match, id: string) => `@${resolveMentionName(id)}`, + ); +} + // replace mention tokens in html-escaped content with rendered spans // must be called on already-escaped html (after escapeHtml) export function renderMentions(escapedHtml: string): string { diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index cf6ca42..ae65879 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -13,6 +13,7 @@ import { setActiveDM } from "../stores/dms"; import { generateAvatarSvg, avatarCacheKey } from "./avatar-svg"; import { cacheAvatarIcon } from "./tauri"; import type { ChatMessage } from "./types"; +import { resolveMentionsPlainText } from "./mentions"; // track if we have notification permission let permissionGranted = false; @@ -181,7 +182,7 @@ export async function notifyChannelMessage( await sendNotification( `${message.author_name} in ${communityName} > ${channelName}`, - message.content, + resolveMentionsPlainText(message.content), message.author_name, extra, ); @@ -213,7 +214,7 @@ export async function notifyMention( await sendNotification( `${message.author_name} mentioned you in ${communityName} > ${channelName}`, - message.content, + resolveMentionsPlainText(message.content), message.author_name, extra, ); @@ -242,7 +243,7 @@ export async function notifyDirectMessage( await sendNotification( `${message.author_name}`, - message.content, + resolveMentionsPlainText(message.content), message.author_name, extra, ); diff --git a/src/styles/app.css b/src/styles/app.css index 1192719..f40da32 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -598,6 +598,33 @@ body { background: rgba(255, 255, 255, 0.15); } +/* highlight messages that mention the current user */ +.dusk-msg-mentioned { + background: rgba(255, 79, 0, 0.08); + border-left: 2px solid var(--color-accent); +} + +.dusk-msg-mentioned:hover { + background: rgba(255, 79, 0, 0.12); +} + +/* flash highlight when jumping to a message from search */ +.dusk-msg-search-highlight { + animation: search-highlight-flash 2s ease-out forwards; +} + +@keyframes search-highlight-flash { + 0% { + background: rgba(255, 79, 0, 0.2); + } + 70% { + background: rgba(255, 79, 0, 0.1); + } + 100% { + background: transparent; + } +} + .dusk-msg-image-wrapper { white-space: normal; }