Skip to:
Content

BuddyPress.org

Ticket #3278: 3278.03.patch

File 3278.03.patch, 61.6 KB (added by DJPaul, 7 years ago)
Line 
1diff --git Gruntfile.js Gruntfile.js
2index ea6b69b..bb49281 100644
3--- Gruntfile.js
4+++ Gruntfile.js
5@@ -6,6 +6,7 @@ module.exports = function( grunt ) {
6        BUILD_DIR  = 'build/',
7 
8        BP_CSS = [
9+               'bp-activity/css/*.css',
10                'bp-activity/admin/css/*.css',
11                'bp-core/admin/css/*.css',
12                'bp-core/css/*.css',
13@@ -18,6 +19,7 @@ module.exports = function( grunt ) {
14        ],
15 
16        BP_JS = [
17+               'bp-activity/js/*.js',
18                'bp-activity/admin/js/*.js',
19                'bp-core/js/*.js',
20                'bp-friends/js/*.js',
21@@ -30,6 +32,8 @@ module.exports = function( grunt ) {
22        ],
23 
24        BP_EXCLUDED_JS = [
25+               '!bp-core/js/jquery.atwho.js',
26+               '!bp-core/js/jquery.caret.js',
27                '!bp-templates/bp-legacy/js/*.js'
28        ];
29 
30diff --git src/bp-activity/bp-activity-actions.php src/bp-activity/bp-activity-actions.php
31index d8e4c23..640a585 100644
32--- src/bp-activity/bp-activity-actions.php
33+++ src/bp-activity/bp-activity-actions.php
34@@ -647,3 +647,28 @@ function bp_activity_setup_akismet() {
35        // Instantiate Akismet for BuddyPress
36        $bp->activity->akismet = new BP_Akismet();
37 }
38+
39+/**
40+ * AJAX endpoint for Suggestions API lookups.
41+ *
42+ * @since BuddyPress (2.1.0)
43+ */
44+function bp_ajax_get_suggestions() {
45+       if ( ! bp_is_user_active() || empty( $_GET['term'] ) || empty( $_GET['type'] ) ) {
46+               wp_send_json_error( 'missing_parameter' );
47+               exit;
48+       }
49+
50+       $results = bp_core_get_suggestions( array(
51+               'term' => sanitize_text_field( $_GET['term'] ),
52+               'type' => sanitize_text_field( $_GET['type'] ),
53+       ) );
54+
55+       if ( is_wp_error( $results ) ) {
56+               wp_send_json_error( $results->get_error_message() );
57+               exit;
58+       }
59+
60+       wp_send_json_success( $results );
61+}
62+add_action( 'wp_ajax_bp_get_suggestions', 'bp_ajax_get_suggestions' );
63diff --git src/bp-activity/bp-activity-cssjs.php src/bp-activity/bp-activity-cssjs.php
64new file mode 100644
65index 0000000..9a7735c
66--- /dev/null
67+++ src/bp-activity/bp-activity-cssjs.php
68@@ -0,0 +1,61 @@
69+<?php
70+
71+/**
72+ * Activity component CSS/JS
73+ *
74+ * @package BuddyPress
75+ * @subpackage ActivityScripts
76+ */
77+
78+// Exit if accessed directly
79+if ( ! defined( 'ABSPATH' ) ) exit;
80+
81+/**
82+ * Enqueue @mentions JS.
83+ *
84+ * @since BuddyPress (2.1)
85+ */
86+function bp_activity_mentions_script() {
87+       if ( ! bp_activity_maybe_load_mentions_scripts() ) {
88+               return;
89+       }
90+
91+       $min  = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min';
92+       $file = is_rtl() ? "mentions-rtl{$min}.css" : "mentions{$min}.css";
93+
94+       wp_enqueue_script( 'bp-mentions', buddypress()->plugin_url . "bp-activity/js/mentions{$min}.js", array( 'jquery', 'jquery-atwho' ), bp_get_version(), true );
95+       wp_enqueue_style( 'bp-mentions-css', buddypress()->plugin_url . "bp-activity/css/{$file}", array(), bp_get_version() );
96+
97+       // Print a list of the current user's friends to the page for quicker @mentions lookups.
98+       do_action( 'bp_activity_mentions_prime_results' );
99+}
100+add_action( 'bp_enqueue_scripts', 'bp_activity_mentions_script' );
101+
102+/**
103+ * Enqueue @mentions JS in wp-admin.
104+ *
105+ * @since BuddyPress (2.1)
106+ */
107+function bp_activity_mentions_dashboard_script() {
108+       if ( ! bp_activity_maybe_load_mentions_scripts() ) {
109+               return;
110+       }
111+
112+       // Special handling for New/Edit screens in wp-admin
113+       if (
114+               ! get_current_screen() ||
115+               ! in_array( get_current_screen()->base, array( 'page', 'post' ) ) ||
116+               ! post_type_supports( get_current_screen()->post_type, 'editor' ) ) {
117+               return;
118+       }
119+
120+       $min  = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min';
121+       $file = is_rtl() ? "mentions-rtl{$min}.css" : "mentions{$min}.css";
122+
123+       wp_enqueue_script( 'bp-mentions', buddypress()->plugin_url . "bp-activity/js/mentions{$min}.js", array( 'jquery', 'jquery-atwho' ), bp_get_version(), true );
124+       wp_enqueue_style( 'bp-mentions-css', buddypress()->plugin_url . "bp-activity/css/{$file}", array(), bp_get_version() );
125+
126+       // Print a list of the current user's friends to the page for quicker @mentions lookups.
127+       do_action( 'bp_activity_mentions_prime_results' );
128+}
129+add_action( 'bp_admin_enqueue_scripts', 'bp_activity_mentions_dashboard_script' );
130\ No newline at end of file
131diff --git src/bp-activity/bp-activity-functions.php src/bp-activity/bp-activity-functions.php
132index e77d8dc..5f70994 100644
133--- src/bp-activity/bp-activity-functions.php
134+++ src/bp-activity/bp-activity-functions.php
135@@ -49,6 +49,22 @@ function bp_activity_do_mentions() {
136 }
137 
138 /**
139+ * Should BuddyPress load the mentions scripts and related assets, including results to prime the
140+ * mentions suggestions?
141+ *
142+ * @return bool True if mentions scripts should be loaded.
143+ * @since BuddyPress (2.1.0)
144+ */
145+function bp_activity_maybe_load_mentions_scripts() {
146+       $retval =
147+               bp_activity_do_mentions() &&
148+               bp_is_user_active() &&
149+               ( bp_is_activity_component() || bp_is_blog_page() && is_singular() && comments_open() || is_admin() );
150+
151+       return (bool) apply_filters( 'bp_activity_maybe_load_mentions_scripts', $retval );
152+}
153+
154+/**
155  * Locate usernames in an activity content string, as designated by an @ sign.
156  *
157  * @since BuddyPress (1.5.0)
158diff --git src/bp-activity/bp-activity-loader.php src/bp-activity/bp-activity-loader.php
159index b0c556c..e3a8dad 100644
160--- src/bp-activity/bp-activity-loader.php
161+++ src/bp-activity/bp-activity-loader.php
162@@ -47,6 +47,7 @@ class BP_Activity_Component extends BP_Component {
163        public function includes( $includes = array() ) {
164                // Files to include
165                $includes = array(
166+                       'cssjs',
167                        'actions',
168                        'screens',
169                        'filters',
170@@ -367,4 +368,4 @@ class BP_Activity_Component extends BP_Component {
171 function bp_setup_activity() {
172        buddypress()->activity = new BP_Activity_Component();
173 }
174-add_action( 'bp_setup_components', 'bp_setup_activity', 6 );
175\ No newline at end of file
176+add_action( 'bp_setup_components', 'bp_setup_activity', 6 );
177diff --git src/bp-activity/css/mentions-rtl.css src/bp-activity/css/mentions-rtl.css
178new file mode 100644
179index 0000000..7185ed6
180--- /dev/null
181+++ src/bp-activity/css/mentions-rtl.css
182@@ -0,0 +1,78 @@
183+.atwho-view {
184+       background: #FFF;
185+       border-radius: 2px;
186+       border: 1px solid rgb(204, 204, 204);
187+       box-shadow: 0 0 5px rgba(204, 204, 204, 0.25), 0 0 1px #FFF;
188+       color: #D84800;
189+       display: none;
190+       font-family: sans-serif;
191+       right: 0;
192+       margin-top: 18px;
193+       position: absolute;
194+       top: 0;
195+       z-index: 1000; /* >999 for wp-admin */
196+}
197+.atwho-view ul {
198+       list-style: none;
199+       margin: auto;
200+       padding: 0;
201+}
202+.atwho-view ul li {
203+       border-bottom: 1px solid #EFEFEF;
204+       box-sizing: content-box;
205+       cursor: pointer;
206+       display: block;
207+       font-size: 14px;
208+       height: 20px;
209+       line-height: 20px;
210+       margin: 0;
211+       overflow: hidden;
212+       padding: 5px 10px;
213+}
214+.atwho-view img {
215+       border-radius: 2px;
216+       float: left;
217+       height: 20px;
218+       margin-right: 10px;
219+       width: 20px;
220+}
221+.atwho-view strong {
222+       background: #EFEFEF;
223+       font: bold;
224+}
225+.atwho-view .username strong {
226+       color: #D54E21;
227+}
228+.atwho-view small {
229+       color: #AAA;
230+       float: left;
231+       font-size: smaller;
232+       font-weight: normal;
233+       margin-right: 40px;
234+}
235+.atwho-view .cur {
236+       background: rgba(239, 239, 239, 0.5);
237+}
238+
239+@media (max-width: 900px) {
240+       .atwho-view img {
241+               float: right;
242+               margin-right: 0;
243+               margin-left: 10px;
244+       }
245+       .atwho-view small {
246+               display: none;
247+       }
248+}
249+@media (max-width: 400px) {
250+       .atwho-view ul li {
251+               font-size: 16px;
252+               line-height: 23px;
253+               padding: 13px;
254+       }
255+       .atwho-view ul li img {
256+               height: 30px;
257+               margin-top: -5px;
258+               width: 30px;
259+       }
260+}
261\ No newline at end of file
262diff --git src/bp-activity/css/mentions.css src/bp-activity/css/mentions.css
263new file mode 100644
264index 0000000..9bc5134
265--- /dev/null
266+++ src/bp-activity/css/mentions.css
267@@ -0,0 +1,78 @@
268+.atwho-view {
269+       background: #FFF;
270+       border-radius: 2px;
271+       border: 1px solid rgb(204, 204, 204);
272+       box-shadow: 0 0 5px rgba(204, 204, 204, 0.25), 0 0 1px #FFF;
273+       color: #D84800;
274+       display: none;
275+       font-family: sans-serif;
276+       left: 0;
277+       margin-top: 18px;
278+       position: absolute;
279+       top: 0;
280+       z-index: 1000; /* >999 for wp-admin */
281+}
282+.atwho-view ul {
283+       list-style: none;
284+       margin: auto;
285+       padding: 0;
286+}
287+.atwho-view ul li {
288+       border-bottom: 1px solid #EFEFEF;
289+       box-sizing: content-box;
290+       cursor: pointer;
291+       display: block;
292+       font-size: 14px;
293+       height: 20px;
294+       line-height: 20px;
295+       margin: 0;
296+       overflow: hidden;
297+       padding: 5px 10px;
298+}
299+.atwho-view img {
300+       border-radius: 2px;
301+       float: right;
302+       height: 20px;
303+       margin-left: 10px;
304+       width: 20px;
305+}
306+.atwho-view strong {
307+       background: #EFEFEF;
308+       font: bold;
309+}
310+.atwho-view .username strong {
311+       color: #D54E21;
312+}
313+.atwho-view small {
314+       color: #AAA;
315+       float: right;
316+       font-size: smaller;
317+       font-weight: normal;
318+       margin-left: 40px;
319+}
320+.atwho-view .cur {
321+       background: rgba(239, 239, 239, 0.5);
322+}
323+
324+@media (max-width: 900px) {
325+       .atwho-view img {
326+               float: left;
327+               margin-left: 0;
328+               margin-right: 10px;
329+       }
330+       .atwho-view small {
331+               display: none;
332+       }
333+}
334+@media (max-width: 400px) {
335+       .atwho-view ul li {
336+               font-size: 16px;
337+               line-height: 23px;
338+               padding: 13px;
339+       }
340+       .atwho-view ul li img {
341+               height: 30px;
342+               margin-top: -5px;
343+               width: 30px;
344+       }
345+}
346\ No newline at end of file
347diff --git src/bp-activity/js/mentions.js src/bp-activity/js/mentions.js
348new file mode 100644
349index 0000000..ae36039
350--- /dev/null
351+++ src/bp-activity/js/mentions.js
352@@ -0,0 +1,204 @@
353+(function( $, undefined ) {
354+       var mentionsQueryCache = [],
355+               mentionsItem;
356+
357+       /**
358+        * Adds BuddyPress @mentions to form inputs.
359+        *
360+        * @param {array|object} options If array, becomes the suggestions' data source. If object, passed as config to $.atwho().
361+        * @since BuddyPress (2.1.0)
362+        */
363+       $.fn.bp_mentions = function( options ) {
364+               if ( $.isArray( options ) ) {
365+                       options = { data: options };
366+               }
367+
368+               /**
369+                * Default options for at.js; see https://github.com/ichord/At.js/.
370+                */
371+               var suggestionsDefaults = {
372+                       delay:               200,
373+                       hide_without_suffix: true,
374+                       insert_tpl:          '</>${atwho-data-value}</>', // For contentEditable, the fake tags make jQuery insert a textNode.
375+                       limit:               10,
376+                       start_with_space:    false,
377+                       suffix:              '',
378+
379+                       callbacks: {
380+                               /**
381+                                * Custom filter to only match the start of spaced words.
382+                                * Based on the core/default one.
383+                                *
384+                                * @param {string} query
385+                                * @param {array} data
386+                                * @param {string} search_key
387+                                * @return {array}
388+                                * @since BuddyPress (2.1.0)
389+                                */
390+                               filter: function( query, data, search_key ) {
391+                                       var item, _i, _len, _results = [],
392+                                       regxp = new RegExp( '^' + query + '| ' + query, 'ig' ); // start of string, or preceded by a space.
393+
394+                                       for ( _i = 0, _len = data.length; _i < _len; _i++ ) {
395+                                               item = data[ _i ];
396+                                               if ( item[ search_key ].toLowerCase().match( regxp ) ) {
397+                                                       _results.push( item );
398+                                               }
399+                                       }
400+
401+                                       return _results;
402+                               },
403+
404+                               /**
405+                                * Removes some spaces around highlighted string and tweaks regex to allow spaces
406+                                * (to match display_name). Based on the core default.
407+                                *
408+                                * @param {unknown} li
409+                                * @param {string} query
410+                                * @return {string}
411+                                * @since BuddyPress (2.1.0)
412+                                */
413+                               highlighter: function( li, query ) {
414+                                       if ( ! query ) {
415+                                               return li;
416+                                       }
417+
418+                                       var regexp = new RegExp( '>(\\s*|[\\w\\s]*)(' + this.at.replace( '+', '\\+') + '?' + query.replace( '+', '\\+' ) + ')([\\w ]*)\\s*<', 'ig' );
419+                                       return li.replace( regexp, function( str, $1, $2, $3 ) {
420+                                               return '>' + $1 + '<strong>' + $2 + '</strong>' + $3 + '<';
421+                                       });
422+                               },
423+
424+                               /**
425+                                * Reposition the suggestion list dynamically.
426+                                *
427+                                * @param {unknown} offset
428+                                * @since BuddyPress (2.1.0)
429+                                */
430+                               before_reposition: function( offset ) {
431+                                       var $view = $( '#atwho-ground-' + this.id + ' .atwho-view' ),
432+                                       caret     = this.$inputor.caret( 'offset', { iframe: $( '#content_ifr' )[0] } ).left,
433+                                       move;
434+
435+                                       // If the caret is past horizontal half, then flip it, yo.
436+                                       if ( caret > ( $( 'body' ).width() / 2 ) ) {
437+                                               $view.addClass( 'flip' );
438+                                               move = caret - offset.left - this.view.$el.width();
439+                                       } else {
440+                                               $view.removeClass( 'flip' );
441+                                               move = caret - offset.left + 1;
442+                                       }
443+
444+                                       offset.top  += 1;
445+                                       offset.left += move;
446+                               },
447+
448+                               /**
449+                                * Override default behaviour which inserts junk tags in the WordPress Visual editor.
450+                                *
451+                                * @param {unknown} $inputor Element which we're inserting content into.
452+                                * @param {string) content The content that will be inserted.
453+                                * @param {string) suffix Applied to the end of the content string.
454+                                * @return {string}
455+                                * @since BuddyPress (2.1.0)
456+                                */
457+                               inserting_wrapper: function( $inputor, content, suffix ) {
458+                                       var new_suffix = ( suffix === '' ) ? suffix : suffix || ' ';
459+                                       return '' + content + new_suffix;
460+                               }
461+                       }
462+               },
463+
464+               /**
465+                * Default options for our @mentions; see https://github.com/ichord/At.js/.
466+                */
467+               mentionsDefaults = {
468+                       callbacks: {
469+                               /**
470+                                * If there are no matches for the query in this.data, then query BuddyPress.
471+                                *
472+                                * @param {string} query Partial @mention to search for.
473+                                * @param {function} render_view Render page callback function.
474+                                * @since BuddyPress (2.1.0)
475+                                */
476+                               remote_filter: function( query, render_view ) {
477+                                       var self = $( this );
478+
479+                                       mentionsItem = mentionsQueryCache[ query ];
480+                                       if ( typeof mentionsItem === 'object' ) {
481+                                               render_view( mentionsItem );
482+                                               return;
483+                                       }
484+
485+                                       if ( self.xhr ) {
486+                                               self.xhr.abort();
487+                                       }
488+
489+                                       self.xhr = $.getJSON( ajaxurl, { 'action': 'bp_get_suggestions', 'term': query, 'type': 'members' } )
490+                                               /**
491+                                                * Success callback for the @suggestions lookup.
492+                                                *
493+                                                * @param {object} response Details of users matching the query.
494+                                                * @since BuddyPress (2.1.0)
495+                                                */
496+                                               .done(function( response ) {
497+                                                       if ( ! response.success ) {
498+                                                               return;
499+                                                       }
500+
501+                                                       var data = $.map( response.data,
502+                                                               /**
503+                                                                * Create a composite index to determine ordering of results;
504+                                                                * nicename matches will appear on top.
505+                                                                *
506+                                                                * @param {array} suggestion A suggestion's original data.
507+                                                                * @return {array} A suggestion's new data.
508+                                                                * @since BuddyPress (2.1.0)
509+                                                                */
510+                                                               function( suggestion ) {
511+                                                                       suggestion.search = suggestion.search || suggestion.ID + ' ' + suggestion.name;
512+                                                                       return suggestion;
513+                                                               }
514+                                                       );
515+
516+                                                       mentionsQueryCache[ query ] = data;
517+                                                       render_view( data );
518+                                               });
519+                               }
520+                       },
521+
522+                       data: $.map( options.data,
523+                               /**
524+                                * Create a composite index to search against of nicename + display name.
525+                                * This will also determine ordering of results, so nicename matches will appear on top.
526+                                *
527+                                * @param {array} suggestion A suggestion's original data.
528+                                * @return {array} A suggestion's new data.
529+                                * @since BuddyPress (2.1.0)
530+                                */
531+                               function( suggestion ) {
532+                                       suggestion.search = suggestion.search || suggestion.ID + ' ' + suggestion.name;
533+                                       return suggestion;
534+                               }
535+                       ),
536+
537+                       at:         '@',
538+                       search_key: 'search',
539+                       tpl:        '<li data-value="@${ID}"><img src="${image}" /><span class="username">@${ID}</span><small>${name}</small></li>'
540+               },
541+
542+               opts = $.extend( true, {}, suggestionsDefaults, mentionsDefaults, options );
543+               return $.fn.atwho.call( this, opts );
544+       };
545+
546+       $( document ).ready(function() {
547+               var users = [];
548+
549+               if ( typeof window.BP_Suggestions === 'object' ) {
550+                       users = window.BP_Suggestions.friends || users;
551+               }
552+
553+               // Activity/reply, post comments, dashboard post 'text' editor.
554+               $( '.bp-suggestions, #comments form textarea, .wp-editor-area' ).bp_mentions( users );
555+       });
556+})( jQuery );
557\ No newline at end of file
558diff --git src/bp-core/bp-core-classes.php src/bp-core/bp-core-classes.php
559index 5bbdf0b..ed9c260 100644
560--- src/bp-core/bp-core-classes.php
561+++ src/bp-core/bp-core-classes.php
562@@ -368,17 +368,26 @@ class BP_User_Query {
563                // 'search_terms' searches user_login and user_nicename
564                // xprofile field matches happen in bp_xprofile_bp_user_query_search()
565                if ( false !== $search_terms ) {
566-                       $search_terms_like = bp_esc_like( $search_terms );
567+                       $search_terms = bp_esc_like( $search_terms );
568 
569                        if ( $search_wildcard === 'left' ) {
570-                               $search_terms_like = '%' . $search_terms_like;
571+                               $search_terms_nospace = '%' . $search_terms;
572+                               $search_terms_space   = '%' . $search_terms . ' %';
573                        } elseif ( $search_wildcard === 'right' ) {
574-                               $search_terms_like = $search_terms_like . '%';
575+                               $search_terms_nospace =        $search_terms . '%';
576+                               $search_terms_space   = '% ' . $search_terms . '%';
577                        } else {
578-                               $search_terms_like = '%' . $search_terms_like . '%';
579+                               $search_terms_nospace = '%' . $search_terms . '%';
580+                               $search_terms_space   = '%' . $search_terms . '%';
581                        }
582 
583-                       $sql['where']['search'] = $wpdb->prepare( "u.{$this->uid_name} IN ( SELECT ID FROM {$wpdb->users} WHERE ( user_login LIKE %s OR user_nicename LIKE %s ) )", $search_terms_like, $search_terms_like );
584+                       $sql['where']['search'] = $wpdb->prepare(
585+                               "u.{$this->uid_name} IN ( SELECT ID FROM {$wpdb->users} WHERE ( user_login LIKE %s OR user_login LIKE %s OR user_nicename LIKE %s OR user_nicename LIKE %s ) )",
586+                               $search_terms_nospace,
587+                               $search_terms_space,
588+                               $search_terms_nospace,
589+                               $search_terms_space
590+                       );
591                }
592 
593                // 'meta_key', 'meta_value' allow usermeta search
594@@ -2566,7 +2575,7 @@ class BP_Members_Suggestions extends BP_Suggestions {
595         * }
596         */
597        protected $default_args = array(
598-               'limit'        => 16,
599+               'limit'        => 10,
600                'only_friends' => false,
601                'term'         => '',
602                'type'         => '',
603@@ -2606,6 +2615,7 @@ class BP_Members_Suggestions extends BP_Suggestions {
604                        'page'            => 1,
605                        'per_page'        => $this->args['limit'],
606                        'search_terms'    => $this->args['term'],
607+                       'search_wildcard' => is_rtl() ? 'left' : 'right',
608                );
609 
610                // Only return matches of friends of this user.
611diff --git src/bp-core/bp-core-cssjs.php src/bp-core/bp-core-cssjs.php
612index b6fa396..c7f7011 100644
613--- src/bp-core/bp-core-cssjs.php
614+++ src/bp-core/bp-core-cssjs.php
615@@ -19,10 +19,15 @@ function bp_core_register_common_scripts() {
616        $url = buddypress()->plugin_url . 'bp-core/js/';
617       
618        $scripts = apply_filters( 'bp_core_register_common_scripts', array(
619+               // Legacy
620                'bp-confirm'        => array( 'file' => "{$url}confirm{$ext}",        'dependencies' => array( 'jquery' ) ),
621                'bp-widget-members' => array( 'file' => "{$url}widget-members{$ext}", 'dependencies' => array( 'jquery' ) ),
622                'bp-jquery-query'   => array( 'file' => "{$url}jquery-query{$ext}",   'dependencies' => array( 'jquery' ) ),
623                'bp-jquery-cookie'  => array( 'file' => "{$url}jquery-cookie{$ext}",  'dependencies' => array( 'jquery' ) ),
624+
625+               // 2.1
626+               'jquery-caret' => array( 'file' => "{$url}jquery.caret{$ext}", 'dependencies' => array( 'jquery' ) ),
627+               'jquery-atwho' => array( 'file' => "{$url}jquery.atwho{$ext}", 'dependencies' => array( 'jquery', 'jquery-caret' ) ),
628        ) );
629 
630        foreach ( $scripts as $id => $script ) {
631diff --git src/bp-core/js/jquery.atwho.js src/bp-core/js/jquery.atwho.js
632new file mode 100755
633index 0000000..d5eefe3
634--- /dev/null
635+++ src/bp-core/js/jquery.atwho.js
636@@ -0,0 +1,824 @@
637+/*! jquery.atwho - v0.5.0 - 2014-07-14
638+* Copyright (c) 2014 chord.luo <chord.luo@gmail.com>;
639+* homepage: http://ichord.github.com/At.js
640+* Licensed MIT
641+*/
642+
643+(function() {
644+  (function(factory) {
645+    if (typeof define === 'function' && define.amd) {
646+      return define(['jquery'], factory);
647+    } else {
648+      return factory(window.jQuery);
649+    }
650+  })(function($) {
651+
652+var $CONTAINER, Api, App, Controller, DEFAULT_CALLBACKS, KEY_CODE, Model, View,
653+  __slice = [].slice;
654+
655+App = (function() {
656+  function App(inputor) {
657+    this.current_flag = null;
658+    this.controllers = {};
659+    this.alias_maps = {};
660+    this.$inputor = $(inputor);
661+    this.iframe = null;
662+    this.setIframe();
663+    this.listen();
664+  }
665+
666+  App.prototype.setIframe = function(iframe) {
667+    if (iframe) {
668+      this.window = iframe.contentWindow;
669+      this.document = iframe.contentDocument || this.window.document;
670+      return this.iframe = iframe;
671+    } else {
672+      this.document = document;
673+      this.window = window;
674+      return this.iframe = null;
675+    }
676+  };
677+
678+  App.prototype.controller = function(at) {
679+    var c, current, current_flag, _ref;
680+    if (this.alias_maps[at]) {
681+      current = this.controllers[this.alias_maps[at]];
682+    } else {
683+      _ref = this.controllers;
684+      for (current_flag in _ref) {
685+        c = _ref[current_flag];
686+        if (current_flag === at) {
687+          current = c;
688+          break;
689+        }
690+      }
691+    }
692+    if (current) {
693+      return current;
694+    } else {
695+      return this.controllers[this.current_flag];
696+    }
697+  };
698+
699+  App.prototype.set_context_for = function(at) {
700+    this.current_flag = at;
701+    return this;
702+  };
703+
704+  App.prototype.reg = function(flag, setting) {
705+    var controller, _base;
706+    controller = (_base = this.controllers)[flag] || (_base[flag] = new Controller(this, flag));
707+    if (setting.alias) {
708+      this.alias_maps[setting.alias] = flag;
709+    }
710+    controller.init(setting);
711+    return this;
712+  };
713+
714+  App.prototype.listen = function() {
715+    return this.$inputor.on('keyup.atwhoInner', (function(_this) {
716+      return function(e) {
717+        return _this.on_keyup(e);
718+      };
719+    })(this)).on('keydown.atwhoInner', (function(_this) {
720+      return function(e) {
721+        return _this.on_keydown(e);
722+      };
723+    })(this)).on('scroll.atwhoInner', (function(_this) {
724+      return function(e) {
725+        var _ref;
726+        return (_ref = _this.controller()) != null ? _ref.view.hide(e) : void 0;
727+      };
728+    })(this)).on('blur.atwhoInner', (function(_this) {
729+      return function(e) {
730+        var c;
731+        if (c = _this.controller()) {
732+          return c.view.hide(e, c.get_opt("display_timeout"));
733+        }
734+      };
735+    })(this)).on('click.atwhoInner', (function(_this) {
736+      return function(e) {
737+        var _ref;
738+        return (_ref = _this.controller()) != null ? _ref.view.hide(e) : void 0;
739+      };
740+    })(this));
741+  };
742+
743+  App.prototype.shutdown = function() {
744+    var c, _, _ref;
745+    _ref = this.controllers;
746+    for (_ in _ref) {
747+      c = _ref[_];
748+      c.destroy();
749+      delete this.controllers[_];
750+    }
751+    return this.$inputor.off('.atwhoInner');
752+  };
753+
754+  App.prototype.dispatch = function() {
755+    return $.map(this.controllers, (function(_this) {
756+      return function(c) {
757+        var delay;
758+        if (delay = c.get_opt('delay')) {
759+          clearTimeout(_this.delayedCallback);
760+          return _this.delayedCallback = setTimeout(function() {
761+            if (c.look_up()) {
762+              return _this.set_context_for(c.at);
763+            }
764+          }, delay);
765+        } else {
766+          if (c.look_up()) {
767+            return _this.set_context_for(c.at);
768+          }
769+        }
770+      };
771+    })(this));
772+  };
773+
774+  App.prototype.on_keyup = function(e) {
775+    var _ref;
776+    switch (e.keyCode) {
777+      case KEY_CODE.ESC:
778+        e.preventDefault();
779+        if ((_ref = this.controller()) != null) {
780+          _ref.view.hide();
781+        }
782+        break;
783+      case KEY_CODE.DOWN:
784+      case KEY_CODE.UP:
785+      case KEY_CODE.CTRL:
786+        $.noop();
787+        break;
788+      case KEY_CODE.P:
789+      case KEY_CODE.N:
790+        if (!e.ctrlKey) {
791+          this.dispatch();
792+        }
793+        break;
794+      default:
795+        this.dispatch();
796+    }
797+  };
798+
799+  App.prototype.on_keydown = function(e) {
800+    var view, _ref;
801+    view = (_ref = this.controller()) != null ? _ref.view : void 0;
802+    if (!(view && view.visible())) {
803+      return;
804+    }
805+    switch (e.keyCode) {
806+      case KEY_CODE.ESC:
807+        e.preventDefault();
808+        view.hide(e);
809+        break;
810+      case KEY_CODE.UP:
811+        e.preventDefault();
812+        view.prev();
813+        break;
814+      case KEY_CODE.DOWN:
815+        e.preventDefault();
816+        view.next();
817+        break;
818+      case KEY_CODE.P:
819+        if (!e.ctrlKey) {
820+          return;
821+        }
822+        e.preventDefault();
823+        view.prev();
824+        break;
825+      case KEY_CODE.N:
826+        if (!e.ctrlKey) {
827+          return;
828+        }
829+        e.preventDefault();
830+        view.next();
831+        break;
832+      case KEY_CODE.TAB:
833+      case KEY_CODE.ENTER:
834+        if (!view.visible()) {
835+          return;
836+        }
837+        e.preventDefault();
838+        view.choose(e);
839+        break;
840+      default:
841+        $.noop();
842+    }
843+  };
844+
845+  return App;
846+
847+})();
848+
849+Controller = (function() {
850+  Controller.prototype.uid = function() {
851+    return (Math.random().toString(16) + "000000000").substr(2, 8) + (new Date().getTime());
852+  };
853+
854+  function Controller(app, at) {
855+    this.app = app;
856+    this.at = at;
857+    this.$inputor = this.app.$inputor;
858+    this.id = this.$inputor[0].id || this.uid();
859+    this.setting = null;
860+    this.query = null;
861+    this.pos = 0;
862+    this.cur_rect = null;
863+    this.range = null;
864+    $CONTAINER.append(this.$el = $("<div id='atwho-ground-" + this.id + "'></div>"));
865+    this.model = new Model(this);
866+    this.view = new View(this);
867+  }
868+
869+  Controller.prototype.init = function(setting) {
870+    this.setting = $.extend({}, this.setting || $.fn.atwho["default"], setting);
871+    this.view.init();
872+    return this.model.reload(this.setting.data);
873+  };
874+
875+  Controller.prototype.destroy = function() {
876+    this.trigger('beforeDestroy');
877+    this.model.destroy();
878+    this.view.destroy();
879+    return this.$el.remove();
880+  };
881+
882+  Controller.prototype.call_default = function() {
883+    var args, error, func_name;
884+    func_name = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : [];
885+    try {
886+      return DEFAULT_CALLBACKS[func_name].apply(this, args);
887+    } catch (_error) {
888+      error = _error;
889+      return $.error("" + error + " Or maybe At.js doesn't have function " + func_name);
890+    }
891+  };
892+
893+  Controller.prototype.trigger = function(name, data) {
894+    var alias, event_name;
895+    if (data == null) {
896+      data = [];
897+    }
898+    data.push(this);
899+    alias = this.get_opt('alias');
900+    event_name = alias ? "" + name + "-" + alias + ".atwho" : "" + name + ".atwho";
901+    return this.$inputor.trigger(event_name, data);
902+  };
903+
904+  Controller.prototype.callbacks = function(func_name) {
905+    return this.get_opt("callbacks")[func_name] || DEFAULT_CALLBACKS[func_name];
906+  };
907+
908+  Controller.prototype.get_opt = function(at, default_value) {
909+    var e;
910+    try {
911+      return this.setting[at];
912+    } catch (_error) {
913+      e = _error;
914+      return null;
915+    }
916+  };
917+
918+  Controller.prototype.content = function() {
919+    if (this.$inputor.is('textarea, input')) {
920+      return this.$inputor.val();
921+    } else {
922+      return this.$inputor.text();
923+    }
924+  };
925+
926+  Controller.prototype.catch_query = function() {
927+    var caret_pos, content, end, query, start, subtext;
928+    content = this.content();
929+    caret_pos = this.$inputor.caret('pos', {
930+      iframe: this.app.iframe
931+    });
932+    subtext = content.slice(0, caret_pos);
933+    query = this.callbacks("matcher").call(this, this.at, subtext, this.get_opt('start_with_space'));
934+    if (typeof query === "string" && query.length <= this.get_opt('max_len', 20)) {
935+      start = caret_pos - query.length;
936+      end = start + query.length;
937+      this.pos = start;
938+      query = {
939+        'text': query,
940+        'head_pos': start,
941+        'end_pos': end
942+      };
943+      this.trigger("matched", [this.at, query.text]);
944+    } else {
945+      query = null;
946+      this.view.hide();
947+    }
948+    return this.query = query;
949+  };
950+
951+  Controller.prototype.rect = function() {
952+    var c, scale_bottom;
953+    if (!(c = this.$inputor.caret('offset', this.pos - 1, {
954+      iframe: this.app.iframe
955+    }))) {
956+      return;
957+    }
958+    if (this.$inputor.attr('contentEditable') === 'true') {
959+      c = (this.cur_rect || (this.cur_rect = c)) || c;
960+    }
961+    scale_bottom = this.app.document.selection ? 0 : 2;
962+    return {
963+      left: c.left,
964+      top: c.top,
965+      bottom: c.top + c.height + scale_bottom
966+    };
967+  };
968+
969+  Controller.prototype.reset_rect = function() {
970+    if (this.$inputor.attr('contentEditable') === 'true') {
971+      return this.cur_rect = null;
972+    }
973+  };
974+
975+  Controller.prototype.mark_range = function() {
976+    if (this.$inputor.attr('contentEditable') === 'true') {
977+      if (this.app.window.getSelection) {
978+        this.range = this.app.window.getSelection().getRangeAt(0);
979+      }
980+      if (this.app.document.selection) {
981+        return this.ie8_range = this.app.document.selection.createRange();
982+      }
983+    }
984+  };
985+
986+  Controller.prototype.insert_content_for = function($li) {
987+    var data, data_value, tpl;
988+    data_value = $li.data('value');
989+    tpl = this.get_opt('insert_tpl');
990+    if (this.$inputor.is('textarea, input') || !tpl) {
991+      return data_value;
992+    }
993+    data = $.extend({}, $li.data('item-data'), {
994+      'atwho-data-value': data_value,
995+      'atwho-at': this.at
996+    });
997+    return this.callbacks("tpl_eval").call(this, tpl, data);
998+  };
999+
1000+  Controller.prototype.insert = function(content, $li) {
1001+    var $inputor, content_node, pos, range, sel, source, start_str, text, wrapped_content;
1002+    $inputor = this.$inputor;
1003+    wrapped_content = this.callbacks('inserting_wrapper').call(this, $inputor, content, this.get_opt("suffix"));
1004+    if ($inputor.is('textarea, input')) {
1005+      source = $inputor.val();
1006+      start_str = source.slice(0, Math.max(this.query.head_pos - this.at.length, 0));
1007+      text = "" + start_str + wrapped_content + (source.slice(this.query['end_pos'] || 0));
1008+      $inputor.val(text);
1009+      $inputor.caret('pos', start_str.length + wrapped_content.length, {
1010+        iframe: this.app.iframe
1011+      });
1012+    } else if (range = this.range) {
1013+      pos = range.startOffset - (this.query.end_pos - this.query.head_pos) - this.at.length;
1014+      range.setStart(range.endContainer, Math.max(pos, 0));
1015+      range.setEnd(range.endContainer, range.endOffset);
1016+      range.deleteContents();
1017+      content_node = $(wrapped_content, this.app.document)[0];
1018+      range.insertNode(content_node);
1019+      range.setEndAfter(content_node);
1020+      range.collapse(false);
1021+      sel = this.app.window.getSelection();
1022+      sel.removeAllRanges();
1023+      sel.addRange(range);
1024+    } else if (range = this.ie8_range) {
1025+      range.moveStart('character', this.query.end_pos - this.query.head_pos - this.at.length);
1026+      range.pasteHTML(wrapped_content);
1027+      range.collapse(false);
1028+      range.select();
1029+    }
1030+    if (!$inputor.is(':focus')) {
1031+      $inputor.focus();
1032+    }
1033+    return $inputor.change();
1034+  };
1035+
1036+  Controller.prototype.render_view = function(data) {
1037+    var search_key;
1038+    search_key = this.get_opt("search_key");
1039+    data = this.callbacks("sorter").call(this, this.query.text, data.slice(0, 1001), search_key);
1040+    return this.view.render(data.slice(0, this.get_opt('limit')));
1041+  };
1042+
1043+  Controller.prototype.look_up = function() {
1044+    var query, _callback;
1045+    if (!(query = this.catch_query())) {
1046+      return;
1047+    }
1048+    _callback = function(data) {
1049+      if (data && data.length > 0) {
1050+        return this.render_view(data);
1051+      } else {
1052+        return this.view.hide();
1053+      }
1054+    };
1055+    this.model.query(query.text, $.proxy(_callback, this));
1056+    return query;
1057+  };
1058+
1059+  return Controller;
1060+
1061+})();
1062+
1063+Model = (function() {
1064+  function Model(context) {
1065+    this.context = context;
1066+    this.at = this.context.at;
1067+    this.storage = this.context.$inputor;
1068+  }
1069+
1070+  Model.prototype.destroy = function() {
1071+    return this.storage.data(this.at, null);
1072+  };
1073+
1074+  Model.prototype.saved = function() {
1075+    return this.fetch() > 0;
1076+  };
1077+
1078+  Model.prototype.query = function(query, callback) {
1079+    var data, search_key, _remote_filter;
1080+    data = this.fetch();
1081+    search_key = this.context.get_opt("search_key");
1082+    data = this.context.callbacks('filter').call(this.context, query, data, search_key) || [];
1083+    _remote_filter = this.context.callbacks('remote_filter');
1084+    if (data.length > 0 || (!_remote_filter && data.length === 0)) {
1085+      return callback(data);
1086+    } else {
1087+      return _remote_filter.call(this.context, query, callback);
1088+    }
1089+  };
1090+
1091+  Model.prototype.fetch = function() {
1092+    return this.storage.data(this.at) || [];
1093+  };
1094+
1095+  Model.prototype.save = function(data) {
1096+    return this.storage.data(this.at, this.context.callbacks("before_save").call(this.context, data || []));
1097+  };
1098+
1099+  Model.prototype.load = function(data) {
1100+    if (!(this.saved() || !data)) {
1101+      return this._load(data);
1102+    }
1103+  };
1104+
1105+  Model.prototype.reload = function(data) {
1106+    return this._load(data);
1107+  };
1108+
1109+  Model.prototype._load = function(data) {
1110+    if (typeof data === "string") {
1111+      return $.ajax(data, {
1112+        dataType: "json"
1113+      }).done((function(_this) {
1114+        return function(data) {
1115+          return _this.save(data);
1116+        };
1117+      })(this));
1118+    } else {
1119+      return this.save(data);
1120+    }
1121+  };
1122+
1123+  return Model;
1124+
1125+})();
1126+
1127+View = (function() {
1128+  function View(context) {
1129+    this.context = context;
1130+    this.$el = $("<div class='atwho-view'><ul class='atwho-view-ul'></ul></div>");
1131+    this.timeout_id = null;
1132+    this.context.$el.append(this.$el);
1133+    this.bind_event();
1134+  }
1135+
1136+  View.prototype.init = function() {
1137+    var id;
1138+    id = this.context.get_opt("alias") || this.context.at.charCodeAt(0);
1139+    return this.$el.attr({
1140+      'id': "at-view-" + id
1141+    });
1142+  };
1143+
1144+  View.prototype.destroy = function() {
1145+    return this.$el.remove();
1146+  };
1147+
1148+  View.prototype.bind_event = function() {
1149+    var $menu;
1150+    $menu = this.$el.find('ul');
1151+    return $menu.on('mouseenter.atwho-view', 'li', function(e) {
1152+      $menu.find('.cur').removeClass('cur');
1153+      return $(e.currentTarget).addClass('cur');
1154+    }).on('click', (function(_this) {
1155+      return function(e) {
1156+        _this.choose(e);
1157+        return e.preventDefault();
1158+      };
1159+    })(this));
1160+  };
1161+
1162+  View.prototype.visible = function() {
1163+    return this.$el.is(":visible");
1164+  };
1165+
1166+  View.prototype.choose = function(e) {
1167+    var $li, content;
1168+    if (($li = this.$el.find(".cur")).length) {
1169+      content = this.context.insert_content_for($li);
1170+      this.context.insert(this.context.callbacks("before_insert").call(this.context, content, $li), $li);
1171+      this.context.trigger("inserted", [$li, e]);
1172+      this.hide(e);
1173+    }
1174+    if (this.context.get_opt("hide_without_suffix")) {
1175+      return this.stop_showing = true;
1176+    }
1177+  };
1178+
1179+  View.prototype.reposition = function(rect) {
1180+    var offset, _ref;
1181+    if (rect.bottom + this.$el.height() - $(window).scrollTop() > $(window).height()) {
1182+      rect.bottom = rect.top - this.$el.height();
1183+    }
1184+    offset = {
1185+      left: rect.left,
1186+      top: rect.bottom
1187+    };
1188+    if ((_ref = this.context.callbacks("before_reposition")) != null) {
1189+      _ref.call(this.context, offset);
1190+    }
1191+    this.$el.offset(offset);
1192+    return this.context.trigger("reposition", [offset]);
1193+  };
1194+
1195+  View.prototype.next = function() {
1196+    var cur, next;
1197+    cur = this.$el.find('.cur').removeClass('cur');
1198+    next = cur.next();
1199+    if (!next.length) {
1200+      next = this.$el.find('li:first');
1201+    }
1202+    return next.addClass('cur');
1203+  };
1204+
1205+  View.prototype.prev = function() {
1206+    var cur, prev;
1207+    cur = this.$el.find('.cur').removeClass('cur');
1208+    prev = cur.prev();
1209+    if (!prev.length) {
1210+      prev = this.$el.find('li:last');
1211+    }
1212+    return prev.addClass('cur');
1213+  };
1214+
1215+  View.prototype.show = function() {
1216+    var rect;
1217+    if (this.stop_showing) {
1218+      this.stop_showing = false;
1219+      return;
1220+    }
1221+    this.context.mark_range();
1222+    if (!this.visible()) {
1223+      this.$el.show();
1224+      this.context.trigger('shown');
1225+    }
1226+    if (rect = this.context.rect()) {
1227+      return this.reposition(rect);
1228+    }
1229+  };
1230+
1231+  View.prototype.hide = function(e, time) {
1232+    var callback;
1233+    if (!this.visible()) {
1234+      return;
1235+    }
1236+    if (isNaN(time)) {
1237+      this.context.reset_rect();
1238+      this.$el.hide();
1239+      return this.context.trigger('hidden', [e]);
1240+    } else {
1241+      callback = (function(_this) {
1242+        return function() {
1243+          return _this.hide();
1244+        };
1245+      })(this);
1246+      clearTimeout(this.timeout_id);
1247+      return this.timeout_id = setTimeout(callback, time);
1248+    }
1249+  };
1250+
1251+  View.prototype.render = function(list) {
1252+    var $li, $ul, item, li, tpl, _i, _len;
1253+    if (!($.isArray(list) && list.length > 0)) {
1254+      this.hide();
1255+      return;
1256+    }
1257+    this.$el.find('ul').empty();
1258+    $ul = this.$el.find('ul');
1259+    tpl = this.context.get_opt('tpl');
1260+    for (_i = 0, _len = list.length; _i < _len; _i++) {
1261+      item = list[_i];
1262+      item = $.extend({}, item, {
1263+        'atwho-at': this.context.at
1264+      });
1265+      li = this.context.callbacks("tpl_eval").call(this.context, tpl, item);
1266+      $li = $(this.context.callbacks("highlighter").call(this.context, li, this.context.query.text));
1267+      $li.data("item-data", item);
1268+      $ul.append($li);
1269+    }
1270+    this.show();
1271+    if (this.context.get_opt('highlight_first')) {
1272+      return $ul.find("li:first").addClass("cur");
1273+    }
1274+  };
1275+
1276+  return View;
1277+
1278+})();
1279+
1280+KEY_CODE = {
1281+  DOWN: 40,
1282+  UP: 38,
1283+  ESC: 27,
1284+  TAB: 9,
1285+  ENTER: 13,
1286+  CTRL: 17,
1287+  P: 80,
1288+  N: 78
1289+};
1290+
1291+DEFAULT_CALLBACKS = {
1292+  before_save: function(data) {
1293+    var item, _i, _len, _results;
1294+    if (!$.isArray(data)) {
1295+      return data;
1296+    }
1297+    _results = [];
1298+    for (_i = 0, _len = data.length; _i < _len; _i++) {
1299+      item = data[_i];
1300+      if ($.isPlainObject(item)) {
1301+        _results.push(item);
1302+      } else {
1303+        _results.push({
1304+          name: item
1305+        });
1306+      }
1307+    }
1308+    return _results;
1309+  },
1310+  matcher: function(flag, subtext, should_start_with_space) {
1311+    var match, regexp;
1312+    flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
1313+    if (should_start_with_space) {
1314+      flag = '(?:^|\\s)' + flag;
1315+    }
1316+    regexp = new RegExp(flag + '([A-Za-z0-9_\+\-]*)$|' + flag + '([^\\x00-\\xff]*)$', 'gi');
1317+    match = regexp.exec(subtext);
1318+    if (match) {
1319+      return match[2] || match[1];
1320+    } else {
1321+      return null;
1322+    }
1323+  },
1324+  filter: function(query, data, search_key) {
1325+    var item, _i, _len, _results;
1326+    _results = [];
1327+    for (_i = 0, _len = data.length; _i < _len; _i++) {
1328+      item = data[_i];
1329+      if (~item[search_key].toLowerCase().indexOf(query.toLowerCase())) {
1330+        _results.push(item);
1331+      }
1332+    }
1333+    return _results;
1334+  },
1335+  remote_filter: null,
1336+  sorter: function(query, items, search_key) {
1337+    var item, _i, _len, _results;
1338+    if (!query) {
1339+      return items;
1340+    }
1341+    _results = [];
1342+    for (_i = 0, _len = items.length; _i < _len; _i++) {
1343+      item = items[_i];
1344+      item.atwho_order = item[search_key].toLowerCase().indexOf(query.toLowerCase());
1345+      if (item.atwho_order > -1) {
1346+        _results.push(item);
1347+      }
1348+    }
1349+    return _results.sort(function(a, b) {
1350+      return a.atwho_order - b.atwho_order;
1351+    });
1352+  },
1353+  tpl_eval: function(tpl, map) {
1354+    var error;
1355+    try {
1356+      return tpl.replace(/\$\{([^\}]*)\}/g, function(tag, key, pos) {
1357+        return map[key];
1358+      });
1359+    } catch (_error) {
1360+      error = _error;
1361+      return "";
1362+    }
1363+  },
1364+  highlighter: function(li, query) {
1365+    var regexp;
1366+    if (!query) {
1367+      return li;
1368+    }
1369+    regexp = new RegExp(">\\s*(\\w*?)(" + query.replace("+", "\\+") + ")(\\w*)\\s*<", 'ig');
1370+    return li.replace(regexp, function(str, $1, $2, $3) {
1371+      return '> ' + $1 + '<strong>' + $2 + '</strong>' + $3 + ' <';
1372+    });
1373+  },
1374+  before_insert: function(value, $li) {
1375+    return value;
1376+  },
1377+  inserting_wrapper: function($inputor, content, suffix) {
1378+    var new_suffix, wrapped_content;
1379+    new_suffix = suffix === "" ? suffix : suffix || " ";
1380+    if ($inputor.is('textarea, input')) {
1381+      return '' + content + new_suffix;
1382+    } else if ($inputor.attr('contentEditable') === 'true') {
1383+      new_suffix = suffix === "" ? suffix : suffix || "&nbsp;";
1384+      if (/firefox/i.test(navigator.userAgent)) {
1385+        wrapped_content = "<span>" + content + new_suffix + "</span>";
1386+      } else {
1387+        suffix = "<span contenteditable='false'>" + new_suffix + "<span>";
1388+        wrapped_content = "<span contenteditable='false'>" + content + suffix + "</span>";
1389+      }
1390+      if (this.app.document.selection) {
1391+        wrapped_content = "<span contenteditable='true'>" + content + "</span>";
1392+      }
1393+      return wrapped_content;
1394+    }
1395+  }
1396+};
1397+
1398+Api = {
1399+  load: function(at, data) {
1400+    var c;
1401+    if (c = this.controller(at)) {
1402+      return c.model.load(data);
1403+    }
1404+  },
1405+  setIframe: function(iframe) {
1406+    this.setIframe(iframe);
1407+    return null;
1408+  },
1409+  run: function() {
1410+    return this.dispatch();
1411+  },
1412+  destroy: function() {
1413+    this.shutdown();
1414+    return this.$inputor.data('atwho', null);
1415+  }
1416+};
1417+
1418+$CONTAINER = $("<div id='atwho-container'></div>");
1419+
1420+$.fn.atwho = function(method) {
1421+  var result, _args;
1422+  _args = arguments;
1423+  $('body').append($CONTAINER);
1424+  result = null;
1425+  this.filter('textarea, input, [contenteditable=true]').each(function() {
1426+    var $this, app;
1427+    if (!(app = ($this = $(this)).data("atwho"))) {
1428+      $this.data('atwho', (app = new App(this)));
1429+    }
1430+    if (typeof method === 'object' || !method) {
1431+      return app.reg(method.at, method);
1432+    } else if (Api[method] && app) {
1433+      return result = Api[method].apply(app, Array.prototype.slice.call(_args, 1));
1434+    } else {
1435+      return $.error("Method " + method + " does not exist on jQuery.caret");
1436+    }
1437+  });
1438+  return result || this;
1439+};
1440+
1441+$.fn.atwho["default"] = {
1442+  at: void 0,
1443+  alias: void 0,
1444+  data: null,
1445+  tpl: "<li data-value='${atwho-at}${name}'>${name}</li>",
1446+  insert_tpl: "<span id='${id}'>${atwho-data-value}</span>",
1447+  callbacks: DEFAULT_CALLBACKS,
1448+  search_key: "name",
1449+  suffix: void 0,
1450+  hide_without_suffix: false,
1451+  start_with_space: true,
1452+  highlight_first: true,
1453+  limit: 5,
1454+  max_len: 20,
1455+  display_timeout: 300,
1456+  delay: null
1457+};
1458+
1459+  });
1460+}).call(this);
1461diff --git src/bp-core/js/jquery.atwho.txt src/bp-core/js/jquery.atwho.txt
1462new file mode 100755
1463index 0000000..36cd1c1
1464--- /dev/null
1465+++ src/bp-core/js/jquery.atwho.txt
1466@@ -0,0 +1,22 @@
1467+Copyright (c) 2013 chord.luo@gmail.com
1468+
1469+Permission is hereby granted, free of charge, to any person
1470+obtaining a copy of this software and associated documentation
1471+files (the "Software"), to deal in the Software without
1472+restriction, including without limitation the rights to use,
1473+copy, modify, merge, publish, distribute, sublicense, and/or sell
1474+copies of the Software, and to permit persons to whom the
1475+Software is furnished to do so, subject to the following
1476+conditions:
1477+
1478+The above copyright notice and this permission notice shall be
1479+included in all copies or substantial portions of the Software.
1480+
1481+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
1482+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
1483+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
1484+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
1485+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
1486+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
1487+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
1488+OTHER DEALINGS IN THE SOFTWARE.
1489diff --git src/bp-core/js/jquery.caret.js src/bp-core/js/jquery.caret.js
1490new file mode 100755
1491index 0000000..caa7876
1492--- /dev/null
1493+++ src/bp-core/js/jquery.caret.js
1494@@ -0,0 +1,366 @@
1495+/*
1496+  Implement Github like autocomplete mentions
1497+  http://ichord.github.com/At.js
1498+
1499+  Copyright (c) 2013 chord.luo@gmail.com
1500+  Licensed under the MIT license.
1501+*/
1502+
1503+
1504+/*
1505+本插件操䜜 textarea 或者 input 内的插入笊
1506+只实现了获埗插入笊圚文本框䞭的䜍眮我讟眮
1507+插入笊的䜍眮.
1508+*/
1509+
1510+
1511+(function() {
1512+  (function(factory) {
1513+    if (typeof define === 'function' && define.amd) {
1514+      return define(['jquery'], factory);
1515+    } else {
1516+      return factory(window.jQuery);
1517+    }
1518+  })(function($) {
1519+    "use strict";
1520+    var EditableCaret, InputCaret, Mirror, Utils, discoveryIframeOf, methods, oDocument, oFrame, oWindow, pluginName, setContextBy;
1521+    pluginName = 'caret';
1522+    EditableCaret = (function() {
1523+      function EditableCaret($inputor) {
1524+        this.$inputor = $inputor;
1525+        this.domInputor = this.$inputor[0];
1526+      }
1527+
1528+      EditableCaret.prototype.setPos = function(pos) {
1529+        return this.domInputor;
1530+      };
1531+
1532+      EditableCaret.prototype.getIEPosition = function() {
1533+        return $.noop();
1534+      };
1535+
1536+      EditableCaret.prototype.getPosition = function() {
1537+        return $.noop();
1538+      };
1539+
1540+      EditableCaret.prototype.getOldIEPos = function() {
1541+        var preCaretTextRange, textRange;
1542+        textRange = oDocument.selection.createRange();
1543+        preCaretTextRange = oDocument.body.createTextRange();
1544+        preCaretTextRange.moveToElementText(this.domInputor);
1545+        preCaretTextRange.setEndPoint("EndToEnd", textRange);
1546+        return preCaretTextRange.text.length;
1547+      };
1548+
1549+      EditableCaret.prototype.getPos = function() {
1550+        var clonedRange, pos, range;
1551+        if (range = this.range()) {
1552+          clonedRange = range.cloneRange();
1553+          clonedRange.selectNodeContents(this.domInputor);
1554+          clonedRange.setEnd(range.endContainer, range.endOffset);
1555+          pos = clonedRange.toString().length;
1556+          clonedRange.detach();
1557+          return pos;
1558+        } else if (oDocument.selection) {
1559+          return this.getOldIEPos();
1560+        }
1561+      };
1562+
1563+      EditableCaret.prototype.getOldIEOffset = function() {
1564+        var range, rect;
1565+        range = oDocument.selection.createRange().duplicate();
1566+        range.moveStart("character", -1);
1567+        rect = range.getBoundingClientRect();
1568+        return {
1569+          height: rect.bottom - rect.top,
1570+          left: rect.left,
1571+          top: rect.top
1572+        };
1573+      };
1574+
1575+      EditableCaret.prototype.getOffset = function(pos) {
1576+        var clonedRange, offset, range, rect;
1577+        if (oWindow.getSelection && (range = this.range())) {
1578+          if (range.endOffset - 1 < 0) {
1579+            return null;
1580+          }
1581+          clonedRange = range.cloneRange();
1582+          clonedRange.setStart(range.endContainer, range.endOffset - 1);
1583+          clonedRange.setEnd(range.endContainer, range.endOffset);
1584+          rect = clonedRange.getBoundingClientRect();
1585+          offset = {
1586+            height: rect.height,
1587+            left: rect.left + rect.width,
1588+            top: rect.top
1589+          };
1590+          clonedRange.detach();
1591+        } else if (oDocument.selection) {
1592+          offset = this.getOldIEOffset();
1593+        }
1594+        if (offset && !oFrame) {
1595+          offset.top += $(oWindow).scrollTop();
1596+          offset.left += $(oWindow).scrollLeft();
1597+        }
1598+        return offset;
1599+      };
1600+
1601+      EditableCaret.prototype.range = function() {
1602+        var sel;
1603+        if (!oWindow.getSelection) {
1604+          return;
1605+        }
1606+        sel = oWindow.getSelection();
1607+        if (sel.rangeCount > 0) {
1608+          return sel.getRangeAt(0);
1609+        } else {
1610+          return null;
1611+        }
1612+      };
1613+
1614+      return EditableCaret;
1615+
1616+    })();
1617+    InputCaret = (function() {
1618+      function InputCaret($inputor) {
1619+        this.$inputor = $inputor;
1620+        this.domInputor = this.$inputor[0];
1621+      }
1622+
1623+      InputCaret.prototype.getIEPos = function() {
1624+        var endRange, inputor, len, normalizedValue, pos, range, textInputRange;
1625+        inputor = this.domInputor;
1626+        range = oDocument.selection.createRange();
1627+        pos = 0;
1628+        if (range && range.parentElement() === inputor) {
1629+          normalizedValue = inputor.value.replace(/\r\n/g, "\n");
1630+          len = normalizedValue.length;
1631+          textInputRange = inputor.createTextRange();
1632+          textInputRange.moveToBookmark(range.getBookmark());
1633+          endRange = inputor.createTextRange();
1634+          endRange.collapse(false);
1635+          if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
1636+            pos = len;
1637+          } else {
1638+            pos = -textInputRange.moveStart("character", -len);
1639+          }
1640+        }
1641+        return pos;
1642+      };
1643+
1644+      InputCaret.prototype.getPos = function() {
1645+        if (oDocument.selection) {
1646+          return this.getIEPos();
1647+        } else {
1648+          return this.domInputor.selectionStart;
1649+        }
1650+      };
1651+
1652+      InputCaret.prototype.setPos = function(pos) {
1653+        var inputor, range;
1654+        inputor = this.domInputor;
1655+        if (oDocument.selection) {
1656+          range = inputor.createTextRange();
1657+          range.move("character", pos);
1658+          range.select();
1659+        } else if (inputor.setSelectionRange) {
1660+          inputor.setSelectionRange(pos, pos);
1661+        }
1662+        return inputor;
1663+      };
1664+
1665+      InputCaret.prototype.getIEOffset = function(pos) {
1666+        var h, textRange, x, y;
1667+        textRange = this.domInputor.createTextRange();
1668+        pos || (pos = this.getPos());
1669+        textRange.move('character', pos);
1670+        x = textRange.boundingLeft;
1671+        y = textRange.boundingTop;
1672+        h = textRange.boundingHeight;
1673+        return {
1674+          left: x,
1675+          top: y,
1676+          height: h
1677+        };
1678+      };
1679+
1680+      InputCaret.prototype.getOffset = function(pos) {
1681+        var $inputor, offset, position;
1682+        $inputor = this.$inputor;
1683+        if (oDocument.selection) {
1684+          offset = this.getIEOffset(pos);
1685+          offset.top += $(oWindow).scrollTop() + $inputor.scrollTop();
1686+          offset.left += $(oWindow).scrollLeft() + $inputor.scrollLeft();
1687+          return offset;
1688+        } else {
1689+          offset = $inputor.offset();
1690+          position = this.getPosition(pos);
1691+          return offset = {
1692+            left: offset.left + position.left - $inputor.scrollLeft(),
1693+            top: offset.top + position.top - $inputor.scrollTop(),
1694+            height: position.height
1695+          };
1696+        }
1697+      };
1698+
1699+      InputCaret.prototype.getPosition = function(pos) {
1700+        var $inputor, at_rect, end_range, format, html, mirror, start_range;
1701+        $inputor = this.$inputor;
1702+        format = function(value) {
1703+          return value.replace(/</g, '&lt').replace(/>/g, '&gt').replace(/`/g, '&#96').replace(/"/g, '&quot').replace(/\r\n|\r|\n/g, "<br />");
1704+        };
1705+        if (pos === void 0) {
1706+          pos = this.getPos();
1707+        }
1708+        start_range = $inputor.val().slice(0, pos);
1709+        end_range = $inputor.val().slice(pos);
1710+        html = "<span style='position: relative; display: inline;'>" + format(start_range) + "</span>";
1711+        html += "<span id='caret' style='position: relative; display: inline;'>|</span>";
1712+        html += "<span style='position: relative; display: inline;'>" + format(end_range) + "</span>";
1713+        mirror = new Mirror($inputor);
1714+        return at_rect = mirror.create(html).rect();
1715+      };
1716+
1717+      InputCaret.prototype.getIEPosition = function(pos) {
1718+        var h, inputorOffset, offset, x, y;
1719+        offset = this.getIEOffset(pos);
1720+        inputorOffset = this.$inputor.offset();
1721+        x = offset.left - inputorOffset.left;
1722+        y = offset.top - inputorOffset.top;
1723+        h = offset.height;
1724+        return {
1725+          left: x,
1726+          top: y,
1727+          height: h
1728+        };
1729+      };
1730+
1731+      return InputCaret;
1732+
1733+    })();
1734+    Mirror = (function() {
1735+      Mirror.prototype.css_attr = ["borderBottomWidth", "borderLeftWidth", "borderRightWidth", "borderTopStyle", "borderRightStyle", "borderBottomStyle", "borderLeftStyle", "borderTopWidth", "boxSizing", "fontFamily", "fontSize", "fontWeight", "height", "letterSpacing", "lineHeight", "marginBottom", "marginLeft", "marginRight", "marginTop", "outlineWidth", "overflow", "overflowX", "overflowY", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "textAlign", "textOverflow", "textTransform", "whiteSpace", "wordBreak", "wordWrap"];
1736+
1737+      function Mirror($inputor) {
1738+        this.$inputor = $inputor;
1739+      }
1740+
1741+      Mirror.prototype.mirrorCss = function() {
1742+        var css,
1743+          _this = this;
1744+        css = {
1745+          position: 'absolute',
1746+          left: -9999,
1747+          top: 0,
1748+          zIndex: -20000
1749+        };
1750+        if (this.$inputor.prop('tagName') === 'TEXTAREA') {
1751+          this.css_attr.push('width');
1752+        }
1753+        $.each(this.css_attr, function(i, p) {
1754+          return css[p] = _this.$inputor.css(p);
1755+        });
1756+        return css;
1757+      };
1758+
1759+      Mirror.prototype.create = function(html) {
1760+        this.$mirror = $('<div></div>');
1761+        this.$mirror.css(this.mirrorCss());
1762+        this.$mirror.html(html);
1763+        this.$inputor.after(this.$mirror);
1764+        return this;
1765+      };
1766+
1767+      Mirror.prototype.rect = function() {
1768+        var $flag, pos, rect;
1769+        $flag = this.$mirror.find("#caret");
1770+        pos = $flag.position();
1771+        rect = {
1772+          left: pos.left,
1773+          top: pos.top,
1774+          height: $flag.height()
1775+        };
1776+        this.$mirror.remove();
1777+        return rect;
1778+      };
1779+
1780+      return Mirror;
1781+
1782+    })();
1783+    Utils = {
1784+      contentEditable: function($inputor) {
1785+        return !!($inputor[0].contentEditable && $inputor[0].contentEditable === 'true');
1786+      }
1787+    };
1788+    methods = {
1789+      pos: function(pos) {
1790+        if (pos || pos === 0) {
1791+          return this.setPos(pos);
1792+        } else {
1793+          return this.getPos();
1794+        }
1795+      },
1796+      position: function(pos) {
1797+        if (oDocument.selection) {
1798+          return this.getIEPosition(pos);
1799+        } else {
1800+          return this.getPosition(pos);
1801+        }
1802+      },
1803+      offset: function(pos) {
1804+        var iOffset, offset;
1805+        offset = this.getOffset(pos);
1806+        if (oFrame) {
1807+          iOffset = $(oFrame).offset();
1808+          offset.top += iOffset.top;
1809+          offset.left += iOffset.left;
1810+        }
1811+        return offset;
1812+      }
1813+    };
1814+    oDocument = null;
1815+    oWindow = null;
1816+    oFrame = null;
1817+    setContextBy = function(settings) {
1818+      var iframe;
1819+      if (iframe = settings != null ? settings.iframe : void 0) {
1820+        oFrame = iframe;
1821+        oWindow = iframe.contentWindow;
1822+        return oDocument = iframe.contentDocument || oWindow.document;
1823+      } else {
1824+        oFrame = void 0;
1825+        oWindow = window;
1826+        return oDocument = document;
1827+      }
1828+    };
1829+    discoveryIframeOf = function($dom) {
1830+      var error;
1831+      oDocument = $dom[0].ownerDocument;
1832+      oWindow = oDocument.defaultView || oDocument.parentWindow;
1833+      try {
1834+        return oFrame = oWindow.frameElement;
1835+      } catch (_error) {
1836+        error = _error;
1837+      }
1838+    };
1839+    $.fn.caret = function(method, value, settings) {
1840+      var caret;
1841+      if (methods[method]) {
1842+        if ($.isPlainObject(value)) {
1843+          setContextBy(value);
1844+          value = void 0;
1845+        } else {
1846+          setContextBy(settings);
1847+        }
1848+        caret = Utils.contentEditable(this) ? new EditableCaret(this) : new InputCaret(this);
1849+        return methods[method].apply(caret, [value]);
1850+      } else {
1851+        return $.error("Method " + method + " does not exist on jQuery.caret");
1852+      }
1853+    };
1854+    $.fn.caret.EditableCaret = EditableCaret;
1855+    $.fn.caret.InputCaret = InputCaret;
1856+    $.fn.caret.Utils = Utils;
1857+    return $.fn.caret.apis = methods;
1858+  });
1859+
1860+}).call(this);
1861diff --git src/bp-core/js/jquery.caret.txt src/bp-core/js/jquery.caret.txt
1862new file mode 100755
1863index 0000000..36cd1c1
1864--- /dev/null
1865+++ src/bp-core/js/jquery.caret.txt
1866@@ -0,0 +1,22 @@
1867+Copyright (c) 2013 chord.luo@gmail.com
1868+
1869+Permission is hereby granted, free of charge, to any person
1870+obtaining a copy of this software and associated documentation
1871+files (the "Software"), to deal in the Software without
1872+restriction, including without limitation the rights to use,
1873+copy, modify, merge, publish, distribute, sublicense, and/or sell
1874+copies of the Software, and to permit persons to whom the
1875+Software is furnished to do so, subject to the following
1876+conditions:
1877+
1878+The above copyright notice and this permission notice shall be
1879+included in all copies or substantial portions of the Software.
1880+
1881+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
1882+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
1883+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
1884+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
1885+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
1886+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
1887+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
1888+OTHER DEALINGS IN THE SOFTWARE.
1889diff --git src/bp-friends/bp-friends-functions.php src/bp-friends/bp-friends-functions.php
1890index 5f797c7..0e4a402 100644
1891--- src/bp-friends/bp-friends-functions.php
1892+++ src/bp-friends/bp-friends-functions.php
1893@@ -566,3 +566,41 @@ function friends_remove_data( $user_id ) {
1894 add_action( 'wpmu_delete_user',  'friends_remove_data' );
1895 add_action( 'delete_user',       'friends_remove_data' );
1896 add_action( 'bp_make_spam_user', 'friends_remove_data' );
1897+
1898+/**
1899+ * Used by the Activity component's @mentions to print a JSON list of the current user's friends.
1900+ *
1901+ * This is intended to speed up @mentions lookups for a majority of use cases.
1902+ *
1903+ * @see bp_activity_mentions_script()
1904+ */
1905+function bp_friends_prime_mentions_results() {
1906+       if ( ! bp_activity_maybe_load_mentions_scripts() ) {
1907+               return;
1908+       }
1909+
1910+       $friends_query = array(
1911+               'count_total'     => '',                    // Prevents total count
1912+               'populate_extras' => false,
1913+
1914+               'type'            => 'alphabetical',
1915+               'user_id'         => get_current_user_id(),
1916+       );
1917+
1918+       $friends_query = new BP_User_Query( $friends_query );
1919+       $results       = array();
1920+
1921+       foreach ( $friends_query->results as $user ) {
1922+               $result        = new stdClass();
1923+               $result->ID    = $user->user_nicename;
1924+               $result->image = bp_core_fetch_avatar( array( 'html' => false, 'item_id' => $user->ID ) );
1925+               $result->name  = bp_core_get_user_displayname( $user->ID );
1926+
1927+               $results[] = $result;
1928+       }
1929+
1930+       wp_localize_script( 'bp-mentions', 'BP_Suggestions', array(
1931+               'friends' => $results,
1932+       ) );
1933+}
1934+add_action( 'bp_activity_mentions_prime_results', 'bp_friends_prime_mentions_results' );
1935\ No newline at end of file
1936diff --git src/bp-groups/bp-groups-classes.php src/bp-groups/bp-groups-classes.php
1937index 02e26be..7121ea4 100644
1938--- src/bp-groups/bp-groups-classes.php
1939+++ src/bp-groups/bp-groups-classes.php
1940@@ -4387,6 +4387,7 @@ class BP_Groups_Member_Suggestions extends BP_Members_Suggestions {
1941                        'page'            => 1,
1942                        'per_page'        => $this->args['limit'],
1943                        'search_terms'    => $this->args['term'],
1944+                       'search_wildcard' => 'right',
1945                );
1946 
1947                // Only return matches of friends of this user.
1948diff --git src/bp-templates/bp-legacy/buddypress/activity/entry.php src/bp-templates/bp-legacy/buddypress/activity/entry.php
1949index 24e296b..2d759b9 100644
1950--- src/bp-templates/bp-legacy/buddypress/activity/entry.php
1951+++ src/bp-templates/bp-legacy/buddypress/activity/entry.php
1952@@ -97,7 +97,7 @@
1953                                        <div class="ac-reply-avatar"><?php bp_loggedin_user_avatar( 'width=' . BP_AVATAR_THUMB_WIDTH . '&height=' . BP_AVATAR_THUMB_HEIGHT ); ?></div>
1954                                        <div class="ac-reply-content">
1955                                                <div class="ac-textarea">
1956-                                                       <textarea id="ac-input-<?php bp_activity_id(); ?>" class="ac-input" name="ac_input_<?php bp_activity_id(); ?>"></textarea>
1957+                                                       <textarea id="ac-input-<?php bp_activity_id(); ?>" class="ac-input bp-suggestions" name="ac_input_<?php bp_activity_id(); ?>"></textarea>
1958                                                </div>
1959                                                <input type="submit" name="ac_form_submit" value="<?php esc_attr_e( 'Post', 'buddypress' ); ?>" /> &nbsp; <a href="#" class="ac-reply-cancel"><?php _e( 'Cancel', 'buddypress' ); ?></a>
1960                                                <input type="hidden" name="comment_form_id" value="<?php bp_activity_id(); ?>" />
1961diff --git src/bp-templates/bp-legacy/buddypress/activity/post-form.php src/bp-templates/bp-legacy/buddypress/activity/post-form.php
1962index acf7368..a45213a 100644
1963--- src/bp-templates/bp-legacy/buddypress/activity/post-form.php
1964+++ src/bp-templates/bp-legacy/buddypress/activity/post-form.php
1965@@ -27,7 +27,7 @@
1966 
1967        <div id="whats-new-content">
1968                <div id="whats-new-textarea">
1969-                       <textarea name="whats-new" id="whats-new" cols="50" rows="10"><?php if ( isset( $_GET['r'] ) ) : ?>@<?php echo esc_textarea( $_GET['r'] ); ?> <?php endif; ?></textarea>
1970+                       <textarea class="bp-suggestions" name="whats-new" id="whats-new" cols="50" rows="10"><?php if ( isset( $_GET['r'] ) ) : ?>@<?php echo esc_textarea( $_GET['r'] ); ?> <?php endif; ?></textarea>
1971                </div>
1972 
1973                <div id="whats-new-options">
1974diff --git src/bp-xprofile/bp-xprofile-functions.php src/bp-xprofile/bp-xprofile-functions.php
1975index 7cce675..8cedbb0 100644
1976--- src/bp-xprofile/bp-xprofile-functions.php
1977+++ src/bp-xprofile/bp-xprofile-functions.php
1978@@ -590,22 +590,29 @@ function bp_xprofile_bp_user_query_search( $sql, BP_User_Query $query ) {
1979 
1980        $bp = buddypress();
1981 
1982-       $search_terms_clean = esc_sql( esc_sql( $query->query_vars['search_terms'] ) );
1983+       $search_terms_clean = bp_esc_like( $query->query_vars['search_terms'] );
1984 
1985        if ( $query->query_vars['search_wildcard'] === 'left' ) {
1986-               $search_terms_clean = '%' . $search_terms_clean;
1987+               $search_terms_nospace = '%' . $search_terms_clean;
1988+               $search_terms_space   = '%' . $search_terms_clean . ' %';
1989        } elseif ( $query->query_vars['search_wildcard'] === 'right' ) {
1990-               $search_terms_clean = $search_terms_clean . '%';
1991+               $search_terms_nospace =        $search_terms_clean . '%';
1992+               $search_terms_space   = '% ' . $search_terms_clean . '%';
1993        } else {
1994-               $search_terms_clean = '%' . $search_terms_clean . '%';
1995+               $search_terms_nospace = '%' . $search_terms_clean . '%';
1996+               $search_terms_space   = '%' . $search_terms_clean . '%';
1997        }
1998 
1999        // Combine the core search (against wp_users) into a single OR clause
2000        // with the xprofile_data search
2001+       $search_xprofile = $wpdb->prepare(
2002+               "u.{$query->uid_name} IN ( SELECT user_id FROM {$bp->profile->table_name_data} WHERE value LIKE %s OR value LIKE %s )",
2003+               $search_terms_nospace,
2004+               $search_terms_space
2005+       );
2006+
2007        $search_core     = $sql['where']['search'];
2008-       $search_xprofile = "u.{$query->uid_name} IN ( SELECT user_id FROM {$bp->profile->table_name_data} WHERE value LIKE '{$search_terms_clean}' )";
2009        $search_combined = "( {$search_xprofile} OR {$search_core} )";
2010-
2011        $sql['where']['search'] = $search_combined;
2012 
2013        return $sql;