# User Engagement Skill

> **Purpose:** Social features including reactions, subscriptions, notifications, and messaging
> **When to use:** Any task involving user engagement features
> **Related skills:** wordpress-plugin-foundation-skill.md (always applies)

---

## Quick Reference

```php
// Add reaction
$reactions->addReaction($post_id, $user_id, 'like');

// Subscribe to thread
$subscriptions->subscribe($thread_id, $user_id);

// Create notification
$notifications->createAlert(
    $user_id,
    'post',
    $post_id,
    $action_user_id,
    'reply'
);

// Send message
$messaging->startConversation($user_id, [$recipient_id], $title, $message);
```

---

## Reaction System

### Reaction Types

```php
private const REACTION_TYPES = [
    'like' => 1,      // +1 point
    'love' => 2,      // +2 points
    'haha' => 1,      // +1 point
    'wow' => 1,       // +1 point
    'sad' => 0,       // 0 points
    'angry' => -1,    // -1 point
    'upvote' => 1,    // +1 point
    'downvote' => -1, // -1 point
];
```

### Add/Remove Reaction

```php
public function toggleReaction(int $postId, int $userId, string $reactionType): array
{
    // Check if already reacted
    $existing = $this->getReaction($postId, $userId);
    
    if ($existing) {
        if ($existing['reaction_type'] === $reactionType) {
            // Remove reaction
            $this->removeReaction($postId, $userId);
            return ['action' => 'removed'];
        } else {
            // Change reaction
            $this->updateReaction($postId, $userId, $reactionType);
            return ['action' => 'changed'];
        }
    }
    
    // Add new reaction
    $this->addReaction($postId, $userId, $reactionType);
    return ['action' => 'added'];
}

private function addReaction(int $postId, int $userId, string $reactionType): void
{
    global $wpdb;
    
    $wpdb->insert(
        $wpdb->prefix . 'presszone_forum_reactions',
        [
            'content_type' => 'post',
            'content_id' => $postId,
            'user_id' => $userId,
            'reaction_type' => $reactionType,
            'created_at' => current_time('mysql'),
        ],
        ['%s', '%d', '%d', '%s', '%s']
    );
    
    // Update post stats
    $this->updatePostReactionStats($postId);
    
    // Update user reputation
    $post = $this->getPost($postId);
    $points = self::REACTION_TYPES[$reactionType] ?? 0;
    $this->updateUserReputation($post['user_id'], $points);
}
```

### Self-Reaction Prevention

```php
public function canReact(int $postId, int $userId): bool
{
    // Cannot react to own posts
    $post = $this->getPost($postId);
    if ($post['user_id'] === $userId) {
        return false;
    }
    
    // Check daily limit
    $dailyLimit = (int) get_option('presszone_forum_daily_vote_limit', 0);
    if ($dailyLimit > 0 && $this->getDailyReactionCount($userId) >= $dailyLimit) {
        return false;
    }
    
    return true;
}
```

---

## Subscription System

### Subscribe/Unsubscribe

```php
public function subscribe(int $threadId, int $userId): bool
{
    global $wpdb;
    
    if ($this->isSubscribed($threadId, $userId)) {
        return false;
    }
    
    return (bool) $wpdb->insert(
        $wpdb->prefix . 'presszone_forum_subscriptions',
        [
            'thread_id' => $threadId,
            'user_id' => $userId,
            'subscribed_date' => current_time('mysql'),
        ],
        ['%d', '%d', '%s']
    );
}

public function unsubscribe(int $threadId, int $userId): bool
{
    global $wpdb;
    
    return (bool) $wpdb->delete(
        $wpdb->prefix . 'presszone_forum_subscriptions',
        [
            'thread_id' => $threadId,
            'user_id' => $userId,
        ],
        ['%d', '%d']
    );
}

public function toggle(int $threadId, int $userId): bool
{
    if ($this->isSubscribed($threadId, $userId)) {
        $this->unsubscribe($threadId, $userId);
        return false;  // Now unsubscribed
    } else {
        $this->subscribe($threadId, $userId);
        return true;   // Now subscribed
    }
}
```

### Notify Subscribers

```php
public function notifySubscribers(int $postId, array $postData): void
{
    $threadId = (int) ($postData['thread_id'] ?? 0);
    $authorId = (int) ($postData['user_id'] ?? 0);
    
    // Get subscribers (excluding post author)
    $subscribers = $this->getSubscribers($threadId, $authorId);
    
    foreach ($subscribers as $subscriberId) {
        // Create in-app notification
        $this->notifications->createAlert(
            $subscriberId,
            'post',
            $postId,
            $authorId,
            'reply'
        );
        
        // Send email notification
        $this->sendEmailNotification($subscriberId, $postId);
    }
}

private function getSubscribers(int $threadId, int $excludeUserId = 0): array
{
    global $wpdb;
    
    $sql = $wpdb->prepare(
        "SELECT user_id FROM {$wpdb->prefix}presszone_forum_subscriptions 
         WHERE thread_id = %d AND user_id != %d",
        $threadId,
        $excludeUserId
    );
    
    return $wpdb->get_col($sql);
}
```

