BuddyPress support for YOURLS: WordPress to Twitter

I’ve just added support for BuddyPress to the YOURLS: WordPress to Twitter plugin, which enables WP and BP integration with the YOURLS URL shortening software. This development was done for a secret (though maybe not so secret anymore 😉 ) project for the Commons, to be implemented here soon.

Read more about the release.

BuddyPress Docs 1.1: Doc History

BuddyPress Docs History
BuddyPress Docs History

I’ve just released version 1.1 of BuddyPress Docs, my collaborative editing software for BuddyPress.

The big new feature in version 1.1 is the History tab. After upgrading, you’ll notice that what used to be a single Edit button has been reorganized into three tabs: Read, Edit, and History. History allows you to brows the entire revision history of a document, to compare the differences between two revisions side by side, to view a single revision, or to restore to any point in the document’s history. Access to the History tab can be limited in the same way that access to the Edit tab can be, on a doc-by-doc basis.

This new feature will, I hope, bring some of the best qualities of wikis to BuddyPress Docs, and make Docs an even better way to collaborate.

The feature will be live on the CUNY Academic Commons in the upcoming weeks.

Download BuddyPress Docs from the wordpress.org plugin repo or follow development at Github.

New WordPress plugin: Unconfirmed

If you’ve ever been responsible for supporting an installation of WordPress Multisite with open registration, you know that the activation process can be a significant source of headaches. Sometimes activation emails get caught by spam filters. Sometimes they are overlooked and deleted by unwitting users. And, to complicate matters, WP’s safeguards prevent folks from re-registering with the same username or email address. This can result in a lot of support requests that are not particularly easy to handle. Aside from reaching manually into the database for an activation key, there’s not much the admin can do to help the would-be member of the site.

The Unconfirmed Dashboard panel
The Unconfirmed Dashboard panel

My new WordPress plugin Unconfirmed eases this problem a bit, by providing WPMS admins with a new set of tools for managing unactivated registrations. (By naming it “Unconfirmed”, I fully expect that the plugin will join some great movies and books in the pantheon of Important Cultural Objects.) Unconfirmed adds a new panel to your Network Admin Dashboard (under the Users menu). When you visit the Unconfirmed panel, it gives you a list of all pending registrations on your system. The list is easily sortable by registration date, username, email address, and activation key. For each unactivated registration, there are two actions that the admin can perform. “Resend Activation Email” does exactly what it says: it sends an exact duplicate of the original activation email, as created by the WordPress core activation notification functions. “Activate” allows admins to activate a pending registration manually, which will trigger the activation success email to the user.

At the moment, Unconfirmed is compatible with WordPress Multisite (aka Network mode) only. In the future, I may expand the plugin to work with non-MS installations of WP. Unconfirmed works with BuddyPress, too. The plugin was developed for use on the CUNY Academic Commons.

Download Unconfirmed from the wordpress.org repo or follow its development on Github.

Redirect BuddyPress activity reply links to forum’s “Leave a Reply”

Activity stream replies in BuddyPress are pretty cool, but they have the potential to be confusing. On the CUNY Academic Commons, we have disabled activity replies for activity entries related to blogs and forums, because allowing replies in these cases has the potential to confuse users and fracture conversation.

There are a number of ways that this could (and should, and will!) be improved in future versions of BuddyPress. But, for now, here’s a trick. The following code will change the behavior of the Reply buttons for forum-related activity entries (new forum topics, and forum topic replies), so that instead of sliding down the inline activity comment box, it goes to the Reply form on the forum topic itself.

Side note: This seems like it’d be an easy thing to do, but it turns out to be somewhat complex. As I explain in the inline documentation, the issue of pagination means that there’s no predictable way to easily concatenate a URL for a topic’s reply box (this is one of the things I want to fix in BP core) – you have to fetch the number of total replies and figure out the last page from there. Also, in the case of topic replies, you have to do an additional query to get the id of the topic that the post belongs to, because that info is not stored in the activity table. The function cac_insert_comment_reply_links() below tries to consolidate these lookups to add as few queries as possible to the pageload.

Second side note: This code is not particularly beautiful. It makes direct queries to the bbPress database tables. So sue me.

OK, so the code itself. First, put this chunk into your bp-custom.php file.

