Social Features

Core platform features for user-to-user interaction: reactions (likes, favorites, bookmarks) and messaging. These are generic systems used by any interactive site -- the dating plugin and others add domain-specific behavior on top.


Reaction System

A polymorphic reaction system that works with any entity type. Supports likes, favorites, bookmarks, passes, and any other reaction type. Uses the same entity_type + entity_id pattern as EntityPhoto and ChangeTracking.

Spec: Reaction System Spec

Data Model

Table: rct_reactions

ColumnTypeDescription
rct_reaction_idint8, serial, PK
rct_usr_user_idint4, FKUser who reacted
rct_entity_typevarchar(50)'user', 'event', 'post', 'product', etc.
rct_entity_idint4ID of target entity
rct_reaction_typevarchar(20)'like', 'favorite', 'pass', 'bookmark' (default 'like')
rct_create_timetimestamp
rct_delete_timetimestampSoft delete (unreact)
Classes: Reaction (single), MultiReaction (collection) in data/reactions_class.php

Usage

Check if a user has reacted:

require_once(PathHelper::getIncludePath('data/reactions_class.php'));

$is_liked = Reaction::has_reacted($user_id, 'event', $event_id);

Toggle a reaction (react if not reacted, unreact if already reacted):

$result = Reaction::toggle($user_id, 'event', $event_id);
// $result = ['action' => 'reacted'|'unreacted', 'reaction' => $reaction_obj]

// With a specific reaction type:
$result = Reaction::toggle($user_id, 'post', $post_id, 'bookmark');

Get reaction count for an entity:

$count = Reaction::get_count('event', $event_id);

Get all entities a user has reacted to:

// All likes
$reactions = Reaction::get_user_reactions($user_id);

// Only event favorites
$favorites = Reaction::get_user_reactions($user_id, 'event', 'favorite');

Query with MultiReaction:

$reactions = new MultiReaction(
    ['entity_type' => 'event', 'entity_id' => $event_id, 'deleted' => false],
    ['rct_create_time' => 'DESC']
);
$reactions->load();

Reaction Button (UI Component)

Drop a reaction button into any view:

// Basic like button with count
Reaction::render_button('event', $event_id);

// Customized bookmark button
Reaction::render_button('post', $post_id, [
    'reaction_type' => 'bookmark',
    'show_count' => false,
    'icon_active' => 'fas fa-bookmark',
    'icon_inactive' => 'far fa-bookmark',
    'css_class' => 'btn-sm'
]);

The button handles AJAX toggling and state updates automatically. User must be logged in.

API Actions

The button's JS calls these POST /api/v1/action/{name} actions with the browser-session credential; each returns its payload inside the response envelope's data. Logged-in only.

ActionParamsdata payload
reaction_toggleentity_type, entity_id, reaction_type (opt){action, count}
reaction_statusentity_type, entity_id{reacted, count}
reaction_countentity_type, entity_id{count}

Entity Types

Any entity with a primary key can be reacted to. Common types:

entity_typeEntityTypical reaction_type
userUsers (dating, follows)like, pass, super_like
eventEventsfavorite, interested
postBlog postslike
productProductsfavorite, bookmark
locationLocationsfavorite
Plugins can introduce new entity types and reaction types without schema changes.


Messaging / Conversations

Threaded user-to-user messaging with conversation grouping, read status, and unread counts. The messaging system is a core API that any plugin can use -- for example, the dating plugin creates conversations automatically when users match.

Spec: Messaging Enhancements Spec

Data Model

Table: cnv_conversations

ColumnTypeDescription
cnv_conversation_idint8, serial, PK
cnv_subjectvarchar(255)Optional subject line (nullable)
cnv_create_timetimestamp
cnv_update_timetimestamp
cnv_delete_timetimestampSoft delete
Table: cnp_conversation_participants

ColumnTypeDescription
cnp_conversation_participant_idint8, serial, PK
cnp_cnv_conversation_idint8, FK
cnp_usr_user_idint4, FK
cnp_last_read_timetimestampMessages after this time are unread
cnp_is_mutedboolSuppresses notifications (default false)
cnp_create_timetimestamp
cnp_delete_timetimestampUser "deleted" conversation for themselves
Unique constraint on (cnp_cnv_conversation_id, cnp_usr_user_id).

Table: msg_messages (existing, modified)

Added column:

ColumnTypeDescription
msg_cnv_conversation_idint8, FKLinks message to a conversation (nullable for legacy broadcast messages)
Classes: Conversation, MultiConversation in data/conversations_class.php; ConversationParticipant, MultiConversationParticipant in data/conversation_participants_class.php

Required index: (msg_cnv_conversation_id, msg_sent_time DESC) on msg_messages for efficient inbox and conversation queries.

Usage

Get or create a 1:1 conversation and send a message:

require_once(PathHelper::getIncludePath('data/conversations_class.php'));

$conversation = Conversation::get_or_create_conversation($sender_user_id, $recipient_user_id);
$message = $conversation->add_message($sender_user_id, 'Hey, are you coming to the event?');

get_or_create_conversation() returns the existing conversation if one already exists between the two users. add_message() creates the message, clears cnp_delete_time for all participants (resurfaces deleted conversations), and creates notifications for other participants (unless muted).

Create a conversation with explicit participant list:

$conversation = Conversation::create_conversation([$user_id_1, $user_id_2], 'Optional subject');

Check unread conversation count for a user:

$unread = Conversation::get_unread_count($user_id);

This is session-cached in $_SESSION['message_unread_count'] — the header icon reads from cache and only queries the database on cache miss.

Get the other participant in a 1:1 conversation:

$other_user = $conversation->get_other_participant($current_user_id);
// Returns User object

Check if a user is in a conversation:

if ($conversation->has_participant($user_id)) {
    // user can view/send messages
}

Load messages in a conversation:

require_once(PathHelper::getIncludePath('data/messages_class.php'));

$messages = new MultiMessage(
    ['conversation_id' => $conversation->key, 'deleted' => false],
    ['msg_sent_time' => 'ASC'],
    50  // limit
);
$messages->load();

Mark a conversation as read:

// Load the participant row for this user
$participants = new MultiConversationParticipant(
    ['conversation_id' => $conversation->key, 'user_id' => $current_user_id]
);
$participants->load();
$participant = $participants->get(0);
$participant->set('cnp_last_read_time', gmdate('Y-m-d H:i:s'));
$participant->save();

// Invalidate session cache
$_SESSION['message_unread_count'] = null;

Blocking

The platform has no user-blocking system. There is no block model, no block table, and no way for one user to block another.

Conversation::get_or_create_conversation() and add_message() each contain a class_exists('UserBlock') branch that would reject a blocked pair. No such class exists, so the branch never runs and the messaging API performs no block checks at all. The branch is the integration point if blocking is ever built.

*A caller that needs to restrict who may message whom must enforce it itself, before calling the messaging API.* The messaging system will not do it.

Plugin Usage

Plugins use the Conversation API directly from their own logic. For example, the dating plugin:

// In plugins/dating/logic/match_logic.php — on mutual like
require_once(PathHelper::getIncludePath('data/conversations_class.php'));

$conversation = Conversation::get_or_create_conversation($user_id_1, $user_id_2);
// Conversation is now ready for the matched users to message in

Plugins that want to restrict who can message whom (e.g., dating match-only messaging) handle that in their own routes and logic before calling the messaging API. The messaging system itself has no gating -- it's just an API for creating conversations and sending messages.

API Surface

The messaging surface is /api/v1 actions (session-key auth: browser-session credential for the web page, native session key for the apps) — both the web conversation page and the iOS native member surface (ios/joinery-kit/Sources/JoineryMemberKit) call these actions directly, so a conversation opened in either place is the same thread:

ActionParamsReturns
conversation_listoffset (20/page)Paginated inbox: id, other-participant display name, preview, last-message time, unread flag, muted flag
conversation_threadconversation_id OR to (compose-mode dedup — no existing 1:1 returns is_compose_mode: true with an empty message list); before/after ISO UTC cursors (50/page)One conversation's messages (id, sender id, body, time, is_mine); marks the conversation read as a side effect
conversation_sendbody + (conversation_id OR to)The created message (conversation_id, message_id, body, sent_time)
conversation_actionconversation_id, action (mute / unmute / delete){conversation_id, action}
All four require a session and re-check participation on every call — send/thread/action verify the caller is sender or recipient via the ConversationParticipant row (cnp_usr_user_id) before touching anything. conversation_send with to calls get_or_create_conversation() automatically, matching an existing 1:1 or starting one.

Header Icon

An envelope icon with unread count badge appears in the header between the cart and notification bell icons. The count is session-cached ($_SESSION['message_unread_count']) and recomputed on cache miss via Conversation::get_unread_count(). Cache is invalidated when sending, reading, or deleting conversations.

Public Routes

RouteViewDescription
/profile/conversationsviews/profile/conversations.phpInbox — conversation list with unread indicators
/profile/conversation?id=Nviews/profile/conversation.phpSingle conversation — message history + compose
/profile/conversation?new=1&to=Nviews/profile/conversation.phpCompose mode — new conversation with a user

Admin

RouteFileDescription
/admin/admin_conversationsadm/admin_conversations.phpBrowse all conversations (permission 8)
/admin/admin_conversation?id=Nadm/admin_conversation.phpView conversation + moderate (permission 8)
Admin actions: soft-delete individual messages, soft-delete entire conversation.

Settings

SettingTypeDefaultDescription
messaging_activebooltrueFeature toggle — disables all user-to-user messaging when false
Max message length is a class constant: Conversation::MAX_MESSAGE_LENGTH = 5000.