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
| Column | Type | Description |
|---|---|---|
rct_reaction_id | int8, serial, PK | |
rct_usr_user_id | int4, FK | User who reacted |
rct_entity_type | varchar(50) | 'user', 'event', 'post', 'product', etc. |
rct_entity_id | int4 | ID of target entity |
rct_reaction_type | varchar(20) | 'like', 'favorite', 'pass', 'bookmark' (default 'like') |
rct_create_time | timestamp | |
rct_delete_time | timestamp | Soft delete (unreact) |
Reaction (single), MultiReaction (collection) in data/reactions_class.phpUsage
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.
| Action | Params | data payload |
|---|---|---|
reaction_toggle | entity_type, entity_id, reaction_type (opt) | {action, count} |
reaction_status | entity_type, entity_id | {reacted, count} |
reaction_count | entity_type, entity_id | {count} |
Entity Types
Any entity with a primary key can be reacted to. Common types:
entity_type | Entity | Typical reaction_type |
|---|---|---|
user | Users (dating, follows) | like, pass, super_like |
event | Events | favorite, interested |
post | Blog posts | like |
product | Products | favorite, bookmark |
location | Locations | favorite |
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
| Column | Type | Description |
|---|---|---|
cnv_conversation_id | int8, serial, PK | |
cnv_subject | varchar(255) | Optional subject line (nullable) |
cnv_create_time | timestamp | |
cnv_update_time | timestamp | |
cnv_delete_time | timestamp | Soft delete |
cnp_conversation_participants| Column | Type | Description |
|---|---|---|
cnp_conversation_participant_id | int8, serial, PK | |
cnp_cnv_conversation_id | int8, FK | |
cnp_usr_user_id | int4, FK | |
cnp_last_read_time | timestamp | Messages after this time are unread |
cnp_is_muted | bool | Suppresses notifications (default false) |
cnp_create_time | timestamp | |
cnp_delete_time | timestamp | User "deleted" conversation for themselves |
(cnp_cnv_conversation_id, cnp_usr_user_id).Table: msg_messages (existing, modified)
Added column:
| Column | Type | Description |
|---|---|---|
msg_cnv_conversation_id | int8, FK | Links message to a conversation (nullable for legacy broadcast messages) |
Conversation, MultiConversation in data/conversations_class.php; ConversationParticipant, MultiConversationParticipant in data/conversation_participants_class.phpRequired 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 objectCheck 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 inPlugins 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:
| Action | Params | Returns |
|---|---|---|
conversation_list | offset (20/page) | Paginated inbox: id, other-participant display name, preview, last-message time, unread flag, muted flag |
conversation_thread | conversation_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_send | body + (conversation_id OR to) | The created message (conversation_id, message_id, body, sent_time) |
conversation_action | conversation_id, action (mute / unmute / delete) | {conversation_id, action} |
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
| Route | View | Description |
|---|---|---|
/profile/conversations | views/profile/conversations.php | Inbox — conversation list with unread indicators |
/profile/conversation?id=N | views/profile/conversation.php | Single conversation — message history + compose |
/profile/conversation?new=1&to=N | views/profile/conversation.php | Compose mode — new conversation with a user |
Admin
| Route | File | Description |
|---|---|---|
/admin/admin_conversations | adm/admin_conversations.php | Browse all conversations (permission 8) |
/admin/admin_conversation?id=N | adm/admin_conversation.php | View conversation + moderate (permission 8) |
Settings
| Setting | Type | Default | Description |
|---|---|---|---|
messaging_active | bool | true | Feature toggle — disables all user-to-user messaging when false |
Conversation::MAX_MESSAGE_LENGTH = 5000.