/**
 * Gets accurate reply URLs for the activity stream
 *
 * Getting accurate Reply links for forum topics is tricky because of pagination - you need to know
 * how many total posts are in the topic so that you can figure out what the last page should be.
 * Moreover, the forum reply activity items don't have the topic_id stored with them. This function
 * attempts to minimize DB queries by looking up all topic_ids at once, then looking up all post
 * counts at once - adding 2 queries for the activity loop is better than 20.
 *
 * Todo: Get a real redirecter into BuddyPress itself
 */
function cac_insert_comment_reply_links( $has_comments ) {
	global $activities_template, $wpdb, $bbdb;

	do_action( 'bbpress_init' );

	$topics_data = array();
	$posts_data = array();
	foreach( $activities_template->activities as $key => $activity ) {
		if ( $activity->type == 'new_forum_topic' ) {
			$topic_id = $activity->secondary_item_id;
			$topics_data[$topic_id]['url'] = $activity->primary_link;
			$topics_data[$topic_id]['activity_key'] = $key;	
		}

		if ( $activity->type == 'new_forum_post' ) {
			$post_id = $activity->secondary_item_id;
			$posts_data[$post_id]['url'] = array_pop( array_reverse( explode( '#', $activity->primary_link ) ) );
			$posts_data[$post_id]['activity_key'] = $key; 
		}
	}

	// In cases where we only have the post id, we must do an extra query to get topic ids
	if ( !empty( $posts_data ) ) {
		$post_ids 	= array_keys( $posts_data );
		$post_ids_sql 	= implode( ',', $post_ids );
		$sql 		= $wpdb->prepare( "SELECT topic_id, post_id FROM {$bbdb->posts} WHERE post_id IN ({$post_ids_sql})" );
		$post_topic_ids = $wpdb->get_results( $sql );

		// Now that we have the topic IDs, we can add that info to $topics_data for the main query
		foreach( $post_topic_ids as $post_topic ) {
			$topics_data[$post_topic->topic_id] = $posts_data[$post_topic->post_id];
		}
	}

	// Now for the main event
	// First, make a topic list and get all the associated posts
	$topic_ids 	= implode( ',', array_keys( $topics_data ) );
	$sql		= $wpdb->prepare( "SELECT topic_id, post_id FROM {$bbdb->posts} WHERE topic_id IN ({$topic_ids})" );
	$posts		= $wpdb->get_results( $sql );

	// Now we get counts. BTW it sucks to do it this way
	$counter	= array();
	foreach( $posts as $post ) {
		if ( empty( $counter[$post->topic_id] ) )
			$counter[$post->topic_id] = 1;
		else
			$counter[$post->topic_id]++;
	}

	// Finally, concatenate the reply url and put it in the activities_template
	foreach( $topics_data as $topic_id => $data ) {
		$total_pages = ceil( $counter[$topic_id] / 15 );	
		$reply_url = cac_forum_reply_url( $data['url'], $total_pages, 15 );
		$key = $data['activity_key'];
		$activities_template->activities[$key]->reply_url = $reply_url;
	}

	return $has_comments;
}
add_action( 'bp_has_activities', 'cac_insert_comment_reply_links' );

/**
 * Filters the url of the activity reply link to use reply_url, if present
 */
function cac_filter_activity_reply_link( $link ) {
	global $activities_template;

	if ( !empty( $activities_template->activity->reply_url ) )
		return $activities_template->activity->reply_url;
	else
		return $link;
}
add_action( 'bp_get_activity_comment_link', 'cac_filter_activity_reply_link' );

/**
 * Echoes the proper CSS class for the activity reply link. This is necessary to ensure that 
 * the JS slider does not appear when we have a custom reply_url.
 */
function cac_activity_reply_link_class() {
	global $activities_template;

	if ( !empty( $activities_template->activity->reply_url ) )
		echo 'class="acomment-reply-nojs"';
	else
		echo 'class="acomment-reply"';
}

/**
 * A replacement for bp_activity_can_comment(). Todo: deprecate into a filter when BP 1.3 comes out
 */
