feat: add mention functionality and improve notification system
- Implemented mention support in DMChatArea, allowing users to mention peers in messages. - Enhanced markdown rendering to support mentions, converting <@peer_id> tokens to display names. - Updated notification system to handle navigation on notification clicks, directing users to relevant channels or DMs. - Introduced caching for avatar icons to reduce redundant disk writes. - Added a MentionList component for displaying mention suggestions. - Improved styling for mentions in both chat and editor contexts. - Persisted last viewed channels per community across app restarts.
This commit is contained in:
parent
1626a40f32
commit
5a61b81811
6
bun.lock
6
bun.lock
|
|
@ -12,9 +12,11 @@
|
|||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"@tiptap/core": "^2.12.0",
|
||||
"@tiptap/extension-mention": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.12.0",
|
||||
"lucide-solid": "^0.469.0",
|
||||
"motion": "^12.0.0",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
|
|
@ -308,6 +310,8 @@
|
|||
|
||||
"@tiptap/extension-list-item": ["@tiptap/extension-list-item@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg=="],
|
||||
|
||||
"@tiptap/extension-mention": ["@tiptap/extension-mention@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0", "@tiptap/suggestion": "^2.7.0" } }, "sha512-uHxVf8RISscb4xgCEJmDSNcFQmzlBTKJh7fp2QAXWIF4Xtrg3zD08PIXUvvHapoluGD9OdBugW4YCu1PJ3xWNw=="],
|
||||
|
||||
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w=="],
|
||||
|
||||
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A=="],
|
||||
|
|
@ -324,6 +328,8 @@
|
|||
|
||||
"@tiptap/starter-kit": ["@tiptap/starter-kit@2.27.2", "", { "dependencies": { "@tiptap/core": "^2.27.2", "@tiptap/extension-blockquote": "^2.27.2", "@tiptap/extension-bold": "^2.27.2", "@tiptap/extension-bullet-list": "^2.27.2", "@tiptap/extension-code": "^2.27.2", "@tiptap/extension-code-block": "^2.27.2", "@tiptap/extension-document": "^2.27.2", "@tiptap/extension-dropcursor": "^2.27.2", "@tiptap/extension-gapcursor": "^2.27.2", "@tiptap/extension-hard-break": "^2.27.2", "@tiptap/extension-heading": "^2.27.2", "@tiptap/extension-history": "^2.27.2", "@tiptap/extension-horizontal-rule": "^2.27.2", "@tiptap/extension-italic": "^2.27.2", "@tiptap/extension-list-item": "^2.27.2", "@tiptap/extension-ordered-list": "^2.27.2", "@tiptap/extension-paragraph": "^2.27.2", "@tiptap/extension-strike": "^2.27.2", "@tiptap/extension-text": "^2.27.2", "@tiptap/extension-text-style": "^2.27.2", "@tiptap/pm": "^2.27.2" } }, "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw=="],
|
||||
|
||||
"@tiptap/suggestion": ["@tiptap/suggestion@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-dQyvCIg0hcAVeh4fCIVCxogvbp+bF+GpbUb8sNlgnGrmHXnapGxzkvrlHnvneXZxLk/j7CxmBPKJNnm4Pbx4zw=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@
|
|||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"@tiptap/core": "^2.12.0",
|
||||
"@tiptap/extension-mention": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.12.0",
|
||||
"lucide-solid": "^0.469.0",
|
||||
"motion": "^12.0.0",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tauri::State;
|
||||
|
|
@ -404,3 +405,28 @@ pub async fn reset_identity(state: State<'_, AppState>) -> Result<(), String> {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// write an svg string to a cache directory and return the absolute path
|
||||
// used for notification icons so the os can display the user's avatar
|
||||
#[tauri::command]
|
||||
pub async fn cache_avatar_icon(
|
||||
cache_key: String,
|
||||
svg_content: String,
|
||||
) -> Result<String, String> {
|
||||
let cache_dir = std::env::temp_dir().join("dusk-avatars");
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| format!("failed to create avatar cache dir: {}", e))?;
|
||||
|
||||
let file_path: PathBuf = cache_dir.join(format!("{}.svg", cache_key));
|
||||
|
||||
// skip write if already cached with the same key
|
||||
if !file_path.exists() {
|
||||
std::fs::write(&file_path, svg_content)
|
||||
.map_err(|e| format!("failed to write avatar svg: {}", e))?;
|
||||
}
|
||||
|
||||
file_path
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "invalid path encoding".to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ pub fn run() {
|
|||
commands::identity::discover_global_peers,
|
||||
commands::identity::set_relay_address,
|
||||
commands::identity::reset_identity,
|
||||
commands::identity::cache_avatar_icon,
|
||||
commands::chat::send_message,
|
||||
commands::chat::get_messages,
|
||||
commands::chat::send_typing,
|
||||
|
|
|
|||
60
src/App.tsx
60
src/App.tsx
|
|
@ -49,6 +49,7 @@ import {
|
|||
addCategory,
|
||||
categories,
|
||||
channels,
|
||||
getLastChannel,
|
||||
} from "./stores/channels";
|
||||
import {
|
||||
addMessage,
|
||||
|
|
@ -111,9 +112,11 @@ import { resetSettings } from "./stores/settings";
|
|||
import {
|
||||
initNotifications,
|
||||
notifyChannelMessage,
|
||||
notifyMention,
|
||||
notifyDirectMessage,
|
||||
isWindowFocused,
|
||||
} from "./lib/notifications";
|
||||
import { isMentioned } from "./lib/mentions";
|
||||
|
||||
const App: Component = () => {
|
||||
let cleanupResize: (() => void) | undefined;
|
||||
|
|
@ -162,7 +165,9 @@ const App: Component = () => {
|
|||
setCategories(cats);
|
||||
|
||||
if (chs.length > 0) {
|
||||
setActiveChannel(chs[0].id);
|
||||
const last = getLastChannel(communityId);
|
||||
const restored = last && chs.some((c) => c.id === last);
|
||||
setActiveChannel(restored ? last : chs[0].id);
|
||||
} else {
|
||||
setActiveChannel(null);
|
||||
clearMessages();
|
||||
|
|
@ -319,11 +324,7 @@ const App: Component = () => {
|
|||
// from the backend will set the accurate state once peers are found.
|
||||
setNodeStatus("running");
|
||||
|
||||
// the createEffect on activeCommunityId handles loading channels,
|
||||
// messages, and members reactively when this is set
|
||||
if (communities.length > 0) {
|
||||
setActiveCommunity(communities[0].id);
|
||||
}
|
||||
// start at the home screen, user can select a community from the sidebar
|
||||
} catch (e) {
|
||||
console.error("initialization error:", e);
|
||||
setNodeStatus("error");
|
||||
|
|
@ -336,19 +337,39 @@ const App: Component = () => {
|
|||
const msg = event.payload;
|
||||
const currentChannelId = activeChannelId();
|
||||
const currentCommunity = activeCommunity();
|
||||
const currentPeerId = identity()?.peer_id;
|
||||
|
||||
// add to store if this is the active channel
|
||||
if (msg.channel_id === currentChannelId) {
|
||||
addMessage(msg);
|
||||
}
|
||||
|
||||
// send notification if window is not focused or this is not the active channel
|
||||
if (!isWindowFocused() || msg.channel_id !== currentChannelId) {
|
||||
// check if the current user is mentioned in this message
|
||||
const mentioned =
|
||||
currentPeerId && isMentioned(msg.content, currentPeerId);
|
||||
|
||||
if (mentioned && msg.channel_id !== currentChannelId) {
|
||||
// mention notifications fire even when the window is focused,
|
||||
// as long as it isnt the active channel
|
||||
const channelList = channels();
|
||||
const channel = channelList.find((c) => c.id === msg.channel_id);
|
||||
const channelName = channel?.name ?? "unknown channel";
|
||||
const communityName = currentCommunity?.name ?? "unknown community";
|
||||
notifyChannelMessage(msg, channelName, communityName);
|
||||
const communityId =
|
||||
channel?.community_id ?? currentCommunity?.id ?? "";
|
||||
notifyMention(msg, channelName, communityName, communityId);
|
||||
} else if (
|
||||
!isWindowFocused() ||
|
||||
msg.channel_id !== currentChannelId
|
||||
) {
|
||||
// regular notification for non-mention messages
|
||||
const channelList = channels();
|
||||
const channel = channelList.find((c) => c.id === msg.channel_id);
|
||||
const channelName = channel?.name ?? "unknown channel";
|
||||
const communityName = currentCommunity?.name ?? "unknown community";
|
||||
const communityId =
|
||||
channel?.community_id ?? currentCommunity?.id ?? "";
|
||||
notifyChannelMessage(msg, channelName, communityName, communityId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -417,15 +438,18 @@ const App: Component = () => {
|
|||
// send notification if window is not focused or this is not the active dm
|
||||
const currentDMPeer = activeDMPeerId();
|
||||
if (!isWindowFocused() || dm.from_peer !== currentDMPeer) {
|
||||
notifyDirectMessage({
|
||||
id: dm.id,
|
||||
channel_id: "",
|
||||
author_id: dm.from_peer,
|
||||
author_name: dm.from_display_name,
|
||||
content: dm.content,
|
||||
timestamp: dm.timestamp,
|
||||
edited: false,
|
||||
});
|
||||
notifyDirectMessage(
|
||||
{
|
||||
id: dm.id,
|
||||
channel_id: "",
|
||||
author_id: dm.from_peer,
|
||||
author_name: dm.from_display_name,
|
||||
content: dm.content,
|
||||
timestamp: dm.timestamp,
|
||||
edited: false,
|
||||
},
|
||||
dm.from_peer,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { For, Show, createEffect } from "solid-js";
|
||||
import Avatar from "../common/Avatar";
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
isEveryone?: boolean;
|
||||
status?: "Online" | "Idle" | "Dnd" | "Offline";
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
items: MentionItem[];
|
||||
selectedIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
}
|
||||
|
||||
const MentionList: Component<MentionListProps> = (props) => {
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
// scroll the selected item into view when selection changes
|
||||
createEffect(() => {
|
||||
const index = props.selectedIndex;
|
||||
if (!containerRef) return;
|
||||
|
||||
const items = containerRef.querySelectorAll("[data-mention-item]");
|
||||
const selected = items[index];
|
||||
if (selected) {
|
||||
selected.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="dusk-mention-list bg-[#0a0a0a] border border-white/20 py-1 max-h-[264px] overflow-y-auto w-[280px] shadow-[0_8px_32px_rgba(0,0,0,0.6)] animate-fade-in"
|
||||
>
|
||||
<Show
|
||||
when={props.items.length > 0}
|
||||
fallback={
|
||||
<div class="px-3 py-2 text-[13px] text-white/40">
|
||||
no matching members
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
data-mention-item
|
||||
class={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors duration-200 cursor-pointer ${
|
||||
index() === props.selectedIndex
|
||||
? "bg-white/10"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
// prevent editor blur
|
||||
e.preventDefault();
|
||||
props.onSelect(item);
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!item.isEveryone}
|
||||
fallback={
|
||||
<div class="w-8 h-8 shrink-0 flex items-center justify-center bg-orange/20 text-orange text-[14px] font-bold rounded-full">
|
||||
@
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
name={item.label}
|
||||
size="sm"
|
||||
status={item.status}
|
||||
showStatus={true}
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-[14px] font-medium text-white truncate block">
|
||||
{item.isEveryone ? "@everyone" : item.label}
|
||||
</span>
|
||||
<Show when={item.isEveryone}>
|
||||
<span class="text-[11px] text-white/40">
|
||||
notify all members
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionList;
|
||||
|
|
@ -85,6 +85,20 @@ const Message: Component<MessageProps> = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
// open profile card when clicking a mention span
|
||||
function handleContentClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains("dusk-mention") && target.dataset.peerId) {
|
||||
e.stopPropagation();
|
||||
openProfileCard({
|
||||
peerId: target.dataset.peerId,
|
||||
displayName: target.textContent?.replace(/^@/, "") ?? "",
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// close context menu on click outside
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", closeContextMenu);
|
||||
|
|
@ -139,7 +153,11 @@ const Message: Component<MessageProps> = (props) => {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<div class="dusk-msg-content" innerHTML={renderedContent()} />
|
||||
<div
|
||||
class="dusk-msg-content"
|
||||
innerHTML={renderedContent()}
|
||||
onClick={handleContentClick}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,28 @@ import { createEditor, EditorContent } from "tiptap-solid";
|
|||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { tiptapToMarkdown } from "../../lib/markdown";
|
||||
import { members } from "../../stores/members";
|
||||
import { identity } from "../../stores/identity";
|
||||
import EmojiPicker from "./EmojiPicker";
|
||||
import GifPicker from "./GifPicker";
|
||||
import MentionList from "./MentionList";
|
||||
import type { MentionItem } from "./MentionList";
|
||||
|
||||
interface MentionPeer {
|
||||
id: string;
|
||||
name: string;
|
||||
status?: "Online" | "Idle" | "Dnd" | "Offline";
|
||||
}
|
||||
|
||||
interface MessageInputProps {
|
||||
channelName: string;
|
||||
onSend: (content: string) => void;
|
||||
onTyping?: () => void;
|
||||
// when provided, uses these peers for mention autocomplete instead of community members.
|
||||
// used in DM context where the members store is irrelevant
|
||||
mentionPeers?: MentionPeer[];
|
||||
}
|
||||
|
||||
const MessageInput: Component<MessageInputProps> = (props) => {
|
||||
|
|
@ -21,12 +35,26 @@ const MessageInput: Component<MessageInputProps> = (props) => {
|
|||
const [showEmojiPicker, setShowEmojiPicker] = createSignal(false);
|
||||
const [showGifPicker, setShowGifPicker] = createSignal(false);
|
||||
|
||||
// mention autocomplete state driven by tiptap suggestion plugin
|
||||
const [showMentionList, setShowMentionList] = createSignal(false);
|
||||
const [mentionItems, setMentionItems] = createSignal<MentionItem[]>([]);
|
||||
const [mentionIndex, setMentionIndex] = createSignal(0);
|
||||
const [mentionClientRect, setMentionClientRect] = createSignal<
|
||||
(() => DOMRect | null) | null
|
||||
>(null);
|
||||
|
||||
// stashed so we can call command() from MentionList selection
|
||||
let mentionCommand: ((props: { id: string; label: string }) => void) | null =
|
||||
null;
|
||||
|
||||
// custom extension to handle enter-to-send behavior
|
||||
const SendOnEnter = Extension.create({
|
||||
name: "sendOnEnter",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: () => {
|
||||
// dont send if mention list is open - enter selects a mention instead
|
||||
if (showMentionList()) return false;
|
||||
handleSubmit();
|
||||
return true;
|
||||
},
|
||||
|
|
@ -34,6 +62,54 @@ const MessageInput: Component<MessageInputProps> = (props) => {
|
|||
},
|
||||
});
|
||||
|
||||
// build the mention items list from community members or dm peers
|
||||
function getMentionItems(query: string): MentionItem[] {
|
||||
const q = query.toLowerCase();
|
||||
const currentUser = identity();
|
||||
|
||||
// dm context uses the explicit peer list passed via 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,
|
||||
label: peer.name,
|
||||
status: peer.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.slice(0, 10);
|
||||
}
|
||||
|
||||
// community context uses the global members store
|
||||
const memberList = members();
|
||||
const items: MentionItem[] = [];
|
||||
|
||||
// everyone option only makes sense in community channels
|
||||
if (!q || "everyone".includes(q)) {
|
||||
items.push({
|
||||
id: "everyone",
|
||||
label: "everyone",
|
||||
isEveryone: true,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
label: member.display_name,
|
||||
status: member.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.slice(0, 10);
|
||||
}
|
||||
|
||||
const editor = createEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
|
|
@ -50,6 +126,75 @@ const MessageInput: Component<MessageInputProps> = (props) => {
|
|||
Placeholder.configure({
|
||||
placeholder: `message #${props.channelName}`,
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "dusk-mention",
|
||||
},
|
||||
renderText({ node }) {
|
||||
return `@${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
suggestion: {
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
items: ({ query }) => getMentionItems(query),
|
||||
render: () => {
|
||||
return {
|
||||
onStart: (suggestionProps) => {
|
||||
setMentionItems(suggestionProps.items);
|
||||
setMentionIndex(0);
|
||||
setMentionClientRect(() => suggestionProps.clientRect ?? null);
|
||||
mentionCommand = suggestionProps.command;
|
||||
setShowMentionList(true);
|
||||
},
|
||||
onUpdate: (suggestionProps) => {
|
||||
setMentionItems(suggestionProps.items);
|
||||
// clamp index if items shrunk
|
||||
setMentionIndex((prev) =>
|
||||
Math.min(prev, Math.max(0, suggestionProps.items.length - 1)),
|
||||
);
|
||||
setMentionClientRect(() => suggestionProps.clientRect ?? null);
|
||||
mentionCommand = suggestionProps.command;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
setMentionIndex((prev) =>
|
||||
prev >= mentionItems().length - 1 ? 0 : prev + 1,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
setMentionIndex((prev) =>
|
||||
prev <= 0 ? mentionItems().length - 1 : prev - 1,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
const items = mentionItems();
|
||||
const idx = mentionIndex();
|
||||
if (items[idx] && mentionCommand) {
|
||||
mentionCommand({
|
||||
id: items[idx].id,
|
||||
label: items[idx].label,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setShowMentionList(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
setShowMentionList(false);
|
||||
setMentionItems([]);
|
||||
setMentionIndex(0);
|
||||
mentionCommand = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
SendOnEnter,
|
||||
],
|
||||
editorProps: {
|
||||
|
|
@ -100,8 +245,37 @@ const MessageInput: Component<MessageInputProps> = (props) => {
|
|||
setShowGifPicker((v) => !v);
|
||||
}
|
||||
|
||||
function handleMentionSelect(item: MentionItem) {
|
||||
if (mentionCommand) {
|
||||
mentionCommand({ id: item.id, label: item.label });
|
||||
}
|
||||
}
|
||||
|
||||
// compute mention list position relative to the input container
|
||||
function mentionListStyle(): string {
|
||||
const clientRectFn = mentionClientRect();
|
||||
if (!clientRectFn) return "";
|
||||
|
||||
const rect = clientRectFn();
|
||||
if (!rect) return "";
|
||||
|
||||
// position the dropdown above the cursor
|
||||
return `position: fixed; left: ${rect.left}px; bottom: ${window.innerHeight - rect.top + 4}px; z-index: 50;`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="shrink-0 px-4 py-2 bg-black border-t border-white/10 relative">
|
||||
{/* mention autocomplete positioned at cursor */}
|
||||
<Show when={showMentionList() && mentionItems().length > 0}>
|
||||
<div style={mentionListStyle()}>
|
||||
<MentionList
|
||||
items={mentionItems()}
|
||||
selectedIndex={mentionIndex()}
|
||||
onSelect={handleMentionSelect}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* picker popovers positioned above the input */}
|
||||
<Show when={showEmojiPicker()}>
|
||||
<div class="absolute bottom-full right-4 mb-2 z-50">
|
||||
|
|
|
|||
|
|
@ -107,6 +107,13 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
|||
channelName={dm()!.display_name}
|
||||
onSend={props.onSendDM}
|
||||
onTyping={props.onTyping}
|
||||
mentionPeers={[
|
||||
{
|
||||
id: dm()!.peer_id,
|
||||
name: dm()!.display_name,
|
||||
status: onlinePeerIds().has(dm()!.peer_id) ? "Online" : "Offline",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
// generates standalone svg strings for notification icons
|
||||
// mirrors the deterministic avatar logic from Avatar.tsx
|
||||
|
||||
// same hash function used in Avatar.tsx
|
||||
function stringHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash &= hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
"#f43f5e",
|
||||
"#ec4899",
|
||||
"#d946ef",
|
||||
"#a855f7",
|
||||
"#8b5cf6",
|
||||
"#6366f1",
|
||||
"#3b82f6",
|
||||
"#0ea5e9",
|
||||
"#06b6d4",
|
||||
"#14b8a6",
|
||||
"#10b981",
|
||||
"#22c55e",
|
||||
"#84cc16",
|
||||
"#eab308",
|
||||
"#f59e0b",
|
||||
"#f97316",
|
||||
"#ef4444",
|
||||
];
|
||||
|
||||
// each face is a tuple of [viewBoxWidth, viewBoxHeight, pathData[]]
|
||||
// extracted from the Avatar.tsx face components
|
||||
const FACES: [number, number, string[]][] = [
|
||||
// round eyes
|
||||
[
|
||||
63,
|
||||
15,
|
||||
[
|
||||
"M62.4 7.2C62.4 11.1765 59.1765 14.4 55.2 14.4C51.2236 14.4 48 11.1765 48 7.2C48 3.22355 51.2236 0 55.2 0C59.1765 0 62.4 3.22355 62.4 7.2Z",
|
||||
"M14.4 7.2C14.4 11.1765 11.1765 14.4 7.2 14.4C3.22355 14.4 0 11.1765 0 7.2C0 3.22355 3.22355 0 7.2 0C11.1765 0 14.4 3.22355 14.4 7.2Z",
|
||||
],
|
||||
],
|
||||
// cross eyes
|
||||
[
|
||||
71,
|
||||
23,
|
||||
[
|
||||
"M11.5 0C12.9411 0 13.6619 0.000460386 14.1748 0.354492C14.3742 0.49213 14.547 0.664882 14.6846 0.864258C15.0384 1.37711 15.0391 2.09739 15.0391 3.53809V7.96094H19.4619C20.9027 7.96094 21.6229 7.9615 22.1357 8.31543C22.3352 8.45308 22.5079 8.62578 22.6455 8.8252C22.9995 9.3381 23 10.0589 23 11.5C23 12.9408 22.9995 13.661 22.6455 14.1738C22.5079 14.3733 22.3352 14.5459 22.1357 14.6836C21.6229 15.0375 20.9027 15.0381 19.4619 15.0381H15.0391V19.4619C15.0391 20.9026 15.0384 21.6229 14.6846 22.1357C14.547 22.3351 14.3742 22.5079 14.1748 22.6455C13.6619 22.9995 12.9411 23 11.5 23C10.0592 23 9.33903 22.9994 8.82617 22.6455C8.62674 22.5079 8.45309 22.3352 8.31543 22.1357C7.96175 21.6229 7.96191 20.9024 7.96191 19.4619V15.0381H3.53809C2.0973 15.0381 1.37711 15.0375 0.864258 14.6836C0.664834 14.5459 0.492147 14.3733 0.354492 14.1738C0.000498831 13.661 -5.88036e-08 12.9408 0 11.5C6.2999e-08 10.0589 0.000460356 9.3381 0.354492 8.8252C0.492144 8.62578 0.664842 8.45308 0.864258 8.31543C1.37711 7.9615 2.09731 7.96094 3.53809 7.96094H7.96191V3.53809C7.96191 2.09765 7.96175 1.37709 8.31543 0.864258C8.45309 0.664828 8.62674 0.492149 8.82617 0.354492C9.33903 0.000555366 10.0592 1.62347e-09 11.5 0Z",
|
||||
"M58.7695 0C60.2107 0 60.9314 0.000460386 61.4443 0.354492C61.6437 0.49213 61.8165 0.664882 61.9541 0.864258C62.308 1.37711 62.3086 2.09739 62.3086 3.53809V7.96094H66.7314C68.1722 7.96094 68.8924 7.9615 69.4053 8.31543C69.6047 8.45308 69.7774 8.62578 69.915 8.8252C70.2691 9.3381 70.2695 10.0589 70.2695 11.5C70.2695 12.9408 70.269 13.661 69.915 14.1738C69.7774 14.3733 69.6047 14.5459 69.4053 14.6836C68.8924 15.0375 68.1722 15.0381 66.7314 15.0381H62.3086V19.4619C62.3086 20.9026 62.308 21.6229 61.9541 22.1357C61.8165 22.3351 61.6437 22.5079 61.4443 22.6455C60.9314 22.9995 60.2107 23 58.7695 23C57.3287 23 56.6086 22.9994 56.0957 22.6455C55.8963 22.5079 55.7226 22.3352 55.585 22.1357C55.2313 21.6229 55.2314 20.9024 55.2314 19.4619V15.0381H50.8076C49.3668 15.0381 48.6466 15.0375 48.1338 14.6836C47.9344 14.5459 47.7617 14.3733 47.624 14.1738C47.27 13.661 47.2695 12.9408 47.2695 11.5C47.2695 10.0589 47.27 9.3381 47.624 8.8252C47.7617 8.62578 47.9344 8.45308 48.1338 8.31543C48.6466 7.9615 49.3668 7.96094 50.8076 7.96094H55.2314V3.53809C55.2314 2.09765 55.2313 1.37709 55.585 0.864258C55.7226 0.664828 55.8963 0.492149 56.0957 0.354492C56.6086 0.000555366 57.3287 1.62347e-09 58.7695 0Z",
|
||||
],
|
||||
],
|
||||
// line eyes
|
||||
[
|
||||
82,
|
||||
8,
|
||||
[
|
||||
"M3.53125 0.164063C4.90133 0.164063 5.58673 0.163893 6.08301 0.485352C6.31917 0.638428 6.52075 0.840012 6.67383 1.07617C6.99555 1.57252 6.99512 2.25826 6.99512 3.62891C6.99512 4.99911 6.99536 5.68438 6.67383 6.18066C6.52075 6.41682 6.31917 6.61841 6.08301 6.77148C5.58672 7.09305 4.90147 7.09277 3.53125 7.09277C2.16062 7.09277 1.47486 7.09319 0.978516 6.77148C0.742356 6.61841 0.540772 6.41682 0.387695 6.18066C0.0662401 5.68439 0.0664063 4.999 0.0664063 3.62891C0.0664063 2.25838 0.0660571 1.57251 0.387695 1.07617C0.540772 0.840012 0.742356 0.638428 0.978516 0.485352C1.47485 0.163744 2.16076 0.164063 3.53125 0.164063Z",
|
||||
"M25.1836 0.164063C26.5542 0.164063 27.24 0.163638 27.7363 0.485352C27.9724 0.638384 28.1731 0.8401 28.3262 1.07617C28.6479 1.57252 28.6484 2.25825 28.6484 3.62891C28.6484 4.99931 28.6478 5.68436 28.3262 6.18066C28.1731 6.41678 27.9724 6.61842 27.7363 6.77148C27.24 7.09321 26.5542 7.09277 25.1836 7.09277H11.3262C9.95557 7.09277 9.26978 7.09317 8.77344 6.77148C8.53728 6.61841 8.33569 6.41682 8.18262 6.18066C7.86115 5.68438 7.86133 4.99902 7.86133 3.62891C7.86133 2.25835 7.86096 1.57251 8.18262 1.07617C8.33569 0.840012 8.53728 0.638428 8.77344 0.485352C9.26977 0.163768 9.95572 0.164063 11.3262 0.164063H25.1836Z",
|
||||
"M78.2034 7.09325C76.8333 7.09325 76.1479 7.09342 75.6516 6.77197C75.4155 6.61889 75.2139 6.4173 75.0608 6.18114C74.7391 5.6848 74.7395 4.99905 74.7395 3.62841C74.7395 2.2582 74.7393 1.57294 75.0608 1.07665C75.2139 0.840493 75.4155 0.638909 75.6516 0.485832C76.1479 0.164271 76.8332 0.164543 78.2034 0.164543C79.574 0.164543 80.2598 0.164122 80.7561 0.485832C80.9923 0.638909 81.1939 0.840493 81.347 1.07665C81.6684 1.57293 81.6682 2.25831 81.6682 3.62841C81.6682 4.99894 81.6686 5.68481 81.347 6.18114C81.1939 6.4173 80.9923 6.61889 80.7561 6.77197C80.2598 7.09357 79.5739 7.09325 78.2034 7.09325Z",
|
||||
"M56.5511 7.09325C55.1804 7.09325 54.4947 7.09368 53.9983 6.77197C53.7622 6.61893 53.5615 6.41722 53.4085 6.18114C53.0868 5.6848 53.0862 4.99907 53.0862 3.62841C53.0862 2.258 53.0868 1.57296 53.4085 1.07665C53.5615 0.840539 53.7622 0.638898 53.9983 0.485832C54.4947 0.164105 55.1804 0.164543 56.5511 0.164543H70.4085C71.7791 0.164543 72.4649 0.164146 72.9612 0.485832C73.1974 0.638909 73.399 0.840493 73.552 1.07665C73.8735 1.57293 73.8733 2.25829 73.8733 3.62841C73.8733 4.99896 73.8737 5.68481 73.552 6.18114C73.399 6.4173 73.1974 6.61889 72.9612 6.77197C72.4649 7.09355 71.7789 7.09325 70.4085 7.09325H56.5511Z",
|
||||
],
|
||||
],
|
||||
// curved eyes
|
||||
[
|
||||
63,
|
||||
9,
|
||||
[
|
||||
"M0 5.06511C0 4.94513 0 4.88513 0.00771184 4.79757C0.0483059 4.33665 0.341025 3.76395 0.690821 3.46107C0.757274 3.40353 0.783996 3.38422 0.837439 3.34559C2.40699 2.21129 6.03888 0 10.5 0C14.9611 0 18.593 2.21129 20.1626 3.34559C20.216 3.38422 20.2427 3.40353 20.3092 3.46107C20.659 3.76395 20.9517 4.33665 20.9923 4.79757C21 4.88513 21 4.94513 21 5.06511C21 6.01683 21 6.4927 20.9657 6.6754C20.7241 7.96423 19.8033 8.55941 18.5289 8.25054C18.3483 8.20676 17.8198 7.96876 16.7627 7.49275C14.975 6.68767 12.7805 6 10.5 6C8.21954 6 6.02504 6.68767 4.23727 7.49275C3.18025 7.96876 2.65174 8.20676 2.47108 8.25054C1.19668 8.55941 0.275917 7.96423 0.0342566 6.6754C0 6.4927 0 6.01683 0 5.06511Z",
|
||||
"M42 5.06511C42 4.94513 42 4.88513 42.0077 4.79757C42.0483 4.33665 42.341 3.76395 42.6908 3.46107C42.7573 3.40353 42.784 3.38422 42.8374 3.34559C44.407 2.21129 48.0389 0 52.5 0C56.9611 0 60.593 2.21129 62.1626 3.34559C62.216 3.38422 62.2427 3.40353 62.3092 3.46107C62.659 3.76395 62.9517 4.33665 62.9923 4.79757C63 4.88513 63 4.94513 63 5.06511C63 6.01683 63 6.4927 62.9657 6.6754C62.7241 7.96423 61.8033 8.55941 60.5289 8.25054C60.3483 8.20676 59.8198 7.96876 58.7627 7.49275C56.975 6.68767 54.7805 6 52.5 6C50.2195 6 48.025 6.68767 46.2373 7.49275C45.1802 7.96876 44.6517 8.20676 44.4711 8.25054C43.1967 8.55941 42.2759 7.96423 42.0343 6.6754C42 6.4927 42 6.01683 42 5.06511Z",
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// generates a standalone svg string for a given name
|
||||
// used as notification icon, matches the Avatar component visuals
|
||||
export function generateAvatarSvg(name: string): string {
|
||||
const hash = stringHash(name);
|
||||
const bgColor = COLORS[hash % COLORS.length];
|
||||
const [vbW, vbH, paths] = FACES[hash % FACES.length];
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
|
||||
// 128x128 icon with the face centered in a circle
|
||||
const size = 128;
|
||||
const half = size / 2;
|
||||
|
||||
// face region takes up ~60% width, centered vertically above the initial
|
||||
const faceWidth = size * 0.6;
|
||||
const faceX = (size - faceWidth) / 2;
|
||||
const faceScale = faceWidth / vbW;
|
||||
const faceHeight = vbH * faceScale;
|
||||
const faceY = half - faceHeight / 2 - 8;
|
||||
|
||||
const pathElements = paths
|
||||
.map((d) => `<path d="${d}" fill="white"/>`)
|
||||
.join("");
|
||||
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">`,
|
||||
`<defs>`,
|
||||
`<clipPath id="c"><circle cx="${half}" cy="${half}" r="${half}"/></clipPath>`,
|
||||
`<radialGradient id="g" cx="50%" cy="50%" r="50%">`,
|
||||
`<stop offset="0%" stop-color="white" stop-opacity="0.15"/>`,
|
||||
`<stop offset="60%" stop-color="white" stop-opacity="0"/>`,
|
||||
`</radialGradient>`,
|
||||
`</defs>`,
|
||||
`<g clip-path="url(#c)">`,
|
||||
`<circle cx="${half}" cy="${half}" r="${half}" fill="${bgColor}"/>`,
|
||||
`<circle cx="${half}" cy="${half}" r="${half}" fill="url(#g)"/>`,
|
||||
`<g transform="translate(${faceX},${faceY}) scale(${faceScale})">`,
|
||||
pathElements,
|
||||
`</g>`,
|
||||
`<text x="${half}" y="${half + 30}" text-anchor="middle" fill="white" font-size="28" font-family="sans-serif" font-weight="bold">${initial}</text>`,
|
||||
`</g>`,
|
||||
`</svg>`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
// cache key derived from name, used for deduplication
|
||||
export function avatarCacheKey(name: string): string {
|
||||
return `avatar_${stringHash(name)}`;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { JSONContent } from "@tiptap/core";
|
||||
import { renderMentions } from "./mentions";
|
||||
|
||||
// convert tiptap's json document tree to markdown-formatted text
|
||||
// preserves formatting marks as markdown syntax for the wire format
|
||||
|
|
@ -20,6 +21,13 @@ export function tiptapToMarkdown(doc: JSONContent): string {
|
|||
|
||||
function textNodeToMarkdown(node: JSONContent): string {
|
||||
if (node.type === "hardBreak") return "\n";
|
||||
|
||||
// mention nodes serialize to the <@peer_id> wire format
|
||||
if (node.type === "mention") {
|
||||
const id = node.attrs?.id ?? "";
|
||||
return `<@${id}>`;
|
||||
}
|
||||
|
||||
if (node.type !== "text" || !node.text) return "";
|
||||
|
||||
let text = node.text;
|
||||
|
|
@ -121,6 +129,10 @@ export function renderMarkdown(text: string): string {
|
|||
} else {
|
||||
let s = escapeHtml(segment);
|
||||
|
||||
// mentions - resolve <@peer_id> tokens before other formatting
|
||||
// runs on escaped html so matches <@...>
|
||||
s = renderMentions(s);
|
||||
|
||||
// bold + italic combined
|
||||
s = s.replace(
|
||||
/\*\*\*(.+?)\*\*\*/g,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { members } from "../stores/members";
|
||||
import { knownPeers } from "../stores/directory";
|
||||
|
||||
// matches mention tokens in the wire format: <@peer_id> or <@everyone>
|
||||
// peer ids are base58-encoded multihash strings (alphanumeric)
|
||||
const MENTION_REGEX = /<@(everyone|[A-Za-z0-9]+)>/g;
|
||||
|
||||
// same pattern but against html-escaped content (after escapeHtml runs)
|
||||
const MENTION_ESCAPED_REGEX = /<@(everyone|[A-Za-z0-9]+)>/g;
|
||||
|
||||
// extract all mentioned peer ids (or "everyone") from raw message content
|
||||
export function extractMentions(content: string): string[] {
|
||||
const mentions: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const regex = new RegExp(MENTION_REGEX.source, "g");
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
mentions.push(match[1]);
|
||||
}
|
||||
|
||||
return mentions;
|
||||
}
|
||||
|
||||
// check if a specific peer is mentioned in the message content
|
||||
// also returns true if @everyone is used
|
||||
export function isMentioned(content: string, peerId: string): boolean {
|
||||
const mentions = extractMentions(content);
|
||||
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
|
||||
export function resolveMentionName(peerId: string): string {
|
||||
if (peerId === "everyone") return "everyone";
|
||||
|
||||
// check active community members first
|
||||
const memberList = members();
|
||||
const member = memberList.find((m) => m.peer_id === peerId);
|
||||
if (member) return member.display_name;
|
||||
|
||||
// fall back to the global peer directory (covers dm peers and
|
||||
// members from other communities)
|
||||
const peers = knownPeers();
|
||||
const peer = peers.find((p) => p.peer_id === peerId);
|
||||
if (peer) return peer.display_name;
|
||||
|
||||
// last resort - truncate the raw peer id for readability
|
||||
if (peerId.length > 12) {
|
||||
return peerId.slice(0, 8) + "...";
|
||||
}
|
||||
return peerId;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return escapedHtml.replace(MENTION_ESCAPED_REGEX, (_match, id: string) => {
|
||||
const name = resolveMentionName(id);
|
||||
|
||||
if (id === "everyone") {
|
||||
return `<span class="dusk-mention dusk-mention-everyone">@${name}</span>`;
|
||||
}
|
||||
|
||||
return `<span class="dusk-mention" data-peer-id="${id}">@${name}</span>`;
|
||||
});
|
||||
}
|
||||
|
|
@ -2,13 +2,27 @@ import {
|
|||
isPermissionGranted,
|
||||
requestPermission,
|
||||
sendNotification as tauriSendNotification,
|
||||
registerActionTypes,
|
||||
onAction,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { settings } from "../stores/settings";
|
||||
import { setActiveCommunity } from "../stores/communities";
|
||||
import { setActiveChannel } from "../stores/channels";
|
||||
import { setActiveDM } from "../stores/dms";
|
||||
import { generateAvatarSvg, avatarCacheKey } from "./avatar-svg";
|
||||
import { cacheAvatarIcon } from "./tauri";
|
||||
import type { ChatMessage } from "./types";
|
||||
|
||||
// track if we have notification permission
|
||||
let permissionGranted = false;
|
||||
|
||||
// avoid redundant disk writes for the same avatar
|
||||
const iconPathCache = new Map<string, string>();
|
||||
|
||||
// action type id for clickable notifications
|
||||
const NAVIGATE_ACTION_TYPE = "dusk-navigate";
|
||||
|
||||
// check and request notification permission on module load
|
||||
export async function initNotifications(): Promise<boolean> {
|
||||
try {
|
||||
|
|
@ -16,28 +30,110 @@ export async function initNotifications(): Promise<boolean> {
|
|||
const granted = await isPermissionGranted();
|
||||
if (granted) {
|
||||
permissionGranted = true;
|
||||
return true;
|
||||
} else {
|
||||
const permission = await requestPermission();
|
||||
permissionGranted = permission === "granted";
|
||||
}
|
||||
|
||||
// request permission
|
||||
const permission = await requestPermission();
|
||||
permissionGranted = permission === "granted";
|
||||
return permissionGranted;
|
||||
if (!permissionGranted) return false;
|
||||
|
||||
// register action types so clicking a notification fires onAction
|
||||
await registerActionTypes([
|
||||
{
|
||||
id: NAVIGATE_ACTION_TYPE,
|
||||
actions: [
|
||||
{
|
||||
id: "default",
|
||||
title: "Open",
|
||||
foreground: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// handle notification clicks by navigating to the relevant screen
|
||||
await onAction((notification) => {
|
||||
const extra = notification.extra as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!extra) return;
|
||||
|
||||
navigateToTarget(extra);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("failed to initialize notifications:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// send a desktop notification if settings allow
|
||||
export async function sendNotification(title: string, body: string): Promise<void> {
|
||||
// check if notifications are enabled in settings
|
||||
// navigate to the community/channel or dm referenced by the notification payload
|
||||
async function navigateToTarget(extra: Record<string, unknown>) {
|
||||
const type = extra.type as string | undefined;
|
||||
|
||||
try {
|
||||
// bring the window to focus
|
||||
await getCurrentWindow().setFocus();
|
||||
await getCurrentWindow().unminimize();
|
||||
} catch {
|
||||
// non-critical, window focus may not be supported in all environments
|
||||
}
|
||||
|
||||
if (type === "channel") {
|
||||
const communityId = extra.community_id as string | undefined;
|
||||
const channelId = extra.channel_id as string | undefined;
|
||||
if (communityId) {
|
||||
setActiveCommunity(communityId);
|
||||
setActiveDM(null);
|
||||
}
|
||||
// channel selection happens after the community effect loads channels,
|
||||
// so defer it slightly to let the reactive chain settle
|
||||
if (channelId) {
|
||||
setTimeout(() => setActiveChannel(channelId), 50);
|
||||
}
|
||||
} else if (type === "dm") {
|
||||
const peerId = extra.peer_id as string | undefined;
|
||||
if (peerId) {
|
||||
// switch to home view and open the dm
|
||||
setActiveCommunity(null);
|
||||
setActiveDM(peerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolve the cached icon path for a given author name
|
||||
// generates the svg and writes it to disk on first call per name
|
||||
async function getIconPath(authorName: string): Promise<string | undefined> {
|
||||
const key = avatarCacheKey(authorName);
|
||||
|
||||
// return from memory cache if we already resolved this name
|
||||
const cached = iconPathCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const svg = generateAvatarSvg(authorName);
|
||||
const path = await cacheAvatarIcon(key, svg);
|
||||
iconPathCache.set(key, path);
|
||||
return path;
|
||||
} catch {
|
||||
// non-critical, notification will just lack an icon
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// send a desktop notification with optional navigation context
|
||||
async function sendNotification(
|
||||
title: string,
|
||||
body: string,
|
||||
authorName?: string,
|
||||
extra?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const currentSettings = settings();
|
||||
if (!currentSettings.enable_desktop_notifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check permission
|
||||
if (!permissionGranted) {
|
||||
const granted = await initNotifications();
|
||||
if (!granted) {
|
||||
|
|
@ -46,7 +142,14 @@ export async function sendNotification(title: string, body: string): Promise<voi
|
|||
}
|
||||
|
||||
try {
|
||||
tauriSendNotification({ title, body });
|
||||
const icon = authorName ? await getIconPath(authorName) : undefined;
|
||||
tauriSendNotification({
|
||||
title,
|
||||
body,
|
||||
icon,
|
||||
actionTypeId: NAVIGATE_ACTION_TYPE,
|
||||
extra,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("failed to send notification:", error);
|
||||
}
|
||||
|
|
@ -57,15 +160,21 @@ export async function notifyChannelMessage(
|
|||
message: ChatMessage,
|
||||
channelName: string,
|
||||
communityName: string,
|
||||
communityId: string,
|
||||
): Promise<void> {
|
||||
const currentSettings = settings();
|
||||
const extra = {
|
||||
type: "channel",
|
||||
community_id: communityId,
|
||||
channel_id: message.channel_id,
|
||||
};
|
||||
|
||||
// dont notify if previews are disabled
|
||||
if (!currentSettings.enable_message_preview) {
|
||||
// send notification without message content
|
||||
await sendNotification(
|
||||
`${message.author_name} in ${communityName} > ${channelName}`,
|
||||
"New message",
|
||||
message.author_name,
|
||||
extra,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -73,19 +182,70 @@ export async function notifyChannelMessage(
|
|||
await sendNotification(
|
||||
`${message.author_name} in ${communityName} > ${channelName}`,
|
||||
message.content,
|
||||
message.author_name,
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
// send notification when the current user is mentioned in a channel message
|
||||
export async function notifyMention(
|
||||
message: ChatMessage,
|
||||
channelName: string,
|
||||
communityName: string,
|
||||
communityId: string,
|
||||
): Promise<void> {
|
||||
const currentSettings = settings();
|
||||
const extra = {
|
||||
type: "channel",
|
||||
community_id: communityId,
|
||||
channel_id: message.channel_id,
|
||||
};
|
||||
|
||||
if (!currentSettings.enable_message_preview) {
|
||||
await sendNotification(
|
||||
`${message.author_name} mentioned you in ${communityName} > ${channelName}`,
|
||||
"You were mentioned",
|
||||
message.author_name,
|
||||
extra,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendNotification(
|
||||
`${message.author_name} mentioned you in ${communityName} > ${channelName}`,
|
||||
message.content,
|
||||
message.author_name,
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
// send notification for a direct message
|
||||
export async function notifyDirectMessage(message: ChatMessage): Promise<void> {
|
||||
export async function notifyDirectMessage(
|
||||
message: ChatMessage,
|
||||
peerId: string,
|
||||
): Promise<void> {
|
||||
const currentSettings = settings();
|
||||
const extra = {
|
||||
type: "dm",
|
||||
peer_id: peerId,
|
||||
};
|
||||
|
||||
if (!currentSettings.enable_message_preview) {
|
||||
await sendNotification(`${message.author_name}`, "Sent you a message");
|
||||
await sendNotification(
|
||||
`${message.author_name}`,
|
||||
"Sent you a message",
|
||||
message.author_name,
|
||||
extra,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendNotification(`${message.author_name}`, message.content);
|
||||
await sendNotification(
|
||||
`${message.author_name}`,
|
||||
message.content,
|
||||
message.author_name,
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
// check if the window is focused
|
||||
|
|
|
|||
|
|
@ -267,6 +267,13 @@ export async function resetIdentity(): Promise<void> {
|
|||
return invoke("reset_identity");
|
||||
}
|
||||
|
||||
export async function cacheAvatarIcon(
|
||||
cacheKey: string,
|
||||
svgContent: string,
|
||||
): Promise<string> {
|
||||
return invoke("cache_avatar_icon", { cacheKey, svgContent });
|
||||
}
|
||||
|
||||
// -- connectivity --
|
||||
|
||||
export async function checkInternetConnectivity(): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,39 @@ const [channels, setChannels] = createSignal<ChannelMeta[]>([]);
|
|||
const [categories, setCategories] = createSignal<CategoryMeta[]>([]);
|
||||
const [activeChannelId, setActiveChannelId] = createSignal<string | null>(null);
|
||||
|
||||
// persists the last viewed channel per community across restarts
|
||||
const LAST_CHANNEL_KEY = "dusk-last-channels";
|
||||
|
||||
function loadLastChannels(): Map<string, string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(LAST_CHANNEL_KEY);
|
||||
if (stored) return new Map(Object.entries(JSON.parse(stored)));
|
||||
} catch {}
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function saveLastChannels(map: Map<string, string>) {
|
||||
localStorage.setItem(
|
||||
LAST_CHANNEL_KEY,
|
||||
JSON.stringify(Object.fromEntries(map)),
|
||||
);
|
||||
}
|
||||
|
||||
const lastChannelByCommunity = loadLastChannels();
|
||||
|
||||
export function setActiveChannel(id: string | null) {
|
||||
setActiveChannelId(id);
|
||||
|
||||
// remember which channel was last viewed in this community
|
||||
const communityId = activeCommunityId();
|
||||
if (communityId && id) {
|
||||
lastChannelByCommunity.set(communityId, id);
|
||||
saveLastChannels(lastChannelByCommunity);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastChannel(communityId: string): string | null {
|
||||
return lastChannelByCommunity.get(communityId) ?? null;
|
||||
}
|
||||
|
||||
export function activeChannel(): ChannelMeta | undefined {
|
||||
|
|
|
|||
|
|
@ -560,6 +560,44 @@ body {
|
|||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.dusk-mention {
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.dusk-mention:hover {
|
||||
text-decoration: underline;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* mention nodes inside the tiptap editor */
|
||||
.ProseMirror .dusk-mention {
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
background: rgba(255, 79, 0, 0.1);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.dusk-mention-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
|
||||
.dusk-mention-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.dusk-mention-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dusk-mention-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.dusk-msg-image-wrapper {
|
||||
white-space: normal;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue