diff --git a/src/bp-activity/bp-activity-filters.php b/src/bp-activity/bp-activity-filters.php
index 82f992a..5dc0d4d 100644
--- a/src/bp-activity/bp-activity-filters.php
+++ b/src/bp-activity/bp-activity-filters.php
@@ -386,6 +386,8 @@ function bp_activity_make_nofollow_filter( $text ) {
 /**
  * Truncate long activity entries when viewed in activity streams.
  *
+ * This method can only be used inside the Activity loop.
+ *
  * @since BuddyPress (1.5.0)
  *
  * @uses bp_is_single_activity()
@@ -402,9 +404,20 @@ function bp_activity_make_nofollow_filter( $text ) {
 function bp_activity_truncate_entry( $text ) {
 	global $activities_template;
 
+	/**
+	 * Provides a filter that lets you choose whether to skip this filter on a per-activity basis.
+	 *
+	 * @param bool $maybe_truncate_text If true, text should be checked to see if it needs truncating.
+	 * @since BuddyPress (2.3.0)
+	 */
+	$maybe_truncate_text = apply_filters( 'bp_activity_maybe_truncate_entry',
+		! in_array( $activities_template->activity->type, array( 'new_blog_post', ), true )
+	);
+
 	// The full text of the activity update should always show on the single activity screen
-	if ( bp_is_single_activity() )
+	if ( ! $maybe_truncate_text || bp_is_single_activity() ) {
 		return $text;
+	}
 
 	/**
 	 * Filters the appended text for the activity excerpt.
diff --git a/src/bp-activity/bp-activity-functions.php b/src/bp-activity/bp-activity-functions.php
index 756dc03..67c13ec 100644
--- a/src/bp-activity/bp-activity-functions.php
+++ b/src/bp-activity/bp-activity-functions.php
@@ -1869,20 +1869,15 @@ function bp_activity_post_type_publish( $post_id = 0, $post = null, $user_id = 0
 		'recorded_time'     => $post->post_date_gmt,
 	);
 
-	// Remove large images and replace them with just one image thumbnail.
-	if ( ! empty( $activity_args['content'] ) ) {
-		$activity_args['content'] = bp_activity_thumbnail_content_images( $activity_args['content'], $activity_args['primary_link'], $activity_args );
-	}
-
 	if ( ! empty( $activity_args['content'] ) ) {
 		// Create the excerpt.
-		$activity_excerpt = bp_create_excerpt( $activity_args['content'] );
+		$activity_summary = bp_activity_create_summary( $activity_args['content'], $activity_args );
 
 		// Backward compatibility filter for blog posts.
 		if ( 'blogs' == $activity_post_object->component_id )  {
-			$activity_args['content'] = apply_filters( 'bp_blogs_record_activity_content', $activity_excerpt, $activity_args['content'], $activity_args, $post->post_type );
+			$activity_args['content'] = apply_filters( 'bp_blogs_record_activity_content', $activity_summary, $activity_args['content'], $activity_args, $post->post_type );
 		} else {
-			$activity_args['content'] = $activity_excerpt;
+			$activity_args['content'] = $activity_summary;
 		}
 	}
 
@@ -1963,17 +1958,13 @@ function bp_activity_post_type_update( $post = null ) {
 	$activity = new BP_Activity_Activity( $activity_id );
 
 	if ( ! empty( $post->post_content ) ) {
-		// Make sure to update the thumbnail image.
-		$post_content = bp_activity_thumbnail_content_images( $post->post_content, $activity->primary_link, (array) $activity );
-
-		// Generate an excerpt.
-		$activity_excerpt = bp_create_excerpt( $post_content );
+		$activity_summary = bp_activity_create_summary( $post->post_content, (array) $activity );
 
 		// Backward compatibility filter for the blogs component.
 		if ( 'blogs' == $activity_post_object->component_id ) {
-			$activity->content = apply_filters( 'bp_blogs_record_activity_content', $activity_excerpt, $post_content, (array) $activity, $post->post_type );
+			$activity->content = apply_filters( 'bp_blogs_record_activity_content', $activity_summary, $post->post_content, (array) $activity, $post->post_type );
 		} else {
-			$activity->content = $activity_excerpt;
+			$activity->content = $activity_summary;
 		}
 	}
 
@@ -2585,6 +2576,8 @@ function bp_activity_hide_user_activity( $user_id ) {
  * through the content, grabs the first image and converts it to a thumbnail,
  * and removes the rest of the images from the string.
  *
+ * As of BuddyPress 2.3, this function is no longer in use.
+ *
  * @since BuddyPress (1.2.0)
  *
  * @uses esc_attr()
@@ -2656,6 +2649,161 @@ function bp_activity_thumbnail_content_images( $content, $link = false, $args =
 }
 
 /**
+ * Create a rich summary of an activity item for the activity stream.
+ *
+ * More than just a simple excerpt, the summary could contain oEmbeds and other types of media.
+ * Currently, it's only used for blog post items, but it will probably be used for all types of
+ * activity in the future.
+ *
+ * @param string $content The content of the activity item.
+ * @param array $activity_args The data passed to bp_activity_add() or the values from an Activity obj.
+ * @return string
+ * @since BuddyPress (2.3.0)
+ */
+function bp_activity_create_summary( $content, $activity ) {
+	$args = array(
+		'width' => isset( $GLOBALS['content_width'] ) ? (int) $GLOBALS['content_width'] : 'medium',
+	);
+
+	// Get the WP_Post object if this activity type is a blog post.
+	if ( $activity['type'] === 'new_blog_post' ) {
+		$content = get_post( $activity['secondary_item_id'] );
+	}
+
+
+	/**
+	 * Filter the class name of the media extractor when creating an Activity summary.
+	 *
+	 * Use this filter to change the media extractor used to extract media info for the activity item.
+	 *
+	 * @param string $extractor Class name.
+	 * @param string $content The content of the activity item.
+	 * @param array $activity The data passed to bp_activity_add() or the values from an Activity obj.
+	 * @since BuddyPress (2.3.0)
+	 */
+	$extractor = apply_filters( 'bp_activity_create_summary_extractor_class', 'BP_Media_Extractor', $content, $activity );
+	$extractor = new $extractor;
+
+	/**
+	 * Filter the arguments passed to the media extractor when creating an Activity summary.
+	 *
+	 * @param array $args Array of bespoke data for the media extractor.
+	 * @param string $content The content of the activity item.
+	 * @param array $activity The data passed to bp_activity_add() or the values from an Activity obj.
+	 * @param BP_Media_Extractor $extractor The media extractor object.
+	 * @since BuddyPress (2.3.0)
+	 */
+	$args = apply_filters( 'bp_activity_create_summary_extractor_args', $args, $content, $activity, $extractor );
+
+
+	// Extract media information from the $content.
+	$media = $extractor->extract( $content, BP_Media_Extractor::ALL, $args );
+
+	// If we converted $content to an object earlier, flip it back to a string.
+	if ( is_a( $content, 'WP_Post' ) ) {
+		$content = $content->post_content;
+	}
+
+	$para_count     = substr_count( strtolower( wpautop( $content ) ), '<p>' );
+	$has_audio      = ! empty( $media['has']['audio'] )           && $media['has']['audio'];
+	$has_videos     = ! empty( $media['has']['videos'] )          && $media['has']['videos'];
+	$has_feat_image = ! empty( $media['has']['featured_images'] ) && $media['has']['featured_images'];
+	$has_galleries  = ! empty( $media['has']['galleries'] )       && $media['has']['galleries'];
+	$has_images     = ! empty( $media['has']['images'] )          && $media['has']['images'];
+	$has_embeds     = false;
+
+	// Embeds must be subtracted from the paragraph count.
+	if ( ! empty( $media['has']['embeds'] ) ) {
+		$has_embeds = $media['has']['embeds'] > 0;
+		$para_count -= count( $media['has']['embeds'] );
+	}
+
+	$extracted_media = array();
+	$use_media_type  = '';
+	$image_source    = '';
+
+	// If it's a short article and there's an embed/audio/video, use it.
+	if ( $para_count <= 3 ) {
+		if ( $has_embeds ) {
+			$use_media_type = 'embeds';
+		} elseif ( $has_audio ) {
+			$use_media_type = 'audio';
+		} elseif ( $has_videos ) {
+			$use_media_type = 'videos';
+		}
+	}
+
+	// If not, or in any other situation, try to use an image.
+	if ( ! $use_media_type && $has_images ) {
+		$use_media_type = 'images';
+		$image_source   = 'html';
+	
+		// Featured Image > Galleries > inline <img>.
+		if ( $has_feat_image ) {
+			$image_source = 'featured_images';
+
+		} elseif ( $has_galleries ) {
+			$image_source = 'galleries';
+		}
+	}
+
+	// Extract an item from the $media results.
+	if ( $use_media_type ) {
+		if ( $use_media_type === 'images' ) {
+			$extracted_media = wp_list_filter( $media[ $use_media_type ], array( 'source' => $image_source ) );
+			$extracted_media = array_shift( $extracted_media );
+		} else {
+			$extracted_media = array_shift( $media[ $use_media_type ] );
+		}
+
+		/**
+		 * Filter the results of the media extractor when creating an Activity summary.
+		 *
+		 * @param array $extracted_media Extracted media item. See {@link BP_Media_Extractor::extract()} for format.
+		 * @param string $content Content of the activity item.
+		 * @param array $activity The data passed to bp_activity_add() or the values from an Activity obj.
+		 * @param array $media All results from the media extraction. See {@link BP_Media_Extractor::extract()} for format.
+		 * @param string $use_media_type The kind of media item that was preferentially extracted.
+		 * @param string $image_source If $use_media_type was "images", the preferential source of the image.
+		 *               Otherwise empty.
+		 * @since BuddyPress (2.3.0)
+		 */
+		$extracted_media = apply_filters(
+			'bp_activity_create_summary_extractor_result',
+			$extracted_media,
+			$content,
+			$activity,
+			$media,
+			$use_media_type,
+			$image_source
+		);
+	}
+
+	// Generate a text excerpt for this activity item (and remove any oEmbeds URLs).
+	$summary = strip_shortcodes( html_entity_decode( strip_tags( $content ) ) );
+	$summary = bp_create_excerpt( preg_replace( '#^\s*(https?://[^\s"]+)\s*$#im', '', $summary ) );
+
+	if ( $use_media_type === 'embeds' ) {
+		$summary .= PHP_EOL . PHP_EOL . $extracted_media['url'];
+	} elseif ( $use_media_type === 'images' ) {
+		$summary .= sprintf( ' <img src="%s">', esc_url( $extracted_media['url'] ) );
+	} elseif ( in_array( $use_media_type, array( 'audio', 'videos' ), true ) ) {
+		$summary .= PHP_EOL . PHP_EOL . $extracted_media['original'];  // Full shortcode.
+	}
+
+	/**
+	 * Filters the newly-generated summary for the activity item.
+	 *
+	 * @param string $summary Activity summary HTML.
+	 * @param string $content $content Content of the activity item.
+	 * @param array $activity The data passed to bp_activity_add() or the values from an Activity obj.
+	 * @param array $extracted_media Media item extracted. See {@link BP_Media_Extractor::extract()} for format.
+	 * @since BuddyPress (2.3.0)
+	 */
+	return apply_filters( 'bp_activity_create_summary', $summary, $content, $activity, $extracted_media );
+}
+
+/**
  * Fetch whether the current user is allowed to mark items as spam.
  *
  * @since BuddyPress (1.6.0)
diff --git a/src/bp-blogs/bp-blogs-activity.php b/src/bp-blogs/bp-blogs-activity.php
index 422e149..c9b3115 100644
--- a/src/bp-blogs/bp-blogs-activity.php
+++ b/src/bp-blogs/bp-blogs-activity.php
@@ -352,11 +352,6 @@ function bp_blogs_record_activity( $args = '' ) {
 
 	$r = wp_parse_args( $args, $defaults );
 
-	// Remove large images and replace them with just one image thumbnail
-	if ( ! empty( $r['content'] ) ) {
-		$r['content'] = bp_activity_thumbnail_content_images( $r['content'], $r['primary_link'], $r );
-	}
-
 	if ( ! empty( $r['action'] ) ) {
 
 		/**
@@ -376,11 +371,11 @@ function bp_blogs_record_activity( $args = '' ) {
 		 *
 		 * @since BuddyPress (1.2.0)
 		 *
-		 * @param string $value Generated excerpt from content for the activity stream.
+		 * @param string $value Generated summary from content for the activity stream.
 		 * @param string $value Content for the activity stream.
 		 * @param array  $r     Array of arguments used for the activity stream item.
 		 */
-		$r['content'] = apply_filters( 'bp_blogs_record_activity_content', bp_create_excerpt( $r['content'] ), $r['content'], $r );
+		$r['content'] = apply_filters( 'bp_blogs_record_activity_content', bp_activity_create_summary( $r['content'], $r ), $r['content'], $r );
 	}
 
 	// Check for an existing entry and update if one exists.
