chore: checkpoint current local changes
This commit is contained in:
parent
5a61b81811
commit
a4e7e51f50
|
|
@ -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<DMSearchPanelProps> = (props) => {
|
||||||
|
const [query, setQuery] = createSignal("");
|
||||||
|
const [fromFilter, setFromFilter] = createSignal<FilterFrom>("anyone");
|
||||||
|
const [mediaFilter, setMediaFilter] = createSignal<MediaFilter | null>(null);
|
||||||
|
const [mentionsOnly, setMentionsOnly] = createSignal(false);
|
||||||
|
const [dateAfter, setDateAfter] = createSignal<string>("");
|
||||||
|
const [dateBefore, setDateBefore] = createSignal<string>("");
|
||||||
|
const [showFilters, setShowFilters] = createSignal(false);
|
||||||
|
|
||||||
|
// full conversation loaded from disk for searching
|
||||||
|
const [allMessages, setAllMessages] = createSignal<DirectMessage[]>([]);
|
||||||
|
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,
|
||||||
|
'<span class="text-orange font-medium">$1</span>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="border-b border-white/10 bg-gray-900 animate-fade-in">
|
||||||
|
{/* search input row */}
|
||||||
|
<div class="flex items-center gap-2 px-4 py-2">
|
||||||
|
<Show
|
||||||
|
when={!loading()}
|
||||||
|
fallback={
|
||||||
|
<Loader2 size={16} class="shrink-0 text-white/40 animate-spin" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Search size={16} class="shrink-0 text-white/40" />
|
||||||
|
</Show>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={loading() ? "loading messages..." : "search messages..."}
|
||||||
|
value={query()}
|
||||||
|
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
disabled={loading()}
|
||||||
|
class="flex-1 bg-transparent text-[14px] text-white placeholder:text-white/30 outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<Show when={!loading() && (query() || hasActiveFilters())}>
|
||||||
|
<span class="text-[12px] font-mono text-white/40 shrink-0">
|
||||||
|
{filteredMessages().length} result{filteredMessages().length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 p-1 text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||||
|
onClick={() => setShowFilters((v) => !v)}
|
||||||
|
aria-label="Toggle filters"
|
||||||
|
>
|
||||||
|
<Show when={showFilters()} fallback={<ChevronDown size={16} />}>
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 p-1 text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||||
|
onClick={props.onClose}
|
||||||
|
aria-label="Close search"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* filter chips */}
|
||||||
|
<Show when={showFilters()}>
|
||||||
|
<div class="px-4 pb-3 flex flex-col gap-2 animate-fade-in">
|
||||||
|
{/* from filter */}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-[11px] font-mono text-white/30 uppercase tracking-wider w-12 shrink-0">
|
||||||
|
from
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<FilterChip
|
||||||
|
active={fromFilter() === "anyone"}
|
||||||
|
onClick={() => setFromFilter("anyone")}
|
||||||
|
icon={<User size={12} />}
|
||||||
|
label="anyone"
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
active={fromFilter() === "me"}
|
||||||
|
onClick={() => setFromFilter("me")}
|
||||||
|
icon={<User size={12} />}
|
||||||
|
label="me"
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
active={fromFilter() === "them"}
|
||||||
|
onClick={() => setFromFilter("them")}
|
||||||
|
icon={<User size={12} />}
|
||||||
|
label={props.peerName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* media type filter */}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-[11px] font-mono text-white/30 uppercase tracking-wider w-12 shrink-0">
|
||||||
|
type
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-1 flex-wrap">
|
||||||
|
<FilterChip
|
||||||
|
active={mediaFilter() === "images"}
|
||||||
|
onClick={() =>
|
||||||
|
setMediaFilter((v) => (v === "images" ? null : "images"))
|
||||||
|
}
|
||||||
|
icon={<Image size={12} />}
|
||||||
|
label="images"
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
active={mediaFilter() === "videos"}
|
||||||
|
onClick={() =>
|
||||||
|
setMediaFilter((v) => (v === "videos" ? null : "videos"))
|
||||||
|
}
|
||||||
|
icon={<FileText size={12} />}
|
||||||
|
label="videos"
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
active={mediaFilter() === "links"}
|
||||||
|
onClick={() =>
|
||||||
|
setMediaFilter((v) => (v === "links" ? null : "links"))
|
||||||
|
}
|
||||||
|
icon={<Link size={12} />}
|
||||||
|
label="links"
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
active={mediaFilter() === "files"}
|
||||||
|
onClick={() =>
|
||||||
|
setMediaFilter((v) => (v === "files" ? null : "files"))
|
||||||
|
}
|
||||||
|
icon={<FileText size={12} />}
|
||||||
|
label="files"
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
active={mentionsOnly()}
|
||||||
|
onClick={() => setMentionsOnly((v) => !v)}
|
||||||
|
icon={<AtSign size={12} />}
|
||||||
|
label="mentions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* date range */}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-[11px] font-mono text-white/30 uppercase tracking-wider w-12 shrink-0">
|
||||||
|
date
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Calendar size={12} class="text-white/30" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateAfter()}
|
||||||
|
onInput={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-white/20">to</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateBefore()}
|
||||||
|
onInput={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* clear all */}
|
||||||
|
<Show when={hasActiveFilters()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="self-start text-[11px] font-mono text-orange hover:text-orange-hover transition-colors duration-200 cursor-pointer"
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
>
|
||||||
|
clear all filters
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* search results */}
|
||||||
|
<Show when={!loading() && (query() || hasActiveFilters())}>
|
||||||
|
<div class="max-h-[320px] overflow-y-auto border-t border-white/5">
|
||||||
|
<Show
|
||||||
|
when={filteredMessages().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="px-4 py-6 text-center text-[13px] text-white/30">
|
||||||
|
no messages found
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={filteredMessages()}>
|
||||||
|
{(msg) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-4 py-2 flex items-start gap-3 text-left hover:bg-gray-800 transition-colors duration-200 cursor-pointer group"
|
||||||
|
onClick={() => handleJump(msg.id)}
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-[13px] font-medium text-white/80 group-hover:text-white truncate">
|
||||||
|
{msg.author_name}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] font-mono text-white/30">
|
||||||
|
{formatDaySeparator(msg.timestamp)} {formatTime(msg.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-[13px] text-white/50 truncate mt-0.5"
|
||||||
|
innerHTML={highlightMatch(msg.content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// reusable filter chip
|
||||||
|
interface FilterChipProps {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
icon: any;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterChip: Component<FilterChipProps> = (props) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-mono transition-colors duration-200 cursor-pointer ${
|
||||||
|
props.active
|
||||||
|
? "bg-orange text-white"
|
||||||
|
: "bg-gray-800 text-white/50 hover:text-white hover:bg-gray-800/80"
|
||||||
|
}`}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
{props.icon}
|
||||||
|
{props.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// utilities
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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;
|
||||||
|
|
@ -7,6 +7,7 @@ import type { MediaKind } from "../../lib/markdown";
|
||||||
import { removeMessage } from "../../stores/messages";
|
import { removeMessage } from "../../stores/messages";
|
||||||
import { activeCommunityId } from "../../stores/communities";
|
import { activeCommunityId } from "../../stores/communities";
|
||||||
import { identity } from "../../stores/identity";
|
import { identity } from "../../stores/identity";
|
||||||
|
import { isMentioned } from "../../lib/mentions";
|
||||||
import Avatar from "../common/Avatar";
|
import Avatar from "../common/Avatar";
|
||||||
import Lightbox from "../common/Lightbox";
|
import Lightbox from "../common/Lightbox";
|
||||||
import { openProfileCard } from "../../stores/ui";
|
import { openProfileCard } from "../../stores/ui";
|
||||||
|
|
@ -36,6 +37,13 @@ const Message: Component<MessageProps> = (props) => {
|
||||||
getStandaloneMediaKind(props.message.content),
|
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 [lightboxOpen, setLightboxOpen] = createSignal(false);
|
||||||
|
|
||||||
const isOwner = () => {
|
const isOwner = () => {
|
||||||
|
|
@ -106,9 +114,10 @@ const Message: Component<MessageProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`flex items-start gap-4 hover:bg-gray-900 transition-colors duration-200 px-4 ${
|
data-message-id={props.message.id}
|
||||||
props.isFirstInGroup ? "pt-2" : "pt-0.5"
|
class={`flex items-start gap-4 transition-colors duration-200 px-4 ${
|
||||||
} ${props.isLastInGroup ? "pb-2" : "pb-0.5"}`}
|
mentionsMe() ? "dusk-msg-mentioned" : "hover:bg-gray-900"
|
||||||
|
} ${props.isFirstInGroup ? "pt-2" : "pt-0.5"} ${props.isLastInGroup ? "pb-2" : "pb-0.5"}`}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@ const MessageInput: Component<MessageInputProps> = (props) => {
|
||||||
if (props.mentionPeers) {
|
if (props.mentionPeers) {
|
||||||
const items: MentionItem[] = [];
|
const items: MentionItem[] = [];
|
||||||
for (const peer of props.mentionPeers) {
|
for (const peer of props.mentionPeers) {
|
||||||
if (currentUser && peer.id === currentUser.peer_id) continue;
|
|
||||||
if (!q || peer.name.toLowerCase().includes(q)) {
|
if (!q || peer.name.toLowerCase().includes(q)) {
|
||||||
items.push({
|
items.push({
|
||||||
id: peer.id,
|
id: peer.id,
|
||||||
|
|
@ -97,7 +96,6 @@ const MessageInput: Component<MessageInputProps> = (props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const member of memberList) {
|
for (const member of memberList) {
|
||||||
if (currentUser && member.peer_id === currentUser.peer_id) continue;
|
|
||||||
if (!q || member.display_name.toLowerCase().includes(q)) {
|
if (!q || member.display_name.toLowerCase().includes(q)) {
|
||||||
items.push({
|
items.push({
|
||||||
id: member.peer_id,
|
id: member.peer_id,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import { Show, createMemo } from "solid-js";
|
import { Show, createMemo, createSignal } from "solid-js";
|
||||||
import { AtSign } from "lucide-solid";
|
import { Phone, Pin, Search } from "lucide-solid";
|
||||||
import {
|
import {
|
||||||
activeDMConversation,
|
activeDMConversation,
|
||||||
dmMessages,
|
dmMessages,
|
||||||
dmTypingPeers,
|
dmTypingPeers,
|
||||||
|
setDMMessages,
|
||||||
} from "../../stores/dms";
|
} from "../../stores/dms";
|
||||||
import { onlinePeerIds } from "../../stores/members";
|
import { onlinePeerIds } from "../../stores/members";
|
||||||
|
import { identity } from "../../stores/identity";
|
||||||
import MessageList from "../chat/MessageList";
|
import MessageList from "../chat/MessageList";
|
||||||
import MessageInput from "../chat/MessageInput";
|
import MessageInput from "../chat/MessageInput";
|
||||||
import TypingIndicator from "../chat/TypingIndicator";
|
import TypingIndicator from "../chat/TypingIndicator";
|
||||||
|
import DMSearchPanel from "../chat/DMSearchPanel";
|
||||||
import Avatar from "../common/Avatar";
|
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 {
|
interface DMChatAreaProps {
|
||||||
onSendDM: (content: string) => void;
|
onSendDM: (content: string) => void;
|
||||||
|
|
@ -19,6 +23,7 @@ interface DMChatAreaProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
||||||
|
const [searchOpen, setSearchOpen] = createSignal(false);
|
||||||
const dm = () => activeDMConversation();
|
const dm = () => activeDMConversation();
|
||||||
|
|
||||||
// adapt DirectMessage[] to ChatMessage[] so the existing MessageList works
|
// adapt DirectMessage[] to ChatMessage[] so the existing MessageList works
|
||||||
|
|
@ -42,6 +47,31 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
||||||
return "offline";
|
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
|
// typing indicator names
|
||||||
const typingNames = createMemo(() => {
|
const typingNames = createMemo(() => {
|
||||||
const typing = dmTypingPeers();
|
const typing = dmTypingPeers();
|
||||||
|
|
@ -57,23 +87,48 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
||||||
{/* dm header */}
|
{/* dm header */}
|
||||||
<div class="h-15 shrink-0 border-b border-white/10 flex flex-col justify-end">
|
<div class="h-15 shrink-0 border-b border-white/10 flex flex-col justify-end">
|
||||||
<div class="h-12 flex items-center justify-between px-4">
|
<div class="h-12 flex items-center justify-between px-4">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<Show when={dm()}>
|
<Show when={dm()}>
|
||||||
<AtSign size={20} class="shrink-0 text-white/40" />
|
<Avatar
|
||||||
|
name={dm()!.display_name}
|
||||||
|
size="sm"
|
||||||
|
status={peerStatus() === "online" ? "Online" : "Offline"}
|
||||||
|
showStatus
|
||||||
|
/>
|
||||||
<span class="text-[16px] font-bold text-white truncate">
|
<span class="text-[16px] font-bold text-white truncate">
|
||||||
{dm()!.display_name}
|
{dm()!.display_name}
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
class={`text-[12px] font-mono ml-1 ${
|
|
||||||
peerStatus() === "online" ? "text-success" : "text-white/30"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{peerStatus()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<IconButton
|
||||||
|
label="Search messages"
|
||||||
|
active={searchOpen()}
|
||||||
|
onClick={() => setSearchOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<Search size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label="Start call">
|
||||||
|
<Phone size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label="Pinned messages">
|
||||||
|
<Pin size={18} />
|
||||||
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* search panel */}
|
||||||
|
<Show when={searchOpen() && dm()}>
|
||||||
|
<DMSearchPanel
|
||||||
|
peerId={dm()!.peer_id}
|
||||||
|
myPeerId={identity()?.peer_id ?? ""}
|
||||||
|
peerName={dm()!.display_name}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
onJumpToMessage={handleJumpToMessage}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* conversation history */}
|
{/* conversation history */}
|
||||||
<Show
|
<Show
|
||||||
|
|
@ -113,6 +168,15 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
||||||
name: dm()!.display_name,
|
name: dm()!.display_name,
|
||||||
status: onlinePeerIds().has(dm()!.peer_id) ? "Online" : "Offline",
|
status: onlinePeerIds().has(dm()!.peer_id) ? "Online" : "Offline",
|
||||||
},
|
},
|
||||||
|
...(identity()
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: identity()!.peer_id,
|
||||||
|
name: identity()!.display_name,
|
||||||
|
status: "Online" as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import { For, Show, createSignal } from "solid-js";
|
import { For, Show, createSignal } from "solid-js";
|
||||||
import { MessageCircle, Search, X, Plus, Group, Users } from "lucide-solid";
|
import { MessageCircle, Search, X, Plus, Group, Users } from "lucide-solid";
|
||||||
|
import { resolveMentionsPlainText } from "../../lib/mentions";
|
||||||
import {
|
import {
|
||||||
dmConversations,
|
dmConversations,
|
||||||
activeDMPeerId,
|
activeDMPeerId,
|
||||||
|
|
@ -170,7 +171,7 @@ const DMSidebar: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
<Show when={dm.last_message}>
|
<Show when={dm.last_message}>
|
||||||
<p class="text-[12px] text-white/40 truncate mt-0.5">
|
<p class="text-[12px] text-white/40 truncate mt-0.5">
|
||||||
{dm.last_message}
|
{resolveMentionsPlainText(dm.last_message!)}
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { members } from "../stores/members";
|
import { members } from "../stores/members";
|
||||||
import { knownPeers } from "../stores/directory";
|
import { knownPeers } from "../stores/directory";
|
||||||
|
import { identity } from "../stores/identity";
|
||||||
|
|
||||||
// matches mention tokens in the wire format: <@peer_id> or <@everyone>
|
// matches mention tokens in the wire format: <@peer_id> or <@everyone>
|
||||||
// peer ids are base58-encoded multihash strings (alphanumeric)
|
// 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");
|
return mentions.includes(peerId) || mentions.includes("everyone");
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve a peer id to a display name by checking community members
|
// resolve a peer id to a display name by checking the current user,
|
||||||
// first, then the global peer directory as a fallback
|
// community members, then the global peer directory as fallbacks
|
||||||
export function resolveMentionName(peerId: string): string {
|
export function resolveMentionName(peerId: string): string {
|
||||||
if (peerId === "everyone") return "everyone";
|
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 memberList = members();
|
||||||
const member = memberList.find((m) => m.peer_id === peerId);
|
const member = memberList.find((m) => m.peer_id === peerId);
|
||||||
if (member) return member.display_name;
|
if (member) return member.display_name;
|
||||||
|
|
@ -51,6 +56,15 @@ export function resolveMentionName(peerId: string): string {
|
||||||
return peerId;
|
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
|
// replace mention tokens in html-escaped content with rendered spans
|
||||||
// must be called on already-escaped html (after escapeHtml)
|
// must be called on already-escaped html (after escapeHtml)
|
||||||
export function renderMentions(escapedHtml: string): string {
|
export function renderMentions(escapedHtml: string): string {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { setActiveDM } from "../stores/dms";
|
||||||
import { generateAvatarSvg, avatarCacheKey } from "./avatar-svg";
|
import { generateAvatarSvg, avatarCacheKey } from "./avatar-svg";
|
||||||
import { cacheAvatarIcon } from "./tauri";
|
import { cacheAvatarIcon } from "./tauri";
|
||||||
import type { ChatMessage } from "./types";
|
import type { ChatMessage } from "./types";
|
||||||
|
import { resolveMentionsPlainText } from "./mentions";
|
||||||
|
|
||||||
// track if we have notification permission
|
// track if we have notification permission
|
||||||
let permissionGranted = false;
|
let permissionGranted = false;
|
||||||
|
|
@ -181,7 +182,7 @@ export async function notifyChannelMessage(
|
||||||
|
|
||||||
await sendNotification(
|
await sendNotification(
|
||||||
`${message.author_name} in ${communityName} > ${channelName}`,
|
`${message.author_name} in ${communityName} > ${channelName}`,
|
||||||
message.content,
|
resolveMentionsPlainText(message.content),
|
||||||
message.author_name,
|
message.author_name,
|
||||||
extra,
|
extra,
|
||||||
);
|
);
|
||||||
|
|
@ -213,7 +214,7 @@ export async function notifyMention(
|
||||||
|
|
||||||
await sendNotification(
|
await sendNotification(
|
||||||
`${message.author_name} mentioned you in ${communityName} > ${channelName}`,
|
`${message.author_name} mentioned you in ${communityName} > ${channelName}`,
|
||||||
message.content,
|
resolveMentionsPlainText(message.content),
|
||||||
message.author_name,
|
message.author_name,
|
||||||
extra,
|
extra,
|
||||||
);
|
);
|
||||||
|
|
@ -242,7 +243,7 @@ export async function notifyDirectMessage(
|
||||||
|
|
||||||
await sendNotification(
|
await sendNotification(
|
||||||
`${message.author_name}`,
|
`${message.author_name}`,
|
||||||
message.content,
|
resolveMentionsPlainText(message.content),
|
||||||
message.author_name,
|
message.author_name,
|
||||||
extra,
|
extra,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,33 @@ body {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
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 {
|
.dusk-msg-image-wrapper {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue