PublishingService.ts raw
1 import { Pubkey, Timestamp } from '@/domain/shared'
2 import { FollowList } from '@/domain/social'
3 import { MuteList } from '@/domain/social'
4 import { RelayList } from '@/domain/relay'
5 import { kinds } from 'nostr-tools'
6
7 /**
8 * Draft event structure (matches TDraftEvent)
9 */
10 export type DraftEvent = {
11 kind: number
12 content: string
13 created_at: number
14 tags: string[][]
15 }
16
17 /**
18 * Options for publishing a note
19 */
20 export type PublishNoteOptions = {
21 parentEventId?: string
22 mentions?: Pubkey[]
23 hashtags?: string[]
24 isNsfw?: boolean
25 addClientTag?: boolean
26 }
27
28 /**
29 * PublishingService Domain Service
30 *
31 * Handles creation of draft events and publishing logic.
32 * This service encapsulates the business rules for event creation.
33 */
34 export class PublishingService {
35 /**
36 * Create a draft note event
37 */
38 createNoteDraft(
39 content: string,
40 options: PublishNoteOptions = {}
41 ): DraftEvent {
42 const tags: string[][] = []
43
44 // Add mention tags
45 if (options.mentions) {
46 for (const pubkey of options.mentions) {
47 tags.push(['p', pubkey.hex])
48 }
49 }
50
51 // Add hashtags
52 if (options.hashtags) {
53 for (const hashtag of options.hashtags) {
54 tags.push(['t', hashtag.toLowerCase()])
55 }
56 }
57
58 // Add NSFW warning
59 if (options.isNsfw) {
60 tags.push(['content-warning', 'NSFW'])
61 }
62
63 // Add client tag
64 if (options.addClientTag) {
65 tags.push(['client', 'smesh'])
66 }
67
68 return {
69 kind: kinds.ShortTextNote,
70 content,
71 created_at: Timestamp.now().unix,
72 tags
73 }
74 }
75
76 /**
77 * Create a draft reaction event
78 */
79 createReactionDraft(
80 targetEventId: string,
81 targetPubkey: string,
82 targetKind: number,
83 emoji: string = '+'
84 ): DraftEvent {
85 const tags: string[][] = [
86 ['e', targetEventId],
87 ['p', targetPubkey]
88 ]
89
90 if (targetKind !== kinds.ShortTextNote) {
91 tags.push(['k', targetKind.toString()])
92 }
93
94 return {
95 kind: kinds.Reaction,
96 content: emoji,
97 created_at: Timestamp.now().unix,
98 tags
99 }
100 }
101
102 /**
103 * Create a draft repost event
104 */
105 createRepostDraft(
106 targetEventId: string,
107 targetPubkey: string,
108 embeddedContent?: string
109 ): DraftEvent {
110 return {
111 kind: kinds.Repost,
112 content: embeddedContent || '',
113 created_at: Timestamp.now().unix,
114 tags: [
115 ['e', targetEventId],
116 ['p', targetPubkey]
117 ]
118 }
119 }
120
121 /**
122 * Create a draft follow list event from a FollowList aggregate
123 */
124 createFollowListDraft(followList: FollowList): DraftEvent {
125 return followList.toDraftEvent()
126 }
127
128 /**
129 * Create a draft mute list event from a MuteList aggregate
130 * Note: The caller must handle encryption of private mutes
131 */
132 createMuteListDraft(muteList: MuteList, encryptedPrivateMutes: string = ''): DraftEvent {
133 return muteList.toDraftEvent(encryptedPrivateMutes)
134 }
135
136 /**
137 * Create a draft relay list event from a RelayList aggregate
138 */
139 createRelayListDraft(relayList: RelayList): DraftEvent {
140 return relayList.toDraftEvent()
141 }
142
143 /**
144 * Extract mentions from note content
145 * Finds npub1, nprofile1, and @mentions
146 */
147 extractMentionsFromContent(content: string): Pubkey[] {
148 const mentions: Pubkey[] = []
149 const seen = new Set<string>()
150
151 // Match npub1 and nprofile1
152 const bech32Regex = /n(?:pub1|profile1)[0-9a-z]{58,}/gi
153 const matches = content.match(bech32Regex) || []
154
155 for (const match of matches) {
156 const pubkey = Pubkey.tryFromString(match)
157 if (pubkey && !seen.has(pubkey.hex)) {
158 seen.add(pubkey.hex)
159 mentions.push(pubkey)
160 }
161 }
162
163 return mentions
164 }
165
166 /**
167 * Extract hashtags from note content
168 */
169 extractHashtagsFromContent(content: string): string[] {
170 const hashtags: string[] = []
171 const matches = content.match(/#[\p{L}\p{N}\p{M}]+/gu) || []
172
173 for (const match of matches) {
174 const hashtag = match.slice(1).toLowerCase()
175 if (hashtag && !hashtags.includes(hashtag)) {
176 hashtags.push(hashtag)
177 }
178 }
179
180 return hashtags
181 }
182 }
183
184 /**
185 * Singleton instance of the publishing service
186 */
187 export const publishingService = new PublishingService()
188