---

## Notification System

### Alert Types

```php
private const ALERT_TYPES = [
    'reply',      // Thread reply
    'quote',      // Post quoted
    'mention',    // @mention
    'reaction',   // Post reacted
    'follow',     // User followed
];
```

### Create Alert

```php
public function createAlert(
    int $userId,
    string $contentType,
    int $contentId,
    int $actionUserId,
    string $actionType
): void {
    global $wpdb;
    
    // Don't notify yourself
    if ($userId === $actionUserId) {
        return;
    }
    
    $wpdb->insert(
        $wpdb->prefix . 'presszone_forum_alerts',
        [
            'user_id' => $userId,
            'content_type' => $contentType,
            'content_id' => $contentId,
            'action_user_id' => $actionUserId,
            'action_type' => $actionType,
            'created_at' => current_time('mysql'),
        ],
        ['%d', '%s', '%d', '%d', '%s', '%s']
    );
}
```

### Get Notifications

```php
public function getNotifications(int $userId, int $limit = 50): array
{
    global $wpdb;
    
    $alerts = $wpdb->get_results($wpdb->prepare(
        "SELECT 
            a.*,
            u.display_name as action_user_name,
            u.user_email as action_user_email
         FROM {$wpdb->prefix}presszone_forum_alerts a
         LEFT JOIN {$wpdb->users} u ON a.action_user_id = u.ID
         WHERE a.user_id = %d
         ORDER BY a.created_at DESC
         LIMIT %d",
        $userId,
        $limit
    ), ARRAY_A);
    
    // Format alerts
    return array_map(function($alert) {
        return [
            'id' => (int) $alert['alert_id'],
            'message' => $this->formatAlertMessage($alert),
            'url' => $this->getAlertUrl($alert),
            'is_read' => !empty($alert['read_date']),
            'created_at' => $alert['created_at'],
        ];
    }, $alerts);
}

private function formatAlertMessage(array $alert): string
{
    $userName = esc_html($alert['action_user_name'] ?? __('Someone', 'forum-press-zone'));
    
    switch ($alert['action_type']) {
        case 'reply':
            return sprintf(__('%s replied to a thread you\'re watching', 'forum-press-zone'), $userName);
        case 'quote':
            return sprintf(__('%s quoted your post', 'forum-press-zone'), $userName);
        case 'mention':
            return sprintf(__('%s mentioned you in a post', 'forum-press-zone'), $userName);
        case 'reaction':
            return sprintf(__('%s reacted to your post', 'forum-press-zone'), $userName);
        default:
            return sprintf(__('Notification from %s', 'forum-press-zone'), $userName);
    }
}
```

### Mark as Read

```php
public function markAsRead(int $userId, array $alertIds): void
{
    global $wpdb;
    
    if (empty($alertIds)) {
        return;
    }
    
    $ids = array_map('absint', $alertIds);
    $placeholders = implode(',', array_fill(0, count($ids), '%d'));
    
    $wpdb->query($wpdb->prepare(
        "UPDATE {$wpdb->prefix}presszone_forum_alerts
         SET read_date = %s
         WHERE user_id = %d AND alert_id IN ($placeholders) AND read_date IS NULL",
        current_time('mysql'),
        $userId,
        ...$ids
    ));
}

public function markAllAsRead(int $userId): void
{
    global $wpdb;
    
    $wpdb->update(
        $wpdb->prefix . 'presszone_forum_alerts',
        ['read_date' => current_time('mysql')],
        ['user_id' => $userId, 'read_date' => null],
        ['%s'],
        ['%d', '%s']
    );
}
```

---

## Messaging System

### Start Conversation

```php
public function startConversation(
    int $userId,
    array $recipientIds,
    string $title,
    string $message
): int {
    global $wpdb;
    
    // Create conversation
    $wpdb->insert(
        $wpdb->prefix . 'presszone_forum_conversations',
        [
            'title' => $title,
            'created_by' => $userId,
            'created_at' => current_time('mysql'),
        ],
        ['%s', '%d', '%s']
    );
    
    $conversationId = $wpdb->insert_id;
    
    // Add participants
    $this->addParticipants($conversationId, array_merge([$userId], $recipientIds));
    
    // Add first message
    $this->addMessage($conversationId, $userId, $message);
    
    return $conversationId;
}

private function addParticipants(int $conversationId, array $userIds): void
{
    global $wpdb;
    
    foreach ($userIds as $userId) {
        $wpdb->insert(
            $wpdb->prefix . 'presszone_forum_conversation_participants',
            [
                'conversation_id' => $conversationId,
                'user_id' => $userId,
                'joined_at' => current_time('mysql'),
            ],
            ['%d', '%d', '%s']
        );
    }
}
```

### Send Message