function cac_activity_can_comment() {
	global $activities_template, $bp;

	if ( false === $activities_template->disable_blogforum_replies || (int)$activities_template->disable_blogforum_replies ) {
		// If we've got a manually created reply_url (see cac_insert_comment_reply_links(), return true
		if ( !empty( $activities_template->activity->reply_url ) )
			return true;

		if ( 'new_blog_post' == bp_get_activity_action_name() || 'new_blog_comment' == bp_get_activity_action_name() || 'new_forum_topic' == bp_get_activity_action_name() || 'new_forum_post' == bp_get_activity_action_name() )
			return false;
	}
	
	return true;
}

You’ll note that there are a few places in that code where the number 15 is mentioned explicitly. I’m assuming that you’re using 15 posts-per-page for your single topic pagination. You can change this number accordingly if you want.

Next, you’ll have to make a few changes in your theme’s activity/entry.php to account for the changes. There are two relevant changes. First, you’ll be removing the activity reply button’s CSS class (hardcoded by default) and replacing it with the dynamically generated version in cac_activity_reply_link_class(). Second, you’ll be swapping out the checks for bp_activity_can_comment() with cac_activity_can_comment(), so that you can still block blog-activity comments. The code below is lines 27-29 of my activity/entry.php – you should be able to figure out which lines to replace with the following, as I haven’t changed much.

<?php if ( is_user_logged_in() &amp;&amp; cac_activity_can_comment() ) : ?>
	<a href="<?php bp_activity_comment_link() ?>" <?php cac_activity_reply_link_class() ?> id="acomment-comment-<?php bp_activity_id() ?>"><?php _e( 'Reply', 'buddypress' ) ?> (<span><?php bp_activity_comment_count() ?></span>)</a>
<?php endif; ?>

Finally, because you’ve changed the CSS selector on some of the reply buttons, you’ll want to add some styles to your stylesheet. These are borrowed right from bp-default.

.activity-list div.activity-meta a.acomment-reply-nojs {
	background: #fff9db;
	border-bottom: 1px solid #ffe8c4;
	border-right: 1px solid #ffe8c4;
	color: #ffa200;
}

div.activity-meta a.acomment-reply-nojs:hover {
	background: #f7740a;
	color: #fff;
    border-color: #f7740a;
}

Good luck!

New BuddyPress plugin: BuddyPress Docs

When I explain the CUNY Academic Commons to someone for the first time, the words ‘connect’ and ‘collaborate’ usually loom large. We provide a number of tools to make it easier for people to find each other (that’s the connecting), and then we try to make it easy for those people to work together on projects that matter to them (that’s the collaborating). Today I am releasing the first public beta of a new BuddyPress plugin that will, I hope, be an important tool in the Commons (and BuddyPress) collaboration toolbox: BuddyPress Docs.

BuddyPress Docs adds a new tab to groups where members can collectively create and edit documents, using an easy-to-use rich text editor. Docs support tagging, hierarchy, oEmbed multimedia, and much more. I’ve written about the feature list in detail on the BuddyPress Docs homepage.

Today the project is being released in a near-stable public beta. That means a few things. First, for the moment you run the plugin on a production site at your own risk, as there are bound to be bugs and rough spots (which I hope you will report back to me!). Second, it means that it’s not up and running on the Commons quite yet. BuddyPress Docs is slated to be turned on here with the release of Commons 1.2, which should be sometime in the next few weeks. The intervening weeks should give folks in the general BuddyPress community some time to put Docs through its paces before it gets put to work here.

The official documentation states that BuddyPress Docs requires WordPress 3.1 and BuddyPress 1.3. The latter requirement is a bit of an overstatement; Docs has been tested with BuddyPress 1.2.8 and it works well. However, the former requirement is strict: though the plugin might appear to work with versions of WP prior to 3.1, certain key features will not work (in particular, taxonomies will not work correctly, so that all Docs will appear on every group, no matter which group created them). Perhaps for the 1.0 stable release I’ll forcibly prevent the plugin from being loaded on those earlier versions of WP.

You can follow the plugin’s development at http://github.com/boonebgorges/buddypress-docs.

Hardening BuddyPress Group Documents

The BuddyPress Group Documents plugin allows groups a handy way for users to share documents with fellow members of a BP group. It’s crucial to the work that is done on the CUNY Academic Commons. But, by default, the plugin stores documents in a subdirectory of your WP uploads folder (usually /wp-content/blogs.dir/ on multisite). That means that documents are available directly, to anyone who has the URL, regardless of the public/private/hidden status of groups. This isn’t a problem from within BuddyPress, since URLs for the documents only appear inside of the protected group interface. But if the URL is shared, then the document becomes publicly available. Worse, if someone posts the URL of a document in a public place, search engine bots will find it, and the contents of the document could end up in Google.