diff --git a/src/bp-core/bp-core-classes.php b/src/bp-core/bp-core-classes.php
index b50325d..70d9084 100644
--- a/src/bp-core/bp-core-classes.php
+++ b/src/bp-core/bp-core-classes.php
@@ -20,3 +20,4 @@ require dirname( __FILE__ ) . '/classes/class-bp-walker-nav-menu-checklist.php';
 require dirname( __FILE__ ) . '/classes/class-bp-suggestions.php';
 require dirname( __FILE__ ) . '/classes/class-bp-members-suggestions.php';
 require dirname( __FILE__ ) . '/classes/class-bp-recursive-query.php';
+require dirname( __FILE__ ) . '/classes/class-bp-media-extractor.php';
diff --git a/src/bp-core/classes/class-bp-media-extractor.php b/src/bp-core/classes/class-bp-media-extractor.php
new file mode 100644
index 0000000..ab40585
--- /dev/null
+++ b/src/bp-core/classes/class-bp-media-extractor.php
@@ -0,0 +1,901 @@
+<?php
+/**
+ * Core component classes.
+ *
+ * @package BuddyPress
+ * @subpackage Core
+ */
+
+// Exit if accessed directly
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Extracts media from text. Use {@link extract()}.
+ *
+ * The supported types are links, mentions, images, shortcodes, embeds, audio, video, and "all".
+ * This is what each type extracts:
+ *
+ * Links:      <a href="http://example.com">
+ * Mentions:   @name
+ *             If the Activity component is enabled, we use it to parse out any @names. A consequence
+ *             to note is that the "name" mentioned must match a real user account. If it's a made-up
+ *             @name, then it isn't extracted.
+ *             If the Activity component is disabled, any @name is extracted (both those matching
+ *             real accounts, and those made-up).
+ * Images:     <img src="image.gif">, [gallery], [gallery ids="2,3"], featured images (Post thumbnails).
+ *             If an extracted image is in the Media Library, then its resolution will be included.
+ * Shortcodes: Extract information about any (registered) shortcodes.
+ *             This includes any shortcodes indirectly covered by any of the other media extraction types.
+ *             For example, [gallery].
+ * Embeds:     Extract any URL matching a registered oEmbed handler.
+ * Audio:      <a href="*.mp3"">, [audio]
+ *             See wp_get_audio_extensions() for supported audio formats.
+ * Video:      [video]
+ *             See wp_get_video_extensions() for supported video formats.
+ *
+ * @see BP_Media_Extractor::extract() Use this to extract media.
+ * @since BuddyPress (2.3.0)
+ */
+class BP_Media_Extractor {
+	/**
+	 * Media type.
+	 *
+	 * @since BuddyPress (2.3.0)
+	 * @var int
+	 */
+	const ALL        = 255;
+	const LINKS      = 1;
+	const MENTIONS   = 2;
+	const IMAGES     = 4;
+	const SHORTCODES = 8;
+	const EMBEDS     = 16;
+	const AUDIO      = 32;
+	const VIDEOS     = 64;
+
+
+	/**
+	 * Extract media from text.
+	 *
+	 * @param string|WP_Post $richtext Content to parse.
+	 * @param int $what_to_extract Media type to extract (optional).
+	 * @param array $extra_args Bespoke data for a particular extractor (optional).
+	 * @return array {
+	 *     @type array $has Extracted media counts. {
+	 *         @type int $audio
+	 *         @type int $embeds
+	 *         @type int $images
+	 *         @type int $links
+	 *         @type int $mentions
+	 *         @type int $shortcodes
+	 *         @type int $video
+	 *     }
+	 *     @type array $audio Extracted audio. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $source Media source. Either "html" or "shortcodes".
+	 *         @type string $url Link to audio.
+	 *     }
+	 *     @type array $embeds Extracted oEmbeds. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $url oEmbed link.
+	 *     }
+	 *     @type array $images Extracted images. {
+	 *         Array of extracted media.
+	 *
+	 *         @type int $gallery_id Gallery ID. Optional, not always set.
+	 *         @type int $height Width of image. If unknown, set to 0.
+	 *         @type string $source Media source. Either "html" or "galleries".
+	 *         @type string $url Link to image.
+	 *         @type int $width Width of image. If unknown, set to 0.
+	 *     }
+	 *     @type array $links Extracted URLs. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $url Link.
+	 *     }
+	 *     @type array $mentions Extracted mentions. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $name @mention.
+	 *         @type string $user_id User ID. Optional, only set if Activity component enabled.
+	 *     }
+	 *     @type array $shortcodes Extracted shortcodes. {
+	 *         Array of extracted media.
+	 *
+	 *         @type array $attributes Key/value pairs of the shortcodes attributes (if any).
+	 *         @type string $content Text wrapped by the shortcode.
+	 *         @type string $type Shortcode type.
+	 *         @type string $original The entire shortcode.
+	 *     }
+	 *     @type array $videos Extracted video. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $source Media source. Currently only "shortcodes".
+	 *         @type string $url Link to audio.
+	 *     }
+	 * }
+	 * @since BuddyPress (2.3.0)
+	 */
+	public function extract( $richtext, $what_to_extract = self::ALL, $extra_args = array() ) {
+		$media = array();
+
+		// Support passing a WordPress Post for the $richtext parameter.
+		if ( is_a( $richtext, 'WP_Post' ) ) {
+			$extra_args['post'] = $richtext;
+			$richtext           = $extra_args['post']->post_content;
+		}
+
+		$plaintext = $this->strip_markup( $richtext );
+
+
+		// Extract links.
+		if ( self::LINKS & $what_to_extract ) {
+			$media = array_merge_recursive( $media, $this->extract_links( $richtext, $plaintext, $extra_args ) );
+		}
+
+		// Extract mentions.
+		if ( self::MENTIONS & $what_to_extract ) {
+			$media = array_merge_recursive( $media, $this->extract_mentions( $richtext, $plaintext, $extra_args ) );
+		}
+
+		// Extract images.
+		if ( self::IMAGES & $what_to_extract ) {
+			$media = array_merge_recursive( $media, $this->extract_images( $richtext, $plaintext, $extra_args ) );
+		}
+
+		// Extract shortcodes.
+		if ( self::SHORTCODES & $what_to_extract ) {
+			$media = array_merge_recursive( $media, $this->extract_shortcodes( $richtext, $plaintext, $extra_args ) );
+		}
+
+		// Extract oEmbeds.
+		if ( self::EMBEDS & $what_to_extract ) {
+			$media = array_merge_recursive( $media, $this->extract_embeds( $richtext, $plaintext, $extra_args ) );
+		}
+
+		// Extract audio.
+		if ( self::AUDIO & $what_to_extract ) {
+			$media = array_merge_recursive( $media, $this->extract_audio( $richtext, $plaintext, $extra_args ) );
+		}
+
+		// Extract video.
+		if ( self::VIDEOS & $what_to_extract ) {
+			$media = array_merge_recursive( $media, $this->extract_video( $richtext, $plaintext, $extra_args ) );
+		}
+
+		/**
+		 * Filters media extracted from text.
+		 *
+		 * @param array $media Extracted media. See {@link BP_Media_Extractor::extract()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param int $what_to_extract Media type to extract.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_extract', $media, $richtext, $what_to_extract, $extra_args, $plaintext );
+	}
+
+
+	/**
+	 * Content type specific extraction methods.
+	 *
+	 * You shouldn't need to use these directly; just use {@link BP_Media_Extractor::extract()}.
+	 */
+
+	/**
+	 * Extract `<a href>` tags from text.
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Bespoke data for a particular extractor (optional).
+	 * @return array {
+	 *     @type array $has Extracted media counts. {
+	 *         @type int $links
+	 *     }
+	 *     @type array $links Extracted URLs. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $url Link.
+	 *     }
+	 * }
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_links( $richtext, $plaintext, $extra_args = array() ) {
+		$data = array( 'has' => array( 'links' => 0 ), 'links' => array() );
+
+		// Matches: href="text" and href='text'
+		if ( stripos( $richtext, 'href=' ) !== false ) {
+			preg_match_all( '#href=(["\'])([^"\']+)\1#i', $richtext, $matches );
+
+			if ( ! empty( $matches[2] ) ) {
+				$matches[2] = array_unique( $matches[2] );
+
+				foreach ( $matches[2] as $link_src ) {
+					$link_src = esc_url_raw( $link_src );
+
+					if ( $link_src ) {
+						$data['links'][] = array( 'url' => $link_src );
+					}
+				}
+			}
+		}
+
+		$data['has']['links'] = count( $data['links'] );
+
+		/**
+		 * Filters links extracted from text.
+		 *
+		 * @param array $data Extracted links. See {@link BP_Media_Extractor::extract_links()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_links', $data, $richtext, $plaintext, $extra_args );
+	}
+
+	/**
+	 * Extract @mentions tags from text.
+	 *
+	 * If the Activity component is enabled, it is used to parse @mentions.
+	 * The mentioned "name" must match a user account, otherwise it is discarded.
+	 *
+	 * If the Activity component is disabled, any @mentions are extracted.
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Bespoke data for a particular extractor.
+	 * @return array {
+	 *     @type array $has Extracted media counts. {
+	 *         @type int $mentions
+	 *     }
+	 *     @type array $mentions Extracted mentions. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $name @mention.
+	 *         @type string $user_id User ID. Optional, only set if Activity component enabled.
+	 *     }
+	 * }
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_mentions( $richtext, $plaintext, $extra_args = array() ) {
+		$data     = array( 'has' => array( 'mentions' => 0 ), 'mentions' => array() );
+		$mentions = array();
+
+		// If the Activity component is active, use it to parse @mentions.
+		if ( bp_is_active( 'activity' ) ) {
+			$mentions = bp_activity_find_mentions( $plaintext );
+			if ( ! $mentions ) {
+				$mentions = array();
+			}
+
+		// If the Activity component is disabled, instead do a basic parse.
+		} else {
+			if ( strpos( $plaintext, '@' ) !== false ) {
+				preg_match_all( '/[@]+([A-Za-z0-9-_\.@]+)\b/', $plaintext, $matches );
+
+				if ( ! empty( $matches[1] ) ) {
+					$mentions = array_unique( array_map( 'strtolower', $matches[1] ) );
+				}
+			}
+		}
+
+		// Build results
+		foreach ( $mentions as $user_id => $mention_name ) {
+			$mention = array( 'name' => strtolower( $mention_name ) );
+
+			// If the Activity component is active, store the User ID, too.
+			if ( bp_is_active( 'activity' ) ) {
+				$mention['user_id'] = (int) $user_id;
+			}
+
+			$data['mentions'][] = $mention;
+		}
+
+		$data['has']['mentions'] = count( $data['mentions'] );
+
+		/**
+		 * Filters @mentions extracted from text.
+		 *
+		 * @param array $data Extracted @mentions. See {@link BP_Media_Extractor::extract_mentions()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor (optional).
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_mentions', $data, $richtext, $plaintext, $extra_args );
+	}
+
+	/**
+	 * Extract images from `<img src>` tags, [galleries], and featured images from a Post.
+	 *
+	 * If an image is in the Media Library, then its resolution is included in the results.
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Bespoke data for a particular extractor (optional).
+	 * @return array {
+	 *     @type array $has Extracted media counts. {
+	 *         @type int $images
+	 *     }
+	 *     @type array $images Extracted images. {
+	 *         Array of extracted media.
+	 *
+	 *         @type int $gallery_id Gallery ID. Optional, not always set.
+	 *         @type int $height Width of image. If unknown, set to 0.
+	 *         @type string $source Media source. Either "html" or "galleries".
+	 *         @type string $url Link to image.
+	 *         @type int $width Width of image. If unknown, set to 0.
+	 *     }
+	 * }
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_images( $richtext, $plaintext, $extra_args = array() ) {
+		$media = array( 'has' => array( 'images' => 0 ), 'images' => array() );
+
+		$featured_image = $this->extract_images_from_featured_images( $richtext, $plaintext, $extra_args );
+		$galleries      = $this->extract_images_from_galleries( $richtext, $plaintext, $extra_args );
+
+
+		// `<img src>` tags.
+		if ( stripos( $richtext, 'src=' ) !== false ) {
+			preg_match_all( '#src=(["\'])([^"\']+)\1#i', $richtext, $img_srcs );  // matches src="text" and src='text'
+
+			// <img>.
+			if ( ! empty( $img_srcs[2] ) ) {
+				$img_srcs[2] = array_unique( $img_srcs[2] );
+
+				foreach ( $img_srcs[2] as $image_src ) {
+					// Skip data URIs.
+					if ( strtolower( substr( $image_src, 0, 5 ) ) === 'data:' ) {
+						continue;
+					}
+
+					$image_src = esc_url_raw( $image_src );
+					if ( ! $image_src ) {
+						continue;
+					}
+
+					$media['images'][] = array(
+						'source' => 'html',
+						'url'    => $image_src,
+
+						// The image resolution isn't available, but we need to set the keys anyway.
+						'height' => 0,
+						'width'  => 0,
+					);
+				}
+			}
+		}
+
+		// Galleries.
+		if ( ! empty( $galleries ) ) {
+			foreach ( $galleries as $gallery ) {
+				foreach ( $gallery as $image ) {
+					$image_url = esc_url_raw( $image['url'] );
+					if ( ! $image_url ) {
+						continue;
+					}
+
+					$media['images'][] = array(
+						'gallery_id' => $image['gallery_id'],
+						'source'     => 'galleries',
+						'url'        => $image_url,
+						'width'      => $image['width'],
+						'height'     => $image['height'],
+					);
+				}
+			}
+
+			$media['has']['galleries'] = count( $galleries );
+		}
+
+		// Featured images (aka thumbnails).
+		if ( ! empty( $featured_image ) ) {
+			$image_url = esc_url_raw( $featured_image[0] );
+
+			if ( $image_url ) {
+				$media['images'][] = array(
+					'source' => 'featured_images',
+					'url'    => $image_url,
+					'width'  => $featured_image[1],
+					'height' => $featured_image[2],
+				);
+
+				$media['has']['featured_images'] = 1;
+			}
+		}
+
+		// Update image count.
+		$media['has']['images'] = count( $media['images'] );
+
+
+		/**
+		 * Filters images extracted from text.
+		 *
+		 * @param array $media Extracted images. See {@link BP_Media_Extractor::extract_images()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_images', $media, $richtext, $plaintext, $extra_args );
+	}
+
+	/**
+	 * Extract shortcodes from text.
+	 *
+	 * This includes any shortcodes indirectly used by other media extraction types.
+	 * For example, [gallery] and [audio].
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Bespoke data for a particular extractor (optional).
+	 * @return array {
+	 *     @type array $has Extracted media counts. {
+	 *         @type int $shortcodes
+	 *     }
+	 *     @type array $shortcodes Extracted shortcodes. {
+	 *         Array of extracted media.
+	 *
+	 *         @type array $attributes Key/value pairs of the shortcodes attributes (if any).
+	 *         @type string $content Text wrapped by the shortcode.
+	 *         @type string $type Shortcode type.
+	 *         @type string $original The entire shortcode.
+	 *     }
+	 * }
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_shortcodes( $richtext, $plaintext, $extra_args = array() ) {
+		$data = array( 'has' => array( 'shortcodes' => 0 ), 'shortcodes' => array() );
+
+		// Match any registered WordPress shortcodes.
+		if ( strpos( $richtext, '[' ) !== false ) {
+	 		preg_match_all( '/' . get_shortcode_regex() . '/s', $richtext, $matches );
+
+			if ( ! empty( $matches[2] ) ) {
+				foreach ( $matches[2] as $i => $shortcode_name ) {
+					$attrs = shortcode_parse_atts( $matches[3][ $i ] );
+					$attrs = ( ! $attrs ) ? array() : $attrs;
+
+					$shortcode               = array();
+					$shortcode['attributes'] = $attrs;             // Attributes
+					$shortcode['content']    = $matches[5][ $i ];  // Content
+					$shortcode['type']       = $shortcode_name;    // Shortcode
+					$shortcode['original']   = $matches[0][ $i ];  // Entire shortcode
+
+					$data['shortcodes'][] = $shortcode;
+				}
+			}
+		}
+
+		$data['has']['shortcodes'] = count( $data['shortcodes'] );
+
+		/**
+		 * Filters shortcodes extracted from text.
+		 *
+		 * @param array $data Extracted shortcodes. See {@link BP_Media_Extractor::extract_shortcodes()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_shortcodes', $data, $richtext, $plaintext, $extra_args );
+	}
+
+	/**
+	 * Extract any URL, matching a registered oEmbed endpoint, from text.
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Bespoke data for a particular extractor (optional).
+	 * @return array {
+	 *     @type array $has Extracted media counts. {
+	 *         @type int $embeds
+	 *     }
+	 *     @type array $embeds Extracted oEmbeds. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $url oEmbed link.
+	 *     }
+	 * }
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_embeds( $richtext, $plaintext, $extra_args = array() ) {
+		$data   = array( 'has' => array( 'embeds' => 0 ), 'embeds' => array() );
+		$embeds = array();
+
+		if ( ! function_exists( '_wp_oembed_get_object' ) ) {
+			require( ABSPATH . WPINC . '/class-oembed.php' );
+		}
+
+
+		// Matches any links on their own lines. They may be oEmbeds.
+		if ( stripos( $richtext, 'http' ) !== false ) {
+			preg_match_all( '#^\s*(https?://[^\s"]+)\s*$#im', $richtext, $matches );
+
+			if ( ! empty( $matches[1] ) ) {
+				$matches[1] = array_unique( $matches[1] );
+				$oembed     = _wp_oembed_get_object();
+
+				foreach ( $matches[1] as $link ) {
+					// Skip data URIs.
+					if ( strtolower( substr( $link, 0, 5 ) ) === 'data:' ) {
+						continue;
+					}
+
+					foreach ( $oembed->providers as $matchmask => $oembed_data ) {
+						list( , $is_regex ) = $oembed_data;
+
+						// Turn asterisk-type provider URLs into regexs.
+						if ( ! $is_regex ) {
+							$matchmask = '#' . str_replace( '___wildcard___', '(.+)', preg_quote( str_replace( '*', '___wildcard___', $matchmask ), '#' ) ) . '#i';
+							$matchmask = preg_replace( '|^#http\\\://|', '#https?\://', $matchmask );
+						}
+
+						// Check whether this "link" is really an oEmbed.
+						if ( preg_match( $matchmask, $link ) ) {
+							$data['embeds'][] = array( 'url' => $link );
+
+							break;
+						}
+					}
+				}
+			}
+		}
+
+		$data['has']['embeds'] = count( $data['embeds'] );
+
+		/**
+		 * Filters embeds extracted from text.
+		 *
+		 * @param array $data Extracted embeds. See {@link BP_Media_Extractor::extract_embeds()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_embeds', $data, $richtext, $plaintext, $extra_args );
+	}
+
+	/**
+	 * Extract [audio] shortcodes and `<a href="*.mp3">` tags, from text.
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Bespoke data for a particular extractor (optional).
+	 * @return array {
+	 *     @type array $has Extracted media counts. {
+	 *         @type int $audio
+	 *     }
+	 *     @type array $audio Extracted audio. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $original The entire shortcode.
+	 *         @type string $source Media source. Either "html" or "shortcodes".
+	 *         @type string $url Link to audio.
+	 *     }
+	 * }
+	 * @see wp_get_audio_extensions() for supported audio formats.
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_audio( $richtext, $plaintext, $extra_args = array() ) {
+		$data   = array( 'has' => array( 'audio' => 0 ), 'audio' => array() );
+		$audios = $this->extract_shortcodes( $richtext, $plaintext, $extra_args );
+		$links  = $this->extract_links( $richtext, $plaintext, $extra_args );
+
+		$audio_types = wp_get_audio_extensions();
+
+
+		// [audio]
+		$audios = wp_list_filter( $audios['shortcodes'], array( 'type' => 'audio' ) );
+		foreach ( $audios as $audio ) {
+
+			// Media URL can appear as the first parameter inside the shortcode brackets.
+			if ( isset( $audio['attributes']['src'] ) ) {
+				$src_param = 'src';
+			} elseif ( isset( $audio['attributes'][0] ) ) {
+				$src_param = 0;
+			} else {
+				continue;
+			}
+
+			$path = untrailingslashit( parse_url( $audio['attributes'][ $src_param ], PHP_URL_PATH ) );
+
+			foreach ( $audio_types as $extension ) {
+				$extension = '.' . $extension;
+
+				// Check this URL's file extension matches that of an accepted audio format.
+				if ( ! $path || substr( $path, -4 ) !== $extension ) {
+					continue;
+				}
+
+				$data['audio'][] = array(
+					'original' => '[audio src="' . esc_url_raw( $audio['attributes'][ $src_param ] ) . '"]',
+					'source'   => 'shortcodes',
+					'url'      => esc_url_raw( $audio['attributes'][ $src_param ] ),
+				);
+			}
+		}
+
+		// <a href="*.mp3"> tags
+		foreach ( $audio_types as $extension ) {
+			$extension = '.' . $extension;
+
+			foreach ( $links['links'] as $link ) {
+				$path = untrailingslashit( parse_url( $link['url'], PHP_URL_PATH ) );
+
+				// Check this URL's file extension matches that of an accepted audio format.
+				if ( ! $path || substr( $path, -4 ) !== $extension ) {
+					continue;
+				}
+
+				$data['audio'][] = array(
+					'original' => '[audio src="' . esc_url_raw( $link['url'] ) . '"]',  // Build an audio shortcode.
+					'source'   => 'html',
+					'url'      => esc_url_raw( $link['url'] ),
+				);
+			}
+		}
+
+		$data['has']['audio'] = count( $data['audio'] );
+
+		/**
+		 * Filters audio extracted from text.
+		 *
+		 * @param array $data Extracted audio. See {@link BP_Media_Extractor::extract_audio()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_audio', $data, $richtext, $plaintext, $extra_args );
+	}
+
+	/**
+	 * Extract [video] shortcodes from text.
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Bespoke data for a particular extractor (optional).
+	 * @return array {
+	 *     @type array $has Extracted media counts. {
+	 *         @type int $video
+	 *     }
+	 *     @type array $videos Extracted video. {
+	 *         Array of extracted media.
+	 *
+	 *         @type string $source Media source. Currently only "shortcodes".
+	 *         @type string $url Link to audio.
+	 *     }
+	 * }
+	 * @see wp_get_video_extensions() for supported video formats.
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_video( $richtext, $plaintext, $extra_args = array() ) {
+		$data   = array( 'has' => array( 'videos' => 0 ), 'videos' => array() );
+		$videos = $this->extract_shortcodes( $richtext, $plaintext, $extra_args );
+
+		$video_types = wp_get_video_extensions();
+
+
+		// [video]
+		$videos = wp_list_filter( $videos['shortcodes'], array( 'type' => 'video' ) );
+		foreach ( $videos as $video ) {
+
+			// Media URL can appear as the first parameter inside the shortcode brackets.
+			if ( isset( $video['attributes']['src'] ) ) {
+				$src_param = 'src';
+			} elseif ( isset( $video['attributes'][0] ) ) {
+				$src_param = 0;
+			} else {
+				continue;
+			}
+
+			$path = untrailingslashit( parse_url( $video['attributes'][ $src_param ], PHP_URL_PATH ) );
+
+			foreach ( $video_types as $extension ) {
+				$extension = '.' . $extension;
+
+				// Check this URL's file extension matches that of an accepted video format (-5 for webm).
+				if ( ! $path || ( substr( $path, -4 ) !== $extension && substr( $path, -5 ) !== $extension ) ) {
+					continue;
+				}
+
+				$data['videos'][] = array(
+					'original' => $video['original'],  // Entire shortcode.
+					'source'   => 'shortcodes',
+					'url'      => esc_url_raw( $video['attributes'][ $src_param ] ),
+				);
+			}
+		}
+
+		$data['has']['videos'] = count( $data['videos'] );
+
+		/**
+		 * Filters videos extracted from text.
+		 *
+		 * @param array $data Extracted videos. See {@link BP_Media_Extractor::extract_videos()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_videos', $data, $richtext, $plaintext, $extra_args );
+	}
+
+
+	/**
+	 * Helpers and utility methods.
+	 */
+
+	/**
+	 * Extract images in [galleries] shortcodes from text.
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Bespoke data for a particular extractor (optional).
+	 * @return array
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_images_from_galleries( $richtext, $plaintext, $extra_args = array() ) {
+		if ( ! isset( $extra_args['post'] ) || ! is_a( $extra_args['post'], 'WP_Post' ) ) {
+			$post = new WP_Post( (object) array( 'post_content' => $richtext ) );
+		} else {
+			$post = $extra_args['post'];
+		}
+
+		// We're not using get_post_galleries_images() because it returns thumbnails; we want the original image.
+		$galleries      = get_post_galleries( $post, false );
+		$galleries_data = array();
+	
+		if ( ! empty( $galleries ) ) {
+			// Validate the size of the images requested.
+			if ( isset( $extra_args['width'] ) ) {
+
+				// A width was specified but not a height, so calculate it assuming a 4:3 ratio.
+				if ( ! isset( $extra_args['height'] ) && ctype_digit( $extra_args['width'] ) ) {
+					$extra_args['height'] = round( ( $extra_args['width'] / 4 ) * 3 );
+				}
+
+				if ( ctype_digit( $extra_args['width'] ) ) {
+					$image_size = array( $extra_args['width'], $extra_args['height'] );
+				} else {
+					$image_size = $extra_args['width'];  // e.g. "thumb", "medium".
+				}
+
+			} else {
+				$image_size = 'full';
+			}
+
+			/**
+			 * There are two variants of gallery shortcode.
+			 *
+			 * One kind specifies the image (post) IDs via an `ids` parameter.
+			 * The other gets the image IDs from post_type=attachment and post_parent=get_the_ID().
+			 */
+
+			foreach ( $galleries as $gallery_id => $gallery ) {
+				$data   = array();
+				$images = array();
+
+				// Gallery ids= variant.
+				if ( isset( $gallery['ids'] ) ) {
+					$images = wp_parse_id_list( $gallery['ids'] );
+
+				// Gallery post_parent variant.
+				} elseif ( isset( $extra_args['post'] ) ) {
+					$images = wp_parse_id_list(
+						get_children( array(
+							'fields'         => 'ids',
+							'order'          => 'ASC',
+							'orderby'        => 'menu_order ID',
+							'post_mime_type' => 'image',
+							'post_parent'    => $extra_args['post']->ID,
+							'post_status'    => 'inherit',
+							'post_type'      => 'attachment',
+						) )
+					);
+				}
+
+				// Extract the data we need from each image in this gallery.
+				foreach ( $images as $image_id ) {
+					$image  = wp_get_attachment_image_src( $image_id, $image_size );
+					$data[] = array(
+						'url'    => $image[0],
+						'width'  => $image[1],
+						'height' => $image[2],
+
+						'gallery_id' => 1 + $gallery_id,
+					);
+				}
+
+				$galleries_data[] = $data;
+			}
+		}
+
+		/**
+		 * Filters image galleries extracted from text.
+		 *
+		 * @param array $galleries_data Galleries. See {@link BP_Media_Extractor::extract_images_from_galleries()}.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_galleries', $galleries_data, $richtext, $plaintext, $extra_args );
+	}
+
+	/**
+	 * Extract the featured image from a Post.
+	 *
+	 * @param string $richtext Content to parse.
+	 * @param string $plaintext Sanitized version of the content.
+	 * @param array $extra_args Contains data that an implementation might need beyond the defaults.
+	 * @return array
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function extract_images_from_featured_images( $richtext, $plaintext, $extra_args ) {
+		$image = array();
+		$thumb = 0;
+
+		if ( isset( $extra_args['post'] ) ) {
+			$thumb = (int) get_post_thumbnail_id( $extra_args['post']->ID );
+		}
+
+		if ( $thumb ) {
+			// Validate the size of the images requested.
+			if ( isset( $extra_args['width'] ) ) {
+				if ( ! isset( $extra_args['height'] ) && ctype_digit( $extra_args['width'] ) ) {
+					// A width was specified but not a height, so calculate it assuming a 4:3 ratio.
+					$extra_args['height'] = round( ( $extra_args['width'] / 4 ) * 3 );
+				}
+
+				if ( ctype_digit( $extra_args['width'] ) ) {
+					$image_size = array( $extra_args['width'], $extra_args['height'] );
+				} else {
+					$image_size = $extra_args['width'];  // e.g. "thumb", "medium".
+				}
+			} else {
+				$image_size = 'full';
+			}
+
+			$image = wp_get_attachment_image_src( $thumb, $image_size );
+		}
+
+		/**
+		 * Filters featured images extracted from a WordPress Post.
+		 *
+		 * @param array $image Extracted images. See {@link BP_Media_Extractor_Post::extract_images()} for format.
+		 * @param string $richtext Content to parse.
+		 * @param string $plaintext Copy of $richtext without any markup.
+		 * @param array $extra_args Bespoke data for a particular extractor.
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_featured_images', $image, $richtext, $plaintext, $extra_args );
+	}
+
+	/**
+	 * Sanitize and format raw content to prepare for content extraction.
+	 *
+	 * HTML tags and shortcodes are removed, and HTML entities are decoded.
+	 *
+	 * @param string $richtext
+	 * @return string
+	 * @since BuddyPress (2.3.0)
+	 */
+	protected function strip_markup( $richtext ) {
+		$plaintext = strip_shortcodes( html_entity_decode( strip_tags( $richtext ) ) );
+
+		/**
+		 * Filters the generated plain text version of the content passed to the extractor.
+		 *
+		 * @param array $plaintext Generated plain text.
+		 * @param string $richtext Original content
+		 * @since BuddyPress (2.3.0)
+		 */
+		return apply_filters( 'bp_media_extractor_strip_markup', $plaintext, $richtext );
+	}
+}
\ No newline at end of file
diff --git a/tests/phpunit/testcases/core/class-bp-media-extractor.php b/tests/phpunit/testcases/core/class-bp-media-extractor.php
new file mode 100644
index 0000000..f791abf
--- /dev/null
+++ b/tests/phpunit/testcases/core/class-bp-media-extractor.php
@@ -0,0 +1,506 @@
+<?php
+/**
+ * @group core
+ * @group BP_Media_Extractor
+ */
+class BP_Tests_Media_Extractor extends BP_UnitTestCase {
+	public static $media_extractor = null;
+	public static $richtext        = '';
+
+
+	public static function setUpBeforeClass() {
+		parent::setUpBeforeClass();
+
+		self::$media_extractor = new BP_Media_Extractor();
+		self::$richtext        = "Hello world.
+
+		This sample text is used to test the media extractor parsing class. @paulgibbs thinks it's pretty cool.
+		Another thing really cool is this @youtube:
+
+		https://www.youtube.com/watch?v=2mjvfnUAfyo
+
+		This video is literally out of the world, but uses a different protocol to the embed above:
+
+		http://www.youtube.com/watch?v=KaOC9danxNo
+
+		<a href='https://example.com'>Testing a regular link.</a>
+		<strong>But we should throw in some markup and maybe even an <img src='http://example.com/image.gif'>.
+		<a href='http://example.com'><img src='http://example.com/image-in-a-link.gif' /></a></strong>.
+		It definitely does not like <img src='data:1234567890A'>data URIs</img>. @
+
+		The parser only extracts wp_allowed_protocols() protocols, not something like <a href='phone:004400'>phone</a>.
+
+		[caption id='example']Here is a caption shortcode.[/caption]
+
+		There are two types of [gallery] shortcodes; one like that, and another with IDs specified.
+
+		Audio shortcodes:
+		[audio src='http://example.com/source.mp3'] 
+		[audio src='http://example.com/source.wav' loop='on' autoplay='off' preload='metadata'].
+
+		The following shortcode should be picked up by the shortcode extractor, but not the audio extractor, because
+		it has an unrecognised file extension (for an audio file). [audio src='http://example.com/not_audio.gif']
+		<a href='http://example.com/more_audio.mp3'>This should be picked up, too</a>.
+
+		Video shortcodes:
+		[video src='http://example.com/source.ogv']
+		[video src='http://example.com/source.webm' loop='on' autoplay='off' preload='metadata']
+
+		The following shortcode should be picked up by the shortcode extractor, but not the video extractor, because
+		it has an unrecognised file extension (for a video file). [video src='http://example.com/not_video.mp3']
+		";
+	}
+
+	public function setUp() {
+		parent::setUp();
+
+		$this->factory->user->create( array( 'user_login' => 'paulgibbs' ) );
+	}
+
+	public function tearDown() {
+		parent::tearDown();
+
+		$this->remove_added_uploads();
+	}
+
+
+	/**
+	 * General.
+	 */
+
+	public function test_check_media_extraction_return_types() {
+		$media = self::$media_extractor->extract( self::$richtext );
+
+		foreach ( array( 'has', 'embeds', 'images', 'links', 'mentions', 'shortcodes', 'audio' ) as $key ) {
+			$this->assertArrayHasKey( $key, $media );
+			$this->assertInternalType( 'array', $media[ $key ] );
+		}
+
+		foreach ( $media['has'] as $item ) {
+			$this->assertInternalType( 'int', $item );
+		}
+
+		foreach ( $media['links'] as $item ) {
+			$this->assertArrayHasKey( 'url', $item );
+			$this->assertInternalType( 'string', $item['url'] );
+			$this->assertNotEmpty( $item['url'] );
+		}
+
+		foreach ( $media['mentions'] as $item ) {
+			$this->assertArrayHasKey( 'name', $item );
+			$this->assertInternalType( 'string', $item['name'] );
+			$this->assertNotEmpty( $item['name'] );
+		}
+
+		foreach ( $media['images'] as $item ) {
+			$this->assertArrayHasKey( 'height', $item );
+			$this->assertInternalType( 'int', $item['height'] );
+
+			$this->assertArrayHasKey( 'width', $item );
+			$this->assertInternalType( 'int', $item['width'] );
+
+			$this->assertArrayHasKey( 'source', $item );
+			$this->assertInternalType( 'string', $item['source'] );
+			$this->assertNotEmpty( $item['source'] );
+
+			$this->assertArrayHasKey( 'url', $item );
+			$this->assertInternalType( 'string', $item['url'] );
+			$this->assertNotEmpty( $item['url'] );
+		}
+
+		foreach ( $media['shortcodes'] as $shortcode_type => $item ) {
+			$this->assertArrayHasKey( 'attributes', $item );
+			$this->assertInternalType( 'array', $item['attributes'] );
+
+			$this->assertArrayHasKey( 'content', $item );
+			$this->assertInternalType( 'string', $item['content'] );
+
+			$this->assertArrayHasKey( 'type', $item );
+			$this->assertInternalType( 'string', $item['type'] );
+
+			$this->assertArrayHasKey( 'original', $item );
+			$this->assertInternalType( 'string', $item['original'] );
+		}
+
+		foreach ( $media['embeds'] as $item ) {
+			$this->assertArrayHasKey( 'url', $item );
+			$this->assertInternalType( 'string', $item['url'] );
+			$this->assertNotEmpty( $item['url'] );
+		}
+
+		foreach ( $media['audio'] as $item ) {
+			$this->assertArrayHasKey( 'url', $item );
+			$this->assertInternalType( 'string', $item['url'] );
+			$this->assertNotEmpty( $item['url'] );
+
+			$this->assertArrayHasKey( 'source', $item );
+			$this->assertInternalType( 'string', $item['source'] );
+			$this->assertNotEmpty( $item['source'] );
+		}
+	}
+
+	public function test_check_media_extraction_counts_are_correct() {
+		$media = self::$media_extractor->extract( self::$richtext );
+		$types = array_keys( $media );
+
+		foreach ( $types as $type ) {
+			if ( $type === 'has' ) {
+				continue;
+			}
+
+			$this->assertArrayHasKey( $type, $media['has'] );
+			$this->assertSame( count( $media[ $type ] ), $media['has'][ $type ], "Difference with the 'has' count for {$type}." );
+		}
+	}
+
+
+	public function test_extract_multiple_media_types_from_content() {
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::LINKS | BP_Media_Extractor::MENTIONS );
+
+		$this->assertNotEmpty( $media['links'] );
+		$this->assertNotEmpty( $media['mentions'] );
+		$this->assertArrayNotHasKey( 'shortcodes', $media );
+	}
+
+	public function test_extract_media_from_a_wp_post() {
+		$post_id = $this->factory->post->create( array( 'post_content' => self::$richtext ) );
+		$media   = self::$media_extractor->extract( get_post( $post_id ), BP_Media_Extractor::LINKS );
+
+		$this->assertArrayHasKey( 'links', $media );
+		$this->assertSame( 'https://example.com', $media['links'][0]['url'] );
+		$this->assertSame( 'http://example.com',  $media['links'][1]['url'] );
+	}
+
+
+	/**
+	 * Link extraction.
+	 */
+
+	public function test_extract_links_from_content() {
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::LINKS );
+
+		$this->assertArrayHasKey( 'links', $media );
+		$this->assertSame( 'https://example.com', $media['links'][0]['url'] );
+		$this->assertSame( 'http://example.com',  $media['links'][1]['url'] );
+	}
+
+	public function test_extract_no_links_from_content_with_invalid_links() {
+		$richtext = "This is some sample text, with links, but not the kinds we want.		
+		<a href=''>Empty links should be ignore<a/> and
+		<a href='phone:004400'>weird protocols should be ignored, too</a>.
+		";
+
+		$media = self::$media_extractor->extract( $richtext, BP_Media_Extractor::LINKS );
+		$this->assertSame( 0, $media['has']['links'] );
+	}
+
+
+	/**
+	 * at-mentions extraction.
+	 */
+
+	public function test_extract_mentions_from_content_with_activity_enabled() {
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::MENTIONS );
+
+		$this->assertArrayHasKey( 'user_id', $media['mentions'][0] );
+		$this->assertSame( 'paulgibbs', $media['mentions'][0]['name'] );
+	}
+
+	public function test_extract_mentions_from_content_with_activity_disabled() {
+		$was_activity_enabled = false;
+
+		// Turn activity off.
+		if ( isset( buddypress()->active_components['activity'] ) ) {
+			unset( buddypress()->active_components['activity'] );
+			$was_activity_enabled = true;
+		}
+
+
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::MENTIONS );
+
+		$this->assertArrayNotHasKey( 'user_id', $media['mentions'][0] );
+		$this->assertSame( 'paulgibbs', $media['mentions'][0]['name'] );
+
+
+		// Turn activity on.
+		if ( $was_activity_enabled ) {
+			buddypress()->active_components['activity'] = 1;
+		}
+	}
+
+
+	/**
+	 * Shortcodes extraction.
+	 */
+
+	public function test_extract_shortcodes_from_content() {
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::SHORTCODES );
+
+		$this->assertArrayHasKey( 'shortcodes', $media );
+
+		$this->assertSame( 'caption', $media['shortcodes'][0]['type'] );
+		$this->assertSame( 'Here is a caption shortcode.', $media['shortcodes'][0]['content'] );
+		$this->assertSame( 'example', $media['shortcodes'][0]['attributes']['id'] );
+
+		$this->assertSame( 'gallery', $media['shortcodes'][1]['type'] );
+		$this->assertEmpty( $media['shortcodes'][1]['content'] );
+
+		$this->assertSame( 'audio', $media['shortcodes'][2]['type'] );
+		$this->assertEmpty( $media['shortcodes'][2]['content'] );
+		$this->assertSame( 'http://example.com/source.mp3', $media['shortcodes'][2]['attributes']['src'] );
+
+		$this->assertSame( 'audio', $media['shortcodes'][3]['type'] );
+		$this->assertEmpty( $media['shortcodes'][3]['content'] );
+		$this->assertSame( 'http://example.com/source.wav', $media['shortcodes'][3]['attributes']['src'] );
+		$this->assertSame( 'on', $media['shortcodes'][3]['attributes']['loop'] );
+		$this->assertSame( 'off', $media['shortcodes'][3]['attributes']['autoplay'] );
+		$this->assertSame( 'metadata', $media['shortcodes'][3]['attributes']['preload'] );
+	}
+
+	public function test_extract_no_shortcodes_from_content_with_unregistered_shortcodes() {
+		$richtext = 'This sample text has some made-up [fake]shortcodes[/fake].';
+
+		$media = self::$media_extractor->extract( $richtext, BP_Media_Extractor::SHORTCODES );
+		$this->assertSame( 0, $media['has']['shortcodes'] );
+	}
+
+
+	/**
+	 * oEmbeds extraction.
+	 */
+
+	public function test_extract_oembeds_from_content() {
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::EMBEDS );
+
+		$this->assertArrayHasKey( 'embeds', $media );
+		$this->assertSame( 'https://www.youtube.com/watch?v=2mjvfnUAfyo', $media['embeds'][0]['url'] );
+		$this->assertSame( 'http://www.youtube.com/watch?v=KaOC9danxNo',  $media['embeds'][1]['url'] );
+	}
+
+
+	/**
+	 * Images extraction (src tags).
+	 */
+
+	// both quote styles
+	public function test_extract_images_from_content_with_src_tags() {
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::IMAGES );
+
+		$this->assertArrayHasKey( 'images', $media );
+		$media = array_values( wp_list_filter( $media['images'], array( 'source' => 'html' ) ) );
+	
+		$this->assertSame( 'http://example.com/image.gif',           $media[0]['url'] );
+		$this->assertSame( 'http://example.com/image-in-a-link.gif', $media[1]['url'] );
+	}
+
+	// empty src attributes, data: URIs
+	public function test_extract_no_images_from_content_with_invalid_src_tags() {
+		$richtext = 'This sample text will contain images with invalid src tags, like this:
+		<img src="data://abcd"> or <img src="phone://0123" />.
+		';
+
+		$media = self::$media_extractor->extract( $richtext, BP_Media_Extractor::IMAGES );
+
+		$this->assertArrayHasKey( 'images', $media );
+		$this->assertSame( 0, $media['has']['images'] );
+	}
+
+
+	/**
+	 * Images extraction (galleries).
+	 */
+
+	public function test_extract_images_from_content_with_galleries_variant_no_ids() {
+		// To test the [gallery] shortcode, we need to create a post and an attachment.
+		$post_id       = $this->factory->post->create( array( 'post_content' => self::$richtext ) );
+		$attachment_id = $this->factory->attachment->create_object( 'image.jpg', $post_id, array(
+			'post_mime_type' => 'image/jpeg',
+			'post_type'      => 'attachment'
+		) );
+		wp_update_attachment_metadata( $attachment_id, array( 'width' => 100, 'height' => 100 ) );
+
+
+		// Extract the gallery images.
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::IMAGES, array(
+			'post' => get_post( $post_id ),
+		) );
+
+		$this->assertArrayHasKey( 'images', $media );
+		$media = array_values( wp_list_filter( $media['images'], array( 'source' => 'galleries' ) ) );
+		$this->assertCount( 1, $media );
+
+		$this->assertSame( 'http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/image.jpg', $media[0]['url'] );
+	}
+
+	public function test_extract_images_from_content_with_galleries_variant_ids() {
+		// To test the [gallery] shortcode, we need to create a post and attachments.
+		$attachment_ids = array();
+		foreach ( range( 1, 3 ) as $i ) {
+			$attachment_id = $this->factory->attachment->create_object( "image{$i}.jpg", 0, array(
+				'post_mime_type' => 'image/jpeg',
+				'post_type'      => 'attachment'
+			) );
+
+			wp_update_attachment_metadata( $attachment_id, array( 'width' => 100, 'height' => 100 ) );
+			$attachment_ids[] = $attachment_id;
+		}
+
+		$attachment_ids = join( ',', $attachment_ids );
+		$post_id        = $this->factory->post->create( array( 'post_content' => "[gallery ids='{$attachment_ids}']" ) );
+
+
+		// Extract the gallery images.
+		$media = self::$media_extractor->extract( '', BP_Media_Extractor::IMAGES, array(
+			'post' => get_post( $post_id ),
+		) );
+
+		$this->assertArrayHasKey( 'images', $media );
+		$media = array_values( wp_list_filter( $media['images'], array( 'source' => 'galleries' ) ) );
+		$this->assertCount( 3, $media );
+
+		for ( $i = 1; $i <= 3; $i++ ) {
+			$this->assertSame( 'http://' . WP_TESTS_DOMAIN . "/wp-content/uploads/image{$i}.jpg", $media[ $i - 1 ]['url'] );
+		}
+	}
+
+	public function test_extract_no_images_from_content_with_invalid_galleries_variant_no_ids() {
+		$post_id = $this->factory->post->create( array( 'post_content' => self::$richtext ) );
+		$media   = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::IMAGES, array(
+			'post' => get_post( $post_id ),
+		) );
+
+		$this->assertArrayHasKey( 'images', $media );
+		$media = array_values( wp_list_filter( $media['images'], array( 'source' => 'galleries' ) ) );
+		$this->assertCount( 0, $media );
+	}
+
+	public function test_extract_no_images_from_content_with_invalid_galleries_variant_ids() {
+		$post_id = $this->factory->post->create( array( 'post_content' => '[gallery ids="117,4529"]' ) );
+		$media   = self::$media_extractor->extract( '', BP_Media_Extractor::IMAGES, array(
+			'post' => get_post( $post_id ),
+		) );
+
+		$this->assertArrayHasKey( 'images', $media );
+		$media = array_values( wp_list_filter( $media['images'], array( 'source' => 'galleries' ) ) );
+		$this->assertCount( 0, $media );
+	}
+
+
+	/**
+	 * Images extraction (thumbnail).
+	 */
+
+	public function test_extract_no_images_from_content_with_featured_image() {
+		$post_id      = $this->factory->post->create( array( 'post_content' => self::$richtext ) );
+		$thumbnail_id = $this->factory->attachment->create_object( 'image.jpg', $post_id, array(
+			'post_mime_type' => 'image/jpeg',
+			'post_type'      => 'attachment'
+		) );
+		set_post_thumbnail( $post_id, $thumbnail_id );
+
+
+		// Extract the gallery images.
+		$media = self::$media_extractor->extract( '', BP_Media_Extractor::IMAGES, array(
+			'post' => get_post( $post_id ),
+		) );
+
+		$this->assertArrayHasKey( 'images', $media );
+		$media = array_values( wp_list_filter( $media['images'], array( 'source' => 'featured_images' ) ) );
+		$this->assertCount( 1, $media );
+
+		$this->assertSame( 'http://' . WP_TESTS_DOMAIN . '/wp-content/uploads/image.jpg', $media[0]['url'] );
+	}
+
+	public function test_extract_images_from_content_without_featured_image() {
+		$post_id = $this->factory->post->create( array( 'post_content' => self::$richtext ) );
+		$media   = self::$media_extractor->extract( '', BP_Media_Extractor::IMAGES, array(
+			'post' => get_post( $post_id ),
+		) );
+
+		$this->assertArrayHasKey( 'images', $media );
+		$media = array_values( wp_list_filter( $media['images'], array( 'source' => 'featured_images' ) ) );
+		$this->assertCount( 0, $media );
+	}
+
+
+	/**
+	 * Audio extraction.
+	 */
+
+	public function test_extract_audio_from_content() {
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::AUDIO );
+
+		$this->assertArrayHasKey( 'audio', $media );
+		$this->assertCount( 3, $media['audio'] );
+
+		$this->assertSame( 'shortcodes', $media['audio'][0]['source'] );
+		$this->assertSame( 'shortcodes', $media['audio'][1]['source'] );
+		$this->assertSame( 'html',       $media['audio'][2]['source'] );
+
+		$this->assertSame( 'http://example.com/source.mp3',     $media['audio'][0]['url'] );
+		$this->assertSame( 'http://example.com/source.wav',     $media['audio'][1]['url'] );
+		$this->assertSame( 'http://example.com/more_audio.mp3', $media['audio'][2]['url'] );
+	}
+
+	public function test_extract_audio_shortcode_with_no_src_param() {
+		$richtext = '[audio http://example.com/a-song.mp3]';
+		$media = self::$media_extractor->extract( $richtext, BP_Media_Extractor::AUDIO );
+
+		$this->assertArrayHasKey( 'audio', $media );
+		$this->assertCount( 1, $media['audio'] );
+		$this->assertSame( 'http://example.com/a-song.mp3', $media['audio'][0]['url'] );
+	}
+
+	public function test_extract_no_audio_from_invalid_content() {
+		$richtext = '[audio src="http://example.com/not_audio.gif"]
+		<a href="http://example.com/more_not_audio.mp33">Hello</a>.';
+
+		$media = self::$media_extractor->extract( $richtext, BP_Media_Extractor::AUDIO );
+		$this->assertSame( 0, $media['has']['audio'] );
+	}
+
+	public function test_extract_no_audio_from_empty_audio_shortcode() {
+		$media = self::$media_extractor->extract( '[audio]', BP_Media_Extractor::AUDIO );
+		$this->assertSame( 0, $media['has']['audio'] );
+	}
+
+
+	/**
+	 * Video extraction.
+	 */
+
+	public function test_extract_video_from_content() {
+		$media = self::$media_extractor->extract( self::$richtext, BP_Media_Extractor::VIDEOS );
+
+		$this->assertArrayHasKey( 'videos', $media );
+		$this->assertCount( 2, $media['videos'] );
+
+		$this->assertSame( 'shortcodes', $media['videos'][0]['source'] );
+		$this->assertSame( 'shortcodes', $media['videos'][1]['source'] );
+
+		$this->assertSame( 'http://example.com/source.ogv',  $media['videos'][0]['url'] );
+		$this->assertSame( 'http://example.com/source.webm', $media['videos'][1]['url'] );
+	}
+
+
+	public function test_extract_video_shortcode_with_no_src_param() {
+		$richtext = '[video http://example.com/source.ogv]';
+		$media = self::$media_extractor->extract( $richtext, BP_Media_Extractor::VIDEOS );
+
+		$this->assertArrayHasKey( 'videos', $media );
+		$this->assertCount( 1, $media['videos'] );
+		$this->assertSame( 'http://example.com/source.ogv', $media['videos'][0]['url'] );
+	}
+
+	public function test_extract_no_video_from_invalid_content() {
+		$richtext = '[video src="http://example.com/not_video.mp3"]';
+		$media    = self::$media_extractor->extract( $richtext, BP_Media_Extractor::VIDEOS );
+
+		$this->assertSame( 0, $media['has']['videos'] );
+	}
+
+	public function test_extract_no_videos_from_empty_video_shortcodes() {
+		$media = self::$media_extractor->extract( '[video]', BP_Media_Extractor::VIDEOS );
+		$this->assertSame( 0, $media['has']['videos'] );
+	}
+}