```php
public function reply(int $conversationId, int $userId, string $message): int
{
    global $wpdb;
    
    // Verify user is participant
    if (!$this->isParticipant($conversationId, $userId)) {
        throw new \Exception('Not a participant');
    }
    
    $wpdb->insert(
        $wpdb->prefix . 'presszone_forum_conversation_messages',
        [
            'conversation_id' => $conversationId,
            'user_id' => $userId,
            'message' => $message,
            'message_date' => current_time('mysql'),
        ],
        ['%d', '%d', '%s', '%s']
    );
    
    $messageId = $wpdb->insert_id;
    
    // Update conversation last message
    $wpdb->update(
        $wpdb->prefix . 'presszone_forum_conversations',
        [
            'last_message_id' => $messageId,
            'last_message_date' => current_time('mysql'),
        ],
        ['conversation_id' => $conversationId],
        ['%d', '%s'],
        ['%d']
    );
    
    return $messageId;
}
```

### Get Messages

```php
public function getMessages(
    int $conversationId,
    int $userId,
    int $page = 1,
    int $perPage = 50
): array {
    global $wpdb;
    
    // Verify participant
    if (!$this->isParticipant($conversationId, $userId)) {
        throw new \Exception('Not a participant');
    }
    
    $offset = ($page - 1) * $perPage;
    
    $messages = $wpdb->get_results($wpdb->prepare(
        "SELECT 
            m.*,
            u.display_name as author_name
         FROM {$wpdb->prefix}presszone_forum_conversation_messages m
         LEFT JOIN {$wpdb->users} u ON m.user_id = u.ID
         WHERE m.conversation_id = %d
         ORDER BY m.message_date ASC
         LIMIT %d OFFSET %d",
        $conversationId,
        $perPage,
        $offset
    ), ARRAY_A);
    
    return array_map(function($msg) use ($userId) {
        return [
            'id' => (int) $msg['message_id'],
            'content' => wp_kses_post($msg['message']),
            'author' => [
                'id' => (int) $msg['user_id'],
                'name' => esc_html($msg['author_name']),
            ],
            'is_own' => (int) $msg['user_id'] === $userId,
            'sent_at' => $msg['message_date'],
        ];
    }, $messages);
}
```

### Polling for New Messages

```php
public function getMessagesSince(int $conversationId, int $since): array
{
    global $wpdb;
    
    $sinceDate = gmdate('Y-m-d H:i:s', $since);
    
    return $wpdb->get_results($wpdb->prepare(
        "SELECT m.*, u.display_name AS author_name
         FROM {$wpdb->prefix}presszone_forum_conversation_messages m
         LEFT JOIN {$wpdb->users} u ON m.user_id = u.ID
         WHERE m.conversation_id = %d AND m.message_date > %s
         ORDER BY m.message_date ASC
         LIMIT 100",
        $conversationId,
        $sinceDate
    ), ARRAY_A);
}
```

---

## Frontend Patterns

### Polling Implementation

```javascript
class MessagePoller {
    constructor(conversationId, callback) {
        this.conversationId = conversationId;
        this.callback = callback;
        this.lastMessageTime = Math.floor(Date.now() / 1000);
        this.pollTimer = null;
        this.interval = 5000; // 5 seconds
    }
    
    start() {
        this.stop();
        this.pollTimer = setInterval(() => this.poll(), this.interval);
    }
    
    stop() {
        if (this.pollTimer) {
            clearInterval(this.pollTimer);
            this.pollTimer = null;
        }
    }
    
    async poll() {
        try {
            const response = await fetch(
                `${CONFIG.restUrl}/messenger/conversations/${this.conversationId}?since=${this.lastMessageTime}`,
                {
                    headers: { 'X-WP-Nonce': CONFIG.nonce },
                    credentials: 'same-origin'
                }
            );
            
            const data = await response.json();
            
            if (data.messages && data.messages.length > 0) {
                this.lastMessageTime = Math.floor(Date.now() / 1000);
                this.callback(data.messages);
            }
        } catch (error) {
            console.error('Polling failed:', error);
        }
    }
}

// Usage
const poller = new MessagePoller(conversationId, (newMessages) => {
    newMessages.forEach(msg => appendMessage(msg));
});

poller.start();
```

---

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Allowing self-reactions | Check `$post['user_id'] !== $userId` |
| Not excluding author from notifications | Pass `$excludeUserId` to getSubscribers |
| Missing participant check | Verify `isParticipant()` before message access |
| Not updating conversation last_message | Update on every new message |
| Polling too frequently | Use 5-10 second intervals |
| Not cleaning up pollers | Stop polling when component unmounts |
| Missing rate limiting | Implement rate limits on reactions/messages |
| Not sanitizing message content | Use `wp_kses_post()` |

---

## Integration with Other Skills

- **wordpress-plugin-foundation-skill.md** - Security and compliance (always applies)
- **php-skill.md** - PHP patterns
- **sql-skill.md** - Database queries
- **rest-api-skill.md** - API endpoints
- **javascript-skill.md** - Frontend polling and UI