I wrote a few helper functions to change this behavior. The strategy is this: Move the files so that they are not accessible via URL, ie in a directory above the web root. (In my case, it’s a directory called bp-group-documents, just above my web root.) Then, catch requests of a certain type (I’ve chosen to go with a URL parameter get_group_doc=), and check them to see whether the current user has the adequate permission to access the document in question. Finally, make sure that all of the URLs and paths that BPGD uses to upload and display documents are filtered to the updated versions. I’ve provided my code below – use and modify at your pleasure. You should be able to place it in your plugins/bp-custom.php file, and then move your existing docs from their current location (probably something like wp-content/blogs.dir/1/files/group-documents) to the new directory.

I also added a line to my .htaccess file to ensure that requests to the old URLs are redirected to the new, hardened URL. That line is this:


RewriteRule ^wp\-content/blogs\.dir/1/files/group\-documents/(.*) /?get_group_doc=$1 [R,L]
 

Obviously, you may have to modify it for different file paths.

EDITED Feb 8, 2011 to include the code for creating directories when none exist


define( 'BP_GROUP_DOCUMENTS_SECURE_PATH', substr( ABSPATH, 0, strrpos( rtrim( ABSPATH, '/' ), '/' ) ) . '/bp-group-documents/' );

function cac_filter_doc_url( $doc_url, $group_id, $file ) {
	$url = bp_get_root_domain() . '?get_group_doc=' . $group_id . '/' . $file;
	return $url;
}
add_filter( 'bp_group_documents_file_url', 'cac_filter_doc_url', 10, 3 );

function cac_filter_doc_path( $doc_url, $group_id, $file ) {
	$document_dir = BP_GROUP_DOCUMENTS_SECURE_PATH . $group_id . '/';
	
	if ( !is_dir( $document_dir ) )
		mkdir( $document_dir, 0775, true );

	$path = BP_GROUP_DOCUMENTS_SECURE_PATH . $group_id . '/' . $file;
	return $path;
}
add_filter( 'bp_group_documents_file_path', 'cac_filter_doc_path', 10, 3 );

function cac_catch_group_doc_request() {
	$error = false;

	if ( empty( $_GET['get_group_doc'] ) )
		return;
	
	$doc_id = $_GET['get_group_doc'];
	
	// Check to see whether the current user has access to the doc in question
	$file_deets 	= explode( '/', $doc_id );
	$group_id 	= $file_deets[0];	
	$group		= new BP_Groups_Group( $group_id );
	
	if ( empty( $group->id ) ) {
		$error = array(
			'message' 	=> 'That group does not exist.',
			'redirect'	=> bp_get_root_domain()
		);
	} else {
		if ( $group->status != 'public' ) {
			// If the group is not public, then the user must be logged in and
			// a member of the group to download the document
			if ( !is_user_logged_in() || !groups_is_user_member( bp_loggedin_user_id(), $group_id ) ) {
				$error = array(
					'message' 	=> sprintf( 'You must be a logged-in member of the group %s to access this document. If you are a member of the group, please log into the site and try again.', $group->name ),
					'redirect'	=> bp_get_group_permalink( $group )
				);
			}
		}
		
		// If we have gotten this far without an error, then the download can go through
		if ( !$error ) {
			
			$doc_path = BP_GROUP_DOCUMENTS_SECURE_PATH . $doc_id;
			
			if ( file_exists( $doc_path ) ) {
				$mime_type = mime_content_type( $doc_path );
				$doc_size = filesize( $doc_path );
				
				header("Cache-Control: public, must-revalidate, post-check=0, pre-check=0");
				header("Pragma: hack");
					
				header("Content-Type: $mime_type; name='" . $file_deets[1] . "'");
				header("Content-Length: " . $doc_size );
				
				header('Content-Disposition: attachment; filename="' . $file_deets[1] . '"');
				header("Content-Transfer-Encoding: binary");              
				ob_clean();
				flush();  		
				readfile( $doc_path );
				die();
		       
			} else {
				// File does not exist
				$error = array(
					'message' 	=> 'The file could not be found.',
					'redirect'	=> bp_get_group_permalink( $group ) . '/documents'
				);
			}
		}
	}
		
	// If we have gotten this far, there was an error. Add a message and redirect
	bp_core_add_message( $error['message'], 'error' );
	bp_core_redirect( $error['redirect'] );
}
add_filter( 'wp', 'cac_catch_group_doc_request', 1 );

