Reaction.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { EventId, Pubkey, Timestamp } from '../shared'
3
4 /**
5 * Type of reaction
6 */
7 export type ReactionType = 'like' | 'dislike' | 'emoji' | 'custom_emoji'
8
9 /**
10 * Custom emoji data
11 */
12 export type CustomEmoji = {
13 shortcode: string
14 url: string
15 }
16
17 /**
18 * Reaction Entity
19 *
20 * Represents a reaction (kind 7) to a Nostr event.
21 * Reactions can be likes (+), dislikes (-), emojis, or custom emojis.
22 */
23 export class Reaction {
24 private constructor(
25 private readonly _event: Event,
26 private readonly _targetEventId: EventId,
27 private readonly _targetAuthor: Pubkey,
28 private readonly _type: ReactionType,
29 private readonly _emoji: string,
30 private readonly _customEmoji?: CustomEmoji
31 ) {}
32
33 /**
34 * Create a Reaction from a Nostr Event
35 */
36 static fromEvent(event: Event): Reaction {
37 if (event.kind !== kinds.Reaction) {
38 throw new Error(`Expected kind ${kinds.Reaction}, got ${event.kind}`)
39 }
40
41 // Find the target event (last 'e' tag)
42 const eTags = event.tags.filter((t) => t[0] === 'e')
43 const lastETag = eTags[eTags.length - 1]
44 if (!lastETag?.[1]) {
45 throw new Error('Reaction must have an e tag')
46 }
47
48 // Find the target author (last 'p' tag)
49 const pTags = event.tags.filter((t) => t[0] === 'p')
50 const lastPTag = pTags[pTags.length - 1]
51 if (!lastPTag?.[1]) {
52 throw new Error('Reaction must have a p tag')
53 }
54
55 const targetEventId = EventId.fromHex(lastETag[1])
56 const targetAuthor = Pubkey.fromHex(lastPTag[1])
57
58 // Determine reaction type
59 const content = event.content
60 let type: ReactionType
61 let emoji = content
62 let customEmoji: CustomEmoji | undefined
63
64 if (content === '+' || content === '') {
65 type = 'like'
66 emoji = '+'
67 } else if (content === '-') {
68 type = 'dislike'
69 } else if (content.startsWith(':') && content.endsWith(':')) {
70 // Custom emoji format :shortcode:
71 const emojiTag = event.tags.find(
72 (t) => t[0] === 'emoji' && t[1] === content.slice(1, -1)
73 )
74 if (emojiTag?.[2]) {
75 type = 'custom_emoji'
76 customEmoji = {
77 shortcode: emojiTag[1],
78 url: emojiTag[2]
79 }
80 } else {
81 type = 'emoji'
82 }
83 } else {
84 type = 'emoji'
85 }
86
87 return new Reaction(event, targetEventId, targetAuthor, type, emoji, customEmoji)
88 }
89
90 /**
91 * Try to create a Reaction from an Event, returns null if invalid
92 */
93 static tryFromEvent(event: Event | null | undefined): Reaction | null {
94 if (!event) return null
95 try {
96 return Reaction.fromEvent(event)
97 } catch {
98 return null
99 }
100 }
101
102 /**
103 * The underlying Nostr event
104 */
105 get event(): Event {
106 return this._event
107 }
108
109 /**
110 * The reaction's event ID
111 */
112 get id(): EventId {
113 return EventId.fromHex(this._event.id)
114 }
115
116 /**
117 * The author of the reaction
118 */
119 get author(): Pubkey {
120 return Pubkey.fromHex(this._event.pubkey)
121 }
122
123 /**
124 * The event ID being reacted to
125 */
126 get targetEventId(): EventId {
127 return this._targetEventId
128 }
129
130 /**
131 * The author of the event being reacted to
132 */
133 get targetAuthor(): Pubkey {
134 return this._targetAuthor
135 }
136
137 /**
138 * The type of reaction
139 */
140 get type(): ReactionType {
141 return this._type
142 }
143
144 /**
145 * The emoji or reaction content
146 */
147 get emoji(): string {
148 return this._emoji
149 }
150
151 /**
152 * Custom emoji data (if applicable)
153 */
154 get customEmoji(): CustomEmoji | undefined {
155 return this._customEmoji
156 }
157
158 /**
159 * When the reaction was created
160 */
161 get createdAt(): Timestamp {
162 return Timestamp.fromUnix(this._event.created_at)
163 }
164
165 /**
166 * Whether this is a like
167 */
168 get isLike(): boolean {
169 return this._type === 'like'
170 }
171
172 /**
173 * Whether this is a dislike
174 */
175 get isDislike(): boolean {
176 return this._type === 'dislike'
177 }
178
179 /**
180 * Whether this is a positive reaction (like or emoji, not dislike)
181 */
182 get isPositive(): boolean {
183 return this._type !== 'dislike'
184 }
185
186 /**
187 * Whether this uses a custom emoji
188 */
189 get hasCustomEmoji(): boolean {
190 return this._type === 'custom_emoji' && !!this._customEmoji
191 }
192
193 /**
194 * Get the display value for the reaction
195 */
196 get displayValue(): string {
197 if (this._type === 'like') return '❤️'
198 if (this._type === 'dislike') return '👎'
199 if (this._customEmoji) return `:${this._customEmoji.shortcode}:`
200 return this._emoji
201 }
202 }
203