array( 'type' => 'integer', 'required' => true, ), ); register_rest_route( $this->namespace, '/me/pulse', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_pulse' ), 'permission_callback' => array( $this, 'permission_logged_in' ), ), ) ); register_rest_route( $this->namespace, '/conversations', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => $this->paging_args( 50 ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => array( 'user_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'required' => true, 'minItems' => 1, 'maxItems' => 100, ), 'title' => array( 'type' => 'string', 'default' => '', 'maxLength' => 100, ), 'group' => array( 'type' => 'boolean', 'default' => false, ), ), ), ) ); register_rest_route( $this->namespace, '/conversations/(?P\d+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'permission_messages' ), ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => array( 'title' => array( 'type' => 'string', 'maxLength' => 100, ), 'muted' => array( 'type' => 'boolean' ), ), ), ) ); register_rest_route( $this->namespace, '/conversations/(?P\d+)/members', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'add_members' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => array( 'user_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'required' => true, 'minItems' => 1, 'maxItems' => 100, ), ), ), ) ); register_rest_route( $this->namespace, '/conversations/(?P\d+)/members/(?P\d+)', array( array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'remove_member' ), 'permission_callback' => array( $this, 'permission_messages' ), ), ) ); register_rest_route( $this->namespace, '/conversations/(?P\d+)/messages', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_messages' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => array( 'before' => array( 'type' => 'integer', 'default' => 0, ), 'after' => array( 'type' => 'integer', 'default' => 0, ), 'limit' => array( 'type' => 'integer', 'default' => 30, 'minimum' => 1, 'maximum' => 100, ), ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_message' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => array( 'message' => array( 'type' => 'string', 'default' => '', ), 'media_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'default' => array(), 'maxItems' => 10, ), ), ), ) ); register_rest_route( $this->namespace, '/conversations/(?P\d+)/read', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'mark_read' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => array( 'message_id' => array( 'type' => 'integer', 'required' => true, ), ), ), ) ); register_rest_route( $this->namespace, '/messages/(?P\d+)', array( array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_message' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => $id, ), ) ); register_rest_route( $this->namespace, '/messages/recipients', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_recipients' ), 'permission_callback' => array( $this, 'permission_messages' ), 'args' => array( 'search' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), ), ), ) ); register_rest_route( $this->namespace, '/me/blocks', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_blocks' ), 'permission_callback' => array( $this, 'permission_logged_in' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'set_block' ), 'permission_callback' => array( $this, 'permission_logged_in' ), 'args' => array( 'user_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), 'blocked' => array( 'type' => 'boolean', 'required' => true, ), ), ), ) ); } /** * Requires a member who may use messages. * * @return true|WP_Error */ public function permission_messages() { $logged_in = $this->permission_logged_in(); if ( is_wp_error( $logged_in ) ) { return $logged_in; } if ( ! Rules::enabled() ) { return new WP_Error( 'spacebay_messages_off', __( 'Private messages are turned off in this community.', 'spacebay' ), array( 'status' => 403 ) ); } return true; } /** * Unread counts, polled by the apps. Add-ons add their own data (ringing calls). * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_pulse( $request ) { unset( $request ); $user_id = get_current_user_id(); $data = array( 'messages' => Rules::enabled() ? Conversations::unread_total( $user_id ) : 0, 'notifications' => Notifications::unread_count( $user_id ), ); /** * Filters the data of the pulse endpoint that the apps poll. * * @since 1.0.0 * * @param array $data Unread counts. * @param int $user_id Member. */ $response = rest_ensure_response( apply_filters( 'spacebay_pulse', $data, $user_id ) ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * Lists the member's conversations. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_items( $request ) { $user_id = get_current_user_id(); $result = Conversations::for_user( $user_id, $request['page'], $request['per_page'], $request['search'] ); return $this->paged_response( $this->format_conversations( $result['items'], $user_id ), $result['total'], $request['per_page'] ); } /** * Opens a one-to-one chat or starts a group chat. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_item( $request ) { $user_id = get_current_user_id(); $others = array_values( array_diff( array_unique( array_map( 'absint', (array) $request['user_ids'] ) ), array( $user_id, 0 ) ) ); if ( ! Rules::can_use( $user_id ) ) { return $this->forbidden(); } if ( ! $others ) { return new WP_Error( 'spacebay_no_recipients', __( 'Choose who you want to message.', 'spacebay' ), array( 'status' => 400 ) ); } $conversation = 1 === count( $others ) && ! $request['group'] ? Conversations::direct( $user_id, $others[0] ) : Conversations::create_group( $user_id, $others, (string) $request['title'] ); if ( is_wp_error( $conversation ) ) { return $conversation; } return rest_ensure_response( $this->format_conversations( array( $this->with_membership( $conversation, $user_id ) ), $user_id )[0] ); } /** * One conversation. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_item( $request ) { $conversation = $this->own_conversation( $request['id'] ); if ( is_wp_error( $conversation ) ) { return $conversation; } return rest_ensure_response( $this->format_conversations( array( $conversation ), get_current_user_id() )[0] ); } /** * Renames a group or mutes a conversation. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_item( $request ) { $user_id = get_current_user_id(); $conversation = $this->own_conversation( $request['id'] ); if ( is_wp_error( $conversation ) ) { return $conversation; } if ( isset( $request['title'] ) && 'group' === $conversation['type'] ) { Conversations::rename( $conversation, $user_id, $request['title'] ); } if ( isset( $request['muted'] ) ) { Conversations::set_muted( $conversation['id'], $user_id, (bool) $request['muted'] ); } return $this->get_item( $request ); } /** * Adds people to a group. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function add_members( $request ) { $conversation = $this->own_conversation( $request['id'] ); if ( is_wp_error( $conversation ) ) { return $conversation; } $result = Conversations::add_members( $conversation, get_current_user_id(), (array) $request['user_ids'] ); return is_wp_error( $result ) ? $result : $this->get_item( $request ); } /** * Removes someone from a group, or leaves a conversation. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function remove_member( $request ) { $conversation = $this->own_conversation( $request['id'] ); if ( is_wp_error( $conversation ) ) { return $conversation; } $result = Conversations::remove_member( $conversation, get_current_user_id(), absint( $request['user_id'] ) ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( array( 'removed' => true ) ); } /** * Messages of a conversation. Marks nothing as read. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_messages( $request ) { $user_id = get_current_user_id(); $conversation = $this->own_conversation( $request['id'] ); if ( is_wp_error( $conversation ) ) { return $conversation; } $result = Messages::for_conversation( $conversation['id'], $request['before'], $request['after'], $request['limit'], Messages::since( $conversation, $user_id ) ); $members = Conversations::members( $conversation['id'] ); $read = array(); // Read receipts: the last message each other member has seen. foreach ( $members as $member ) { if ( $member['user_id'] !== $user_id ) { $read[] = array( 'user_id' => $member['user_id'], 'last_read_id' => $member['last_read_id'], ); } } $data = array( 'items' => Messages::format_many( $result['items'], $user_id ), 'has_more' => $result['has_more'], 'read' => $read, ); /** * Filters a page of a conversation's messages. * * @since 1.0.0 * * @param array $data Messages, has_more and read receipts. * @param array $conversation Conversation row. * @param int $user_id Viewer. */ $response = rest_ensure_response( apply_filters( 'spacebay_conversation_messages', $data, $conversation, $user_id ) ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * Sends a message. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_message( $request ) { $user_id = get_current_user_id(); $conversation = $this->own_conversation( $request['id'] ); if ( is_wp_error( $conversation ) ) { return $conversation; } $message = Messages::create( $conversation, $user_id, array( 'message' => (string) $request['message'], 'media_ids' => (array) $request['media_ids'], ) ); if ( is_wp_error( $message ) ) { return $message; } return rest_ensure_response( Messages::format_many( array( $message ), $user_id )[0] ); } /** * Marks messages as read. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function mark_read( $request ) { $user_id = get_current_user_id(); $conversation = $this->own_conversation( $request['id'] ); if ( is_wp_error( $conversation ) ) { return $conversation; } Conversations::mark_read( $conversation['id'], $user_id, min( absint( $request['message_id'] ), $conversation['last_message_id'] ) ); return rest_ensure_response( array( 'unread' => Conversations::unread_total( $user_id ) ) ); } /** * Deletes one of the member's own messages. Moderators may delete any. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function delete_message( $request ) { $user_id = get_current_user_id(); $message = Messages::find( $request['id'] ); if ( ! $message || 'text' !== $message['type'] || ! Messages::visible_to( $message, $user_id ) ) { return new WP_Error( 'spacebay_message_not_found', __( 'Message not found.', 'spacebay' ), array( 'status' => 404 ) ); } if ( $message['user_id'] !== $user_id && ! Rules::is_staff( $user_id ) ) { return $this->forbidden(); } return rest_ensure_response( Messages::format_many( array( Messages::delete( $message ) ), $user_id )[0] ); } /** * Members the current member may message. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_recipients( $request ) { $user_id = get_current_user_id(); $items = array(); // Look through a few pages of the directory and keep the people the rules allow. for ( $page = 1; $page <= 4; $page++ ) { $found = MemberDirectory::query( array( 'search' => $request['search'], 'per_page' => 50, 'page' => $page, 'orderby' => 'name', ) ); foreach ( $found['items'] as $member ) { if ( Rules::can_message( $user_id, $member['id'] ) ) { $items[] = $member; } } if ( count( $items ) >= 12 || $page * 50 >= $found['total'] ) { break; } } $items = array_slice( $items, 0, 12 ); return rest_ensure_response( $items ); } /** * Members the current member has blocked. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_blocks( $request ) { unset( $request ); return rest_ensure_response( array_values( Formatter::authors( Rules::blocked_ids( get_current_user_id() ) ) ) ); } /** * Blocks or unblocks a member. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function set_block( $request ) { $user_id = get_current_user_id(); $other = absint( $request['user_id'] ); if ( $other === $user_id || ! get_userdata( $other ) ) { return new WP_Error( 'spacebay_member_not_found', __( 'Member not found.', 'spacebay' ), array( 'status' => 404 ) ); } if ( $request['blocked'] && Rules::is_staff( $other ) ) { return new WP_Error( 'spacebay_cannot_block_staff', __( 'Community managers and moderators cannot be blocked.', 'spacebay' ), array( 'status' => 400 ) ); } Rules::set_blocked( $user_id, $other, (bool) $request['blocked'] ); return rest_ensure_response( array( 'blocked' => Rules::has_blocked( $user_id, $other ), 'can_message' => Rules::can_message( $user_id, $other ), ) ); } /** * A conversation the current member takes part in, or a 404 error. * * @param int $id Conversation ID. * @return array|WP_Error Conversation with the member's own state. */ private function own_conversation( $id ) { $user_id = get_current_user_id(); $conversation = Conversations::find( $id ); if ( ! $conversation || ! Conversations::is_member( $conversation['id'], $user_id ) ) { return new WP_Error( 'spacebay_conversation_not_found', __( 'Conversation not found.', 'spacebay' ), array( 'status' => 404 ) ); } return $this->with_membership( $conversation, $user_id ); } /** * Adds the member's read position, mute and role to a conversation. * * @param array $conversation Conversation row. * @param int $user_id Member. * @return array */ private function with_membership( array $conversation, $user_id ) { $membership = Conversations::membership( $conversation['id'], $user_id ); $conversation['last_read_id'] = $membership ? $membership['last_read_id'] : 0; $conversation['is_muted'] = $membership ? $membership['is_muted'] : 0; $conversation['role'] = $membership ? $membership['role'] : 'member'; $conversation['joined_at'] = $membership ? $membership['joined_at'] : ''; return $conversation; } /** * Formats conversations for the apps. * * @param array[] $rows Conversation rows with membership fields. * @param int $user_id Viewer. * @return array[] */ private function format_conversations( array $rows, $user_id ) { if ( ! $rows ) { return array(); } $ids = wp_list_pluck( $rows, 'id' ); $unread = Conversations::unread( $user_id, $ids ); $members = array(); $people = array(); $last = array(); // One query for the members of every chat on the page and one for the last messages. $active = Conversations::member_ids_many( $ids ); $messages = Messages::find_many( wp_list_pluck( $rows, 'last_message_id' ) ); foreach ( $rows as $row ) { $members[ $row['id'] ] = $active[ $row['id'] ] ?? array(); if ( 'direct' === $row['type'] ) { $members[ $row['id'] ] = array_values( array_unique( array_merge( $members[ $row['id'] ], array( Messages::direct_other( $row, $user_id ) ) ) ) ); } $people = array_merge( $people, $members[ $row['id'] ] ); // People added to a group later do not see the message from before they joined. $hidden = 'group' === $row['type'] && isset( $messages[ $row['last_message_id'] ] ) && ! empty( $row['joined_at'] ) && $messages[ $row['last_message_id'] ]['created_at'] < $row['joined_at']; if ( $row['last_message_id'] && isset( $messages[ $row['last_message_id'] ] ) && ! $hidden ) { $last[ $row['id'] ] = $messages[ $row['last_message_id'] ]; } } $authors = Formatter::authors( array_merge( $people, wp_list_pluck( $last, 'user_id' ) ) ); $previews = array(); $formatted = $last ? Messages::format_many( array_values( $last ), $user_id ) : array(); foreach ( $formatted as $message ) { $previews[ $message['conversation_id'] ] = $message; } $items = array(); foreach ( $rows as $row ) { $others = array(); foreach ( $members[ $row['id'] ] as $member_id ) { if ( (int) $member_id !== (int) $user_id && isset( $authors[ $member_id ] ) ) { $others[] = $authors[ $member_id ]; } } $is_direct = 'direct' === $row['type']; $other_id = $is_direct ? Messages::direct_other( $row, $user_id ) : 0; $preview = $previews[ $row['id'] ] ?? null; $size = count( $active[ $row['id'] ] ?? array() ); $items[] = array( 'id' => $row['id'], 'type' => $row['type'], 'title' => $is_direct ? ( $others[0]['name'] ?? __( 'Deleted member', 'spacebay' ) ) : ( '' !== $row['title'] ? $row['title'] : implode( ', ', wp_list_pluck( array_slice( $others, 0, 3 ), 'name' ) ) ), 'has_title' => ! $is_direct && '' !== $row['title'], 'others' => $others, 'size' => $size, 'role' => $row['role'] ?? 'member', 'muted' => ! empty( $row['is_muted'] ), 'unread' => $unread[ $row['id'] ] ?? 0, 'last_read_id' => (int) ( $row['last_read_id'] ?? 0 ), 'last_message' => $preview ? array( 'id' => $preview['id'], 'text' => $this->preview_text( $preview ), 'is_mine' => $preview['is_mine'], 'is_event' => 'system' === $preview['type'], 'author' => $preview['author']['name'], 'created_at' => $preview['created_at'], ) : null, 'can_send' => $is_direct ? Rules::can_message( $user_id, $other_id ) : $size > 1, 'blocked' => $is_direct && Rules::has_blocked( $user_id, $other_id ), 'updated_at' => \SpaceBay\Support\Time::iso( $row['last_message_at'] ), ); } /** * Filters the formatted conversations of a list. * * @since 1.0.0 * * @param array[] $items Conversations. * @param int $user_id Viewer. */ $items = apply_filters( 'spacebay_conversation_items', $items, $user_id ); return $items; } /** * One-line preview of a message. * * @param array $message Formatted message. * @return string */ private function preview_text( array $message ) { if ( $message['deleted'] ) { return __( 'Message deleted', 'spacebay' ); } if ( isset( $message['text'] ) ) { return $message['text']; } $text = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $message['content'] ) ) ); if ( '' === $text && $message['media'] ) { return __( 'Sent a photo', 'spacebay' ); } return mb_substr( $text, 0, 120 ); } }