Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions desktop/src-tauri/src/commands/social.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ use crate::{
pub async fn publish_note(
content: String,
reply_to: Option<String>,
mention_pubkeys: Option<Vec<String>>,
media_tags: Option<Vec<Vec<String>>>,
state: State<'_, AppState>,
) -> Result<SubmitEventResponse, String> {
let reply_id = reply_to
.map(|hex| EventId::from_hex(&hex).map_err(|e| format!("invalid reply_to event id: {e}")))
.transpose()?;

let builder = events::build_note(&content, reply_id)?;
let mentions = mention_pubkeys.unwrap_or_default();
let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect();
let media = media_tags.unwrap_or_default();
let builder = events::build_note(&content, reply_id, &mention_refs, &media)?;
submit_event(builder, &state).await
}

Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ fn mention_tags(mentions: &[&str]) -> Result<Vec<Tag>, String> {
let mut seen = std::collections::HashSet::new();
let mut tags = Vec::new();
for &hex in mentions {
check_pubkey(hex)?;
let lower = hex.to_ascii_lowercase();
if seen.insert(lower.clone()) {
tags.push(tag(vec!["p", &lower])?);
Expand Down Expand Up @@ -428,12 +429,16 @@ pub fn build_huddle_guidelines(
pub fn build_note(
content: &str,
reply_to_event_id: Option<EventId>,
mentions: &[&str],
media_tags: &[Vec<String>],
) -> Result<EventBuilder, String> {
check_content(content)?;
let mut tags = Vec::new();
if let Some(parent) = reply_to_event_id {
tags.push(tag(vec!["e", &parent.to_hex(), "", "reply"])?);
}
tags.extend(mention_tags(mentions)?);
imeta_tags(media_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::TextNote, content).tags(tags))
}

Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import {
type MentionSuggestion,
} from "@/features/messages/ui/MentionAutocomplete";
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
import type { ChannelMember } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";

type ForumComposerProps = {
channelId?: string | null;
/** Override mention source when no channel is available (e.g. Pulse). */
members?: ChannelMember[];
placeholder: string;
disabled?: boolean;
isSending?: boolean;
Expand All @@ -39,6 +42,7 @@ type ForumComposerProps = {

export function ForumComposer({
channelId = null,
members,
placeholder,
disabled,
isSending,
Expand All @@ -58,7 +62,7 @@ export function ForumComposer({
setIsFormattingOpen(pressed);
}, []);

const mentions = useMentions(channelId);
const mentions = useMentions(channelId, members);
const channelLinks = useChannelLinks();
const media = useMediaUpload();

Expand Down
8 changes: 6 additions & 2 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@ import {
} from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import type { ChannelMember } from "@/shared/api/types";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { hasMention } from "./hasMention";

const MENTION_DEBOUNCE_MS = 120;

export function useMentions(channelId: string | null) {
export function useMentions(
channelId: string | null,
externalMembers?: ChannelMember[],
) {
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
const [mentionStartIndex, setMentionStartIndex] = React.useState(0);
const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0);
const mentionMapRef = React.useRef<Map<string, string>>(new Map());

const membersQuery = useChannelMembersQuery(channelId);
const members = membersQuery.data;
const members = externalMembers ?? membersQuery.data;
const managedAgentsQuery = useManagedAgentsQuery();
const personasQuery = usePersonasQuery();
const managedAgentNamesByPubkey = React.useMemo(
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export function useRichTextEditor({
// after block nodes (lists, blockquotes, code blocks) which creates
// a phantom empty line in the compact message composer.
trailingNode: false,
// Disable StarterKit's built-in Link — we configure it separately
// below with custom options (autolink, openOnClick, etc.).
link: false,
}),
// Shift+Enter inside lists/blockquotes: split the node instead of
// inserting a hard break so continuation lines keep their formatting.
Expand Down
91 changes: 63 additions & 28 deletions desktop/src/features/messages/ui/FormattingToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react";
import { useEditorState, type Editor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import {
Bold,
Code,
Expand All @@ -11,22 +11,67 @@ import {
Strikethrough,
} from "lucide-react";

import { Toggle } from "@/shared/ui/toggle";
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";

type FormattingToolbarProps = {
editor: Editor | null;
disabled?: boolean;
};

type ActiveStates = {
bold: boolean;
italic: boolean;
strike: boolean;
code: boolean;
link: boolean;
bulletList: boolean;
orderedList: boolean;
blockquote: boolean;
};

function getActiveStates(editor: Editor): ActiveStates {
return {
bold: editor.isActive("bold"),
italic: editor.isActive("italic"),
strike: editor.isActive("strike"),
code: editor.isActive("code"),
link: editor.isActive("link"),
bulletList: editor.isActive("bulletList"),
orderedList: editor.isActive("orderedList"),
blockquote: editor.isActive("blockquote"),
};
}

/**
* Formatting bar shown above the editor when the format toggle is active.
* Renders all formatting buttons in a single row with bg-muted styling.
* Uses plain buttons with explicit active-class toggling instead of
* Radix Toggle to avoid data-state / re-render issues.
*/
export const FormattingToolbar = React.memo(function FormattingToolbar({
editor,
disabled = false,
}: FormattingToolbarProps) {
const [activeStates, setActiveStates] = React.useState<ActiveStates | null>(
() => (editor ? getActiveStates(editor) : null),
);

React.useEffect(() => {
if (!editor) {
setActiveStates(null);
return;
}
setActiveStates(getActiveStates(editor));

const onTransaction = () => {
setActiveStates(getActiveStates(editor));
};
editor.on("transaction", onTransaction);
return () => {
editor.off("transaction", onTransaction);
};
}, [editor]);

const toggleBold = React.useCallback(() => {
editor?.chain().focus().toggleBold().run();
}, [editor]);
Expand Down Expand Up @@ -80,26 +125,6 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
editor?.chain().focus().toggleBlockquote().run();
}, [editor]);

// Subscribe to editor state changes so active marks/nodes update on
// selection change — useEditorState triggers re-renders when the
// selector result changes.
const activeStates = useEditorState({
editor,
selector: ({ editor: ed }) =>
ed
? {
bold: ed.isActive("bold"),
italic: ed.isActive("italic"),
strike: ed.isActive("strike"),
code: ed.isActive("code"),
link: ed.isActive("link"),
bulletList: ed.isActive("bulletList"),
orderedList: ed.isActive("orderedList"),
blockquote: ed.isActive("blockquote"),
}
: null,
});

if (!editor || !activeStates) return null;

const items = [
Expand Down Expand Up @@ -163,15 +188,25 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
{items.map((item) => (
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<Toggle
<button
type="button"
aria-label={item.label}
aria-pressed={item.active}
disabled={disabled}
pressed={item.active}
onPressedChange={() => item.action()}
className="h-7 w-7 min-w-7 [&_svg]:size-3.5"
onClick={() => item.action()}
className={cn(
"inline-flex h-7 w-7 min-w-7 items-center justify-center rounded-md text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0",
item.active
? "bg-primary text-primary-foreground"
: "bg-transparent text-muted-foreground",
)}
>
<item.icon className="h-3.5 w-3.5" />
</Toggle>
</button>
</TooltipTrigger>
<TooltipContent>
{"shortcut" in item
Expand Down
42 changes: 31 additions & 11 deletions desktop/src/features/messages/ui/MessageComposerToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import {
X,
} from "lucide-react";

import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { Toggle } from "@/shared/ui/toggle";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
import { FormattingToolbar } from "./FormattingToolbar";
Expand Down Expand Up @@ -90,15 +90,25 @@ export const MessageComposerToolbar = React.memo(
>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
<button
type="button"
aria-label="Toggle formatting"
aria-pressed={isFormattingOpen}
disabled={composerDisabled}
pressed={isFormattingOpen}
onPressedChange={onFormattingToggle}
size="sm"
onClick={() => onFormattingToggle(!isFormattingOpen)}
className={cn(
"inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
isFormattingOpen
? "bg-primary text-primary-foreground"
: "bg-transparent text-muted-foreground",
)}
>
<ALargeSmall className="h-4 w-4" />
</Toggle>
</button>
</TooltipTrigger>
<TooltipContent>Formatting</TooltipContent>
</Tooltip>
Expand Down Expand Up @@ -204,15 +214,25 @@ export const MessageComposerToolbar = React.memo(
>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
<button
type="button"
aria-label="Toggle formatting"
aria-pressed={isFormattingOpen}
disabled={composerDisabled}
pressed={isFormattingOpen}
onPressedChange={onFormattingToggle}
size="sm"
onClick={() => onFormattingToggle(!isFormattingOpen)}
className={cn(
"inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
isFormattingOpen
? "bg-primary text-primary-foreground"
: "bg-transparent text-muted-foreground",
)}
>
<ALargeSmall className="h-4 w-4" />
</Toggle>
</button>
</TooltipTrigger>
<TooltipContent>Formatting</TooltipContent>
</Tooltip>
Expand Down
13 changes: 11 additions & 2 deletions desktop/src/features/pulse/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,17 @@ export function usePublishNoteMutation(currentPubkey?: string) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ content, replyTo }: { content: string; replyTo?: string }) =>
publishNote(content, replyTo),
mutationFn: ({
content,
replyTo,
mentionPubkeys,
mediaTags,
}: {
content: string;
replyTo?: string;
mentionPubkeys?: string[];
mediaTags?: string[][];
}) => publishNote(content, replyTo, mentionPubkeys, mediaTags),
onSuccess: () => {
if (currentPubkey) {
void queryClient.invalidateQueries({
Expand Down
Loading