// http://www.php.net/manual/en/function.mime-content-type.php#87856
if(!function_exists('mime_content_type')) {

    function mime_content_type($filename) {

        $mime_types = array(

            'txt' => 'text/plain',
            'htm' => 'text/html',
            'html' => 'text/html',
            'php' => 'text/html',
            'css' => 'text/css',
            'js' => 'application/javascript',
            'json' => 'application/json',
            'xml' => 'application/xml',
            'swf' => 'application/x-shockwave-flash',
            'flv' => 'video/x-flv',

            // images
            'png' => 'image/png',
            'jpe' => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'jpg' => 'image/jpeg',
            'gif' => 'image/gif',
            'bmp' => 'image/bmp',
            'ico' => 'image/vnd.microsoft.icon',
            'tiff' => 'image/tiff',
            'tif' => 'image/tiff',
            'svg' => 'image/svg+xml',
            'svgz' => 'image/svg+xml',

            // archives
            'zip' => 'application/zip',
            'rar' => 'application/x-rar-compressed',
            'exe' => 'application/x-msdownload',
            'msi' => 'application/x-msdownload',
            'cab' => 'application/vnd.ms-cab-compressed',

            // audio/video
            'mp3' => 'audio/mpeg',
            'qt' => 'video/quicktime',
            'mov' => 'video/quicktime',

            // adobe
            'pdf' => 'application/pdf',
            'psd' => 'image/vnd.adobe.photoshop',
            'ai' => 'application/postscript',
            'eps' => 'application/postscript',
            'ps' => 'application/postscript',

            // ms office
            'doc' => 'application/msword',
            'rtf' => 'application/rtf',
            'xls' => 'application/vnd.ms-excel',
            'ppt' => 'application/vnd.ms-powerpoint',

            // open office
            'odt' => 'application/vnd.oasis.opendocument.text',
            'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
        );

        $ext = strtolower(array_pop(explode('.',$filename)));
        if (array_key_exists($ext, $mime_types)) {
            return $mime_types[$ext];
        }
        elseif (function_exists('finfo_open')) {
            $finfo = finfo_open(FILEINFO_MIME);
            $mimetype = finfo_file($finfo, $filename);
            finfo_close($finfo);
            return $mimetype;
        }
        else {
            return 'application/octet-stream';
        }
    }
}


 

Group Announcements tab in BuddyPress

I had a request or two to explain how I built the Group Announcements feature on the CUNY Academic Commons. Here goes.

Brief background: When the Commons was upgraded to BuddyPress 1.2, we got the benefit of interactive activity streams everywhere, including groups. This caused some confusion, however, as users were uncertain where conversation best fit into the Commons’s architecture: in the Forums (where it had been traditionally), or in the activity stream. In some communities this kind of fracturing might be okay or even welcome, but in ours it was confusing. At the same time, we wanted a way for group admins and mods to send important notices to the members of their groups. By taking the group activity updates and repurposing it as a Group Announcements section, I was able to kill two birds with one stone: providing an announcement space for mods, while focusing extended discussion in the forums.

You can download the CAC Group Announcements plugin here.

I’m not putting this in the repo at the moment because I don’t want to build a proper admin UI and support it 🙂 For that reason, here is a primer on how the plugin works – if you want to customize or maintain it, you’re on your own, buster.

  1. The CAC_Group_Announcements class is an instance of the BuddyPress Group Extension API. It is responsible for creating the Announcements tab markup and adding it to the nav. You’ll notice that the majority of the markup is created by including bp-default’s activity-loop.php and post-form.php templates. You could customize this more if you wanted.
  2. bp_is_group_announcements() is a little template tag that can be used to test whether you’re looking at a group announcements page. This is needed for the activity filter, in step 3.
  3. cac_set_announcement_filter() adds a filter to the bp_has_activities query string when you are looking at an announcements page, so that it only displays activity items of the type activity_update. In other words, when you are looking at the regular activity stream for the group, you see all of the associated group activity items (new members, forum posts, etc) but when you’re on the announcements page you only see activity updates (which, remember, have been repurposed as announcements).

