mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
feat: add hashtag support to rich editor (#185)
* feat: add automatic hashtag extraction and t tags in POST command
Extract hashtags from post content and automatically add them as t tags to published events.
Changes:
- Add hashtag extraction logic to RichEditor.serializeContent() using Unicode-aware regex
- Update SerializedContent interface to include hashtags field
- Update RichEditor props and callbacks to pass hashtags through the pipeline
- Add t tags for each hashtag in PostViewer.handlePublish()
Hashtags are deduplicated and stored in lowercase (following Nostr convention).
Example: #bitcoin #nostr #Bitcoin → ["t", "bitcoin"], ["t", "nostr"]
* refactor: use NoteBlueprint for automatic hashtag/mention extraction
Replace manual hashtag and mention extraction with applesauce's NoteBlueprint,
which automatically extracts hashtags, mentions, and event quotes from text content.
Changes:
- Simplify SerializedContent interface by removing manually extracted fields
- Remove hashtag extraction regex and mention/eventRef tracking from editors
- Replace manual event building with factory.create(NoteBlueprint, ...)
- Use q tags for event quotes (NIP-18) instead of e tags
Benefits:
- ~70 lines of code removed
- Leverage battle-tested applesauce extraction logic
- Automatic benefits from future applesauce improvements
- Correct semantic tags (q for quotes, p for mentions, t for hashtags)
What still works:
- Custom emoji tags (NIP-30)
- Blob attachments/imeta tags (NIP-92)
- Address references (naddr - not yet in applesauce)
- Client tag
All tests pass (980/980).
* refactor: use NoteReplyBlueprint in NIP-10 adapter
Replace manual NIP-10 tag building with NoteReplyBlueprint, which automatically
handles root/reply markers, p-tag copying, and all the threading logic.
Changes:
- Simplify sendMessage from ~95 lines to ~40 lines
- Remove manual e-tag building with root/reply markers
- Remove manual p-tag deduplication logic
- Use factory.create(NoteReplyBlueprint, parentEvent, content, options)
- Automatically get hashtags, mentions, and event quotes via setShortTextContent
Benefits:
- ~55 lines of complex threading logic removed
- Leverage battle-tested applesauce NIP-10 implementation
- Automatic root detection from parent's existing tags
- Cleaner, more maintainable code
All tests pass (980/980).
* refactor: use GroupMessageBlueprint and ReactionBlueprint in chat adapters
Replace manual event building with applesauce blueprints in all chat adapters.
Changes:
- NIP-29: Use GroupMessageBlueprint for kind 9 messages
* Auto-handles h-tag, hashtags, mentions, emojis
* Manually add q-tag for replies (NIP-29 specific)
* ~15 lines removed
- All adapters (NIP-10, NIP-29, NIP-53, NIP-C7): Use ReactionBlueprint for kind 7 reactions
* Auto-handles e-tag, k-tag, p-tag, custom emoji support
* Protocol-specific tags (h-tag, a-tag) added manually
* ~60 lines removed across 4 adapters
Benefits:
- ~75 lines of code removed total
- Leverage battle-tested applesauce blueprints
- Automatic hashtag, mention, and quote extraction
- Cleaner, more maintainable code
All tests pass (980/980).
* fix: add required previous field to GroupMessageBlueprint options
GroupMessageBlueprintOptions requires a 'previous' field for message threading.
Added empty array for now since we don't support threading yet.
* docs: add comprehensive blueprint documentation to applesauce skills
Added detailed documentation for:
- NoteBlueprint (automatic hashtag/mention/quote extraction)
- NoteReplyBlueprint (NIP-10 threading)
- ReactionBlueprint (kind 7 reactions)
- GroupMessageBlueprint (NIP-29 groups)
- DeleteBlueprint (NIP-09 deletion)
- EventFactory usage patterns
- Before/after examples showing code reduction
- Best practices for using blueprints
This documents the refactoring work done throughout the codebase.
* fix: use single newline separator in TipTap getText() calls
TipTap's getText() uses double newlines (\n\n) by default to separate
block nodes like paragraphs, which was causing extra blank lines in
posted content.
Changed to getText({ blockSeparator: '\n' }) in both RichEditor and
MentionEditor to use single newlines between paragraphs.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ import { Kind1Renderer } from "./nostr/kinds";
|
||||
import pool from "@/services/relay-pool";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import { NoteBlueprint } from "applesauce-common/blueprints";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
@@ -344,8 +345,6 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
content: string,
|
||||
emojiTags: EmojiTag[],
|
||||
blobAttachments: BlobAttachment[],
|
||||
mentions: string[],
|
||||
eventRefs: string[],
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
) => {
|
||||
if (!canSign || !signer || !pubkey) {
|
||||
@@ -373,35 +372,31 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(signer);
|
||||
|
||||
// Build tags array
|
||||
const tags: string[][] = [];
|
||||
// Use NoteBlueprint - it auto-extracts hashtags, mentions, and quotes from content!
|
||||
const draft = await factory.create(NoteBlueprint, content.trim(), {
|
||||
emojis: emojiTags.map((e) => ({
|
||||
shortcode: e.shortcode,
|
||||
url: e.url,
|
||||
})),
|
||||
});
|
||||
|
||||
// Add p tags for mentions
|
||||
for (const pubkey of mentions) {
|
||||
tags.push(["p", pubkey]);
|
||||
}
|
||||
// Add tags that applesauce doesn't handle yet
|
||||
const additionalTags: string[][] = [];
|
||||
|
||||
// Add e tags for event references
|
||||
for (const eventId of eventRefs) {
|
||||
tags.push(["e", eventId]);
|
||||
}
|
||||
|
||||
// Add a tags for address references
|
||||
// Add a tags for address references (naddr - not yet supported by applesauce)
|
||||
for (const addr of addressRefs) {
|
||||
tags.push(["a", `${addr.kind}:${addr.pubkey}:${addr.identifier}`]);
|
||||
additionalTags.push([
|
||||
"a",
|
||||
`${addr.kind}:${addr.pubkey}:${addr.identifier}`,
|
||||
]);
|
||||
}
|
||||
|
||||
// Add client tag (if enabled)
|
||||
if (settings.includeClientTag) {
|
||||
tags.push(GRIMOIRE_CLIENT_TAG);
|
||||
additionalTags.push(GRIMOIRE_CLIENT_TAG);
|
||||
}
|
||||
|
||||
// Add emoji tags
|
||||
for (const emoji of emojiTags) {
|
||||
tags.push(["emoji", emoji.shortcode, emoji.url]);
|
||||
}
|
||||
|
||||
// Add blob attachment tags (imeta)
|
||||
// Add imeta tags for blob attachments (NIP-92)
|
||||
for (const blob of blobAttachments) {
|
||||
const imetaTag = [
|
||||
"imeta",
|
||||
@@ -413,15 +408,13 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
if (blob.server) {
|
||||
imetaTag.push(`server ${blob.server}`);
|
||||
}
|
||||
tags.push(imetaTag);
|
||||
additionalTags.push(imetaTag);
|
||||
}
|
||||
|
||||
// Create and sign event (kind 1 note)
|
||||
const draft = await factory.build({
|
||||
kind: 1,
|
||||
content: content.trim(),
|
||||
tags,
|
||||
});
|
||||
// Merge additional tags with blueprint tags
|
||||
draft.tags.push(...additionalTags);
|
||||
|
||||
// Sign the event
|
||||
event = await factory.sign(draft);
|
||||
} catch (error) {
|
||||
// Signing failed - user might have rejected it
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface BlobAttachment {
|
||||
|
||||
/**
|
||||
* Result of serializing editor content
|
||||
* Note: mentions, event quotes, and hashtags are extracted automatically by applesauce
|
||||
* from the text content (nostr: URIs and #hashtags), so we don't need to extract them here.
|
||||
*/
|
||||
export interface SerializedContent {
|
||||
/** The text content with mentions as nostr: URIs and emoji as :shortcode: */
|
||||
@@ -68,11 +70,7 @@ export interface SerializedContent {
|
||||
emojiTags: EmojiTag[];
|
||||
/** Blob attachments for imeta tags (NIP-92) */
|
||||
blobAttachments: BlobAttachment[];
|
||||
/** Mentioned pubkeys for p tags */
|
||||
mentions: string[];
|
||||
/** Referenced event IDs for e tags (from note/nevent) */
|
||||
eventRefs: string[];
|
||||
/** Referenced addresses for a tags (from naddr) */
|
||||
/** Referenced addresses for a tags (from naddr - not yet handled by applesauce) */
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>;
|
||||
}
|
||||
|
||||
@@ -668,8 +666,14 @@ export const MentionEditor = forwardRef<
|
||||
let text = "";
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const addressRefs: Array<{
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}> = [];
|
||||
const seenEmojis = new Set<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
const seenAddrs = new Set<string>();
|
||||
const json = editorInstance.getJSON();
|
||||
|
||||
json.content?.forEach((node: any) => {
|
||||
@@ -735,6 +739,16 @@ export const MentionEditor = forwardRef<
|
||||
text += `nostr:${nip19.neventEncode(data)}`;
|
||||
} else if (type === "naddr") {
|
||||
text += `nostr:${nip19.naddrEncode(data)}`;
|
||||
// Extract addressRefs for manual a tags (applesauce doesn't handle naddr yet)
|
||||
const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`;
|
||||
if (!seenAddrs.has(addrKey)) {
|
||||
seenAddrs.add(addrKey);
|
||||
addressRefs.push({
|
||||
kind: data.kind,
|
||||
pubkey: data.pubkey,
|
||||
identifier: data.identifier || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@@ -752,9 +766,7 @@ export const MentionEditor = forwardRef<
|
||||
text: text.trim(),
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
mentions: [],
|
||||
eventRefs: [],
|
||||
addressRefs: [],
|
||||
addressRefs,
|
||||
};
|
||||
},
|
||||
[],
|
||||
@@ -954,15 +966,13 @@ export const MentionEditor = forwardRef<
|
||||
() => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() || "",
|
||||
getContent: () => editor?.getText({ blockSeparator: "\n" }) || "",
|
||||
getSerializedContent: () => {
|
||||
if (!editor)
|
||||
return {
|
||||
text: "",
|
||||
emojiTags: [],
|
||||
blobAttachments: [],
|
||||
mentions: [],
|
||||
eventRefs: [],
|
||||
addressRefs: [],
|
||||
};
|
||||
return serializeContent(editor);
|
||||
|
||||
@@ -42,8 +42,6 @@ export interface RichEditorProps {
|
||||
content: string,
|
||||
emojiTags: EmojiTag[],
|
||||
blobAttachments: BlobAttachment[],
|
||||
mentions: string[],
|
||||
eventRefs: string[],
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
) => void;
|
||||
onChange?: () => void;
|
||||
@@ -156,12 +154,15 @@ const EmojiMention = Mention.extend({
|
||||
|
||||
/**
|
||||
* Serialize editor content to plain text with nostr: URIs
|
||||
* Note: hashtags, mentions, and event quotes are extracted automatically by applesauce's
|
||||
* NoteBlueprint from the text content, so we only need to extract what it doesn't handle:
|
||||
* - Custom emojis (for emoji tags)
|
||||
* - Blob attachments (for imeta tags)
|
||||
* - Address references (naddr - not yet supported by applesauce)
|
||||
*/
|
||||
function serializeContent(editor: any): SerializedContent {
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const mentions = new Set<string>();
|
||||
const eventRefs = new Set<string>();
|
||||
const addressRefs: Array<{
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
@@ -171,10 +172,11 @@ function serializeContent(editor: any): SerializedContent {
|
||||
const seenBlobs = new Set<string>();
|
||||
const seenAddrs = new Set<string>();
|
||||
|
||||
// Get plain text representation
|
||||
const text = editor.getText();
|
||||
// Get plain text representation with single newline between blocks
|
||||
// (TipTap's default is double newline which adds extra blank lines)
|
||||
const text = editor.getText({ blockSeparator: "\n" });
|
||||
|
||||
// Walk the document to collect emoji, blob, mention, and event data
|
||||
// Walk the document to collect emoji, blob, and address reference data
|
||||
editor.state.doc.descendants((node: any) => {
|
||||
if (node.type.name === "emoji") {
|
||||
const { id, url, source } = node.attrs;
|
||||
@@ -190,20 +192,11 @@ function serializeContent(editor: any): SerializedContent {
|
||||
seenBlobs.add(sha256);
|
||||
blobAttachments.push({ url, sha256, mimeType, size, server });
|
||||
}
|
||||
} else if (node.type.name === "mention") {
|
||||
// Extract pubkey from @mentions for p tags
|
||||
const { id } = node.attrs;
|
||||
if (id) {
|
||||
mentions.add(id);
|
||||
}
|
||||
} else if (node.type.name === "nostrEventPreview") {
|
||||
// Extract event/address references for e/a tags
|
||||
// Extract address references (naddr) for manual a tags
|
||||
// Note: applesauce handles note/nevent automatically from nostr: URIs
|
||||
const { type, data } = node.attrs;
|
||||
if (type === "note" && data) {
|
||||
eventRefs.add(data);
|
||||
} else if (type === "nevent" && data?.id) {
|
||||
eventRefs.add(data.id);
|
||||
} else if (type === "naddr" && data) {
|
||||
if (type === "naddr" && data) {
|
||||
const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`;
|
||||
if (!seenAddrs.has(addrKey)) {
|
||||
seenAddrs.add(addrKey);
|
||||
@@ -221,8 +214,6 @@ function serializeContent(editor: any): SerializedContent {
|
||||
text,
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
mentions: Array.from(mentions),
|
||||
eventRefs: Array.from(eventRefs),
|
||||
addressRefs,
|
||||
};
|
||||
}
|
||||
@@ -398,8 +389,6 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
serialized.text,
|
||||
serialized.emojiTags,
|
||||
serialized.blobAttachments,
|
||||
serialized.mentions,
|
||||
serialized.eventRefs,
|
||||
serialized.addressRefs,
|
||||
);
|
||||
// Don't clear content here - let the parent component decide when to clear
|
||||
@@ -545,15 +534,13 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
() => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() || "",
|
||||
getContent: () => editor?.getText({ blockSeparator: "\n" }) || "",
|
||||
getSerializedContent: () => {
|
||||
if (!editor)
|
||||
return {
|
||||
text: "",
|
||||
emojiTags: [],
|
||||
blobAttachments: [],
|
||||
mentions: [],
|
||||
eventRefs: [],
|
||||
addressRefs: [],
|
||||
};
|
||||
return serializeContent(editor);
|
||||
|
||||
Reference in New Issue
Block a user