chore: checkpoint current local changes

This commit is contained in:
cloudwithax 2026-02-15 21:36:37 -05:00
parent 5a61b81811
commit a4e7e51f50
8 changed files with 556 additions and 24 deletions

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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;

View File

@ -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

View File

@ -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,

View File

@ -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>

View File

@ -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>

View File

@ -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 {

View File

@ -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,
); );

View File

@ -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;
} }