As I look at the code, I see that there are things I would definitely change if I were going to make this into a distributed plugin. If you want to make those changes, be my guest. You’re welcome to help each other in the comment section, but I won’t be formally supporting this, as it is a very basic hack that should happen at the theme level anyway. Good luck!

New BuddyPress plugin: BP External Activity

Here on the CUNY Academic Commons, we have an installation of MediaWiki which runs alongside our WordPress/BuddyPress installation. Without some additional coding, edits on the wiki don’t show up in the Commons News feed, and as a result they tend to get a little lost in the shuffle. This new plugin, BP External Activity, addresses this issue by allowing the administrator of a BuddyPress installation to specify an arbitrary RSS feed whose items will be regularly imported into the activity stream.

The plugin imports RSS feeds every hour. You may find that you need to decrease your Simplepie cache time to make it work:
add_filter( 'wp_feed_cache_transient_lifetime', create_function('$a', 'return 600;') );
reduces the RSS cache to ten minutes, for example. Put that in your bp-custom.php file if you are having problems with the plugin.

At the moment, the plugin uses the Author field from the RSS feed to look for a matching author in your WP database. If it doesn’t find one, it uses the unlinked text ‘A user’, as in ‘A user edited the wiki page…’.

Thanks to Andy Peatling, whose External Group Blogs served as the inspiration for a good part of the code.

Visit the plugin homepage for more details.

BuddyPress plugins running on the CUNY Academic Commons

A few people have asked recently for a list of the plugins installed on the CUNY Academic Commons. In the spirit of Joe’s post, here I thought I’d make it public. I’m going to limit myself to the BuddyPress plugins here, for the sake of simplicity. (I’d like to write a series of posts on the anatomy of the CUNY Academic Commons; maybe this will be the first in that series.) Here they are, in no particular order other than the order in which they appear on my plugin list.

  • BP TinyMCE. This plugin is messed up, and I have part of it switched off, but I still use the filters that allow additional tags through, in case people want to write some raw HTML in their forum posts, etc.
  • BP Groupblog. Allows blogs to be associated with groups, displaying posts on that group’s activity feed and automatically credentialing group members on the blog. I did some custom modifications to the way the plugin works so that clicking on the Blog tab in a group leads you to subdomain address rather than the Groupblog custom address (thereby also ensuring that visitors see the intended blog theme rather than the BP-ish theme).
  • BP MPO Activity Filter. This plugin works along with More Privacy Options to ensure that the new privacy settings are understood by Buddypress and that blog-related activity items are displayed to the appropriate people.
  • BuddyPress Group Documents. This one is crucial to our members, who often use the plugin to share collaborative docs.
  • BP Include Non-Member Comments makes sure that blog comments from non-members are included on the sitewide activity feed.
  • BP External Activity – an as-yet unreleased plugin I wrote that brings in items from an external RSS feed and adds them to the sitewide activity feed. We’re using it for MediaWiki edits.
  • BP Group Management lets admins add people to groups. Very handy for putting together a group quickly, without having to wait for invites.
  • BP System Report. We’re using this one to keep track of some data in our system and report it back to members and administrators.
  • BuddyPress Group Email Subscription allows users to subscribe to immediate or digest email notification of group activity. Right now we’re running it on a trial basis with a handful of members, in order to test it. (Here’s how to run it with a whitelist of users, if you want)
  • BuddyPress Terms of Service Agreement, another as-yet-unreleased plugin (this one by CAC Dev Team member Chris Stein) that requires new members to check TOS acceptance box before being allowed to register.
  • Custom Profile Filters for BuddyPress allows users to customize the way that their profile interests become links
  • Enhanced BuddyPress Widgets. Lets the admin decide the default state of BP widgets on the front page.
  • Forum Attachments for BuddyPress. Another of our most important BP plugins, this one allows users to share files via the group forums.
  • Group Forum Subscription for BuddyPress. This is our legacy email notification system, which is going to be in place until I get back from my honeymoon and can replace it 🙂
  • Invite Anyone lets our users invite new members to the community and makes it easier to populate groups.

Questions about any of these plugins or how they work with BuddyPress? Ask in the comments.