Integration Bridges (Layer 1)
Free & ProThe bridge layer is how BuddyNext connects to optional companion plugins (Jetonomy forums, WPMediaVerse media + DM, wb-gamification, Career Board) and the host theme. Each bridge is an adapter class under includes/Bridges/ that translates a companion's hooks and data into BuddyNext surfaces - and stays completely inert when the companion is not installed. This page is for developers writing a new bridge, theming a bridged surface, or extending one of the existing integrations.


Overview / Contract
A bridge is a thin, one-directional adapter. The rules every BuddyNext bridge follows:
- One class per companion,
Bridgesuffix. Adapter classes live inincludes/Bridges/and are named{Companion}Bridge(for exampleJetonomyBridge). A companion that also needs to mirror inbound notifications ships a paired{Companion}BridgeListenerimplementingBuddyNext\Contracts\ListenerInterface. - Self-guarding at hook time, not load time. Every bridge's entry method (
init()for adapters,register()for listeners) bails immediately with aclass_exists()/function_exists()check against the companion. Nothing is registered on a site that does not run the companion, so no hooks are wasted and no fatals occur on activation-order differences. - Loaded on a single seam after everyone else has booted. Bridges are wired on
buddynext_load_bridges, which BuddyNext fires atplugins_loaded:25- after BuddyNext itself (priority 15) and after Pro companions like Jetonomy Pro and WPMediaVerse Pro (priority 20). Activation order between BuddyNext and a companion therefore never matters. - Feature-toggle gated. Each integration bridge is additionally gated on its Platform -> Features toggle via
buddynext_feature_enabled( '{feature}' )(all default-on). Turning a bridge off in the admin actually disables it, independent of whether the companion is active. - BuddyNext owns companion table access. When a bridge needs companion data (for example Jetonomy's
jt_posts/jt_replies), the bridge class is the only place that reads those tables - templates and services never reach into a companion's schema directly.
How bridges are wired
// includes/Core/Plugin.php - fired at plugins_loaded:25
do_action( 'buddynext_load_bridges' );
add_action( 'buddynext_load_bridges', function (): void {
// Theme bridge - always wired (it self-guards on the active template).
( new BuddyXBridge() )->init();
if ( buddynext_feature_enabled( 'wpmediaverse' ) ) {
( new WPMediaVerseBridge() )->init();
}
if ( buddynext_feature_enabled( 'gamification' ) ) {
( new GamificationBridge() )->init();
( new \BuddyNext\Profile\GamificationAchievements() )->register();
}
if ( buddynext_feature_enabled( 'jetonomy' ) ) {
( new JetonomyBridge() )->init();
}
} );
A third-party bridge attaches the same way - hook buddynext_load_bridges and wire your own adapter inside a feature/class_exists guard.
Bridge -> companion -> key seams
| Bridge | Companion | Guard (active when) | Feature toggle | Key seams |
|---|---|---|---|---|
JetonomyBridge |
Jetonomy (forums) | class_exists( 'Jetonomy\Jetonomy' ) |
jetonomy |
jetonomy_after_create_post, jetonomy_post_deleted, jetonomy_after_create_reply (consume); buddynext_rail_items, buddynext_register_nav, buddynext_context_nav, buddynext_hashtag_related_discussions (provide); REST POST /spaces/{id}/forum |
WPMediaVerseBridge |
WPMediaVerse (media + DM engine) | class_exists( 'WPMediaVerse\Core\Plugin' ) |
wpmediaverse |
mvs_buddynext_active, mvs_can_send_message, mvs_dm_denial_reason, mvs_user_profile_url, mvs_message_sent, mvs_favorite_toggled, mvs_comment_created, mvs_user_followed/unfollowed (consume); fires buddynext_dm_sent / buddynext_dm_received |
GamificationBridge |
wb-gamification | function_exists( 'wb_gam_submit_event' ) |
gamification |
Consumes wb_gam_badge_awarded to post a credential-badge feed activity. Point awards for BuddyNext activity are owned by the wb-gamification plugin's own integrations/buddynext.php manifest, not this bridge. |
CareerBoardBridge (registered in Pro) |
Career Board (wp-career-board) |
defined( 'WCB_VERSION' ) guard inside the bridge |
career_board |
wcb_job_created, wcb_job_expired, wcbp_resume_published, wcb_notification_created (consume) -> bn_search_index (object_type='job') + bn_notifications; pure inbound listener |
ListoraBridge (registered in Pro) |
WB Listora (business listings) | defined( 'WB_LISTORA_VERSION' ) guard inside the bridge |
listora |
transition_post_status, before_delete_post (consume) -> bn_search_index (object_type='listing') + feed listing cards via IntegrationActivity; no notification mirror yet (Listora has no creation hook to mirror from) |
MemberBlogBridge |
WB Member Blog (front-end publishing) | defined( 'BUDDYPRESS_MEMBER_BLOG_VERSION' ), checked lazily per surface, not at hook time |
blog (shares the feed aspect Free's BlogPostListener already registers) |
Merges the nav aspect onto the shared blog registry entry; adds an "Articles" profile tab + REST via MemberBlogRestController; consumes no companion hook - the feed side is generic site tracking, not this bridge |
BuddyXBridge |
BuddyX theme | 'buddyx' === get_template() |
always wired | buddyx_is_full_width_page (provide) so plugin pages escape the theme's .container wrapper |
PwaService (PWA, not a companion bridge) |
none (first-party) | always wired | n/a | Serves the web-app manifest + service worker; opt-out filter buddynext_pwa_register_sw |
Version floors and the staleness gate
A bridge is written against a specific version of its partner. Two failure modes follow: the partner can be older than a seam the bridge needs (the seam silently no-ops), or newer than the version the bridge was built for (the bridge may not use the partner's newest capabilities and can drift toward broken). Both are surfaced instead of left to rot.
Each bridge declares two version fields in its buddynext_integrations registry entry, both normalized null-safe by IntegrationRegistry::all() (Pro suite bridges declare them via AbstractSuitePanelProvider::integration_min_version() / integration_tested_version()):
min_version- the floor below which the bridge's wired seams no-op. Declared per bridge (there is no central map).tested_version- the partner release the bridge was last built and verified against.
Three surfaces read them:
- Integration Settings (Settings -> Integration Settings) shows one badge per integration: Active, Update needed (installed
< min_version), or Newer partner (installed> tested_version- informational; the partner is ahead of the bridge, which still works and is due a refresh). - CLI gate
wp buddynext bridge-statuswalks the registry and prints installed / floor / tested / state per bridge. It exits non-zero when any bridge is below its floor;--strictalso fails when a partner is ahead oftested_version. Run it in CI so bridges cannot silently fall behind as partners ship. - Per-bridge deep audits (what the partner offers vs what the bridge consumes) are tracked as Basecamp cards, not in code.
Current declared values (update the row when you re-verify a bridge against a new partner release):
| Integration | Partner constant | min_version |
tested_version |
|---|---|---|---|
media |
MVS_VERSION |
2.4.0 | 2.5.0 |
gamification |
WB_GAM_VERSION |
1.6.3 | 1.6.4 |
careerboard |
WCB_VERSION |
1.4.3 | 1.6.0 |
learnomy |
LEARNOMY_VERSION |
1.9.4 | 1.9.5 |
eventonomy |
EVENTONOMY_VERSION |
1.6.0 | 1.6.0 |
jetonomy |
JETONOMY_VERSION |
(none) | 1.9.7 |
listora |
WB_LISTORA_VERSION |
(none) | 1.8.0 |
blog |
BUDDYPRESS_MEMBER_BLOG_VERSION |
(none) | 4.1.0 |
Identity takeover (BuddyNext is master)
Where a partner renders a member's name, @handle, avatar or profile link on a shared surface, BuddyNext owns that identity: one profile link, one name, one mention system across the whole site. A partner exposes filter seams for a host to claim; the bridge fills them so the partner defers to BuddyNext.
- Jetonomy fills all five identity seams (
JetonomyBridge):jetonomy_profile_url->PageRouter::profile_url,jetonomy_user_handle+jetonomy_resolve_mention_handles-> BuddyNextHandle(matched emit/resolve pair, incl. custom slug + reserveduser-{id}),jetonomy_user_display_name-> WPdisplay_name, andjetonomy_profile_action_url-> BuddyNext profile / edit / notification screens (badges/digeststay on Jetonomy). Avatars come through WPpre_get_avatar_datawhere BuddyNext'sAvatarService(priority 50/99) already wins. - WPMediaVerse fills
mvs_user_profile_url-> BuddyNext profile and reportsmvs_has_custom_avatar; its display name defaults to WPdisplay_namealready, and a single-media page redirects to the source BuddyNext activity by default, so identity there is native BuddyNext.
JetonomyBridge
Routes Jetonomy forum events into BuddyNext search, the activity feed, the navigation rail, profile and space tabs, and the notification center. Active only when Jetonomy\Jetonomy exists.
Inbound (what it consumes)
| Hook | Type | Fired when | Bridge handler |
|---|---|---|---|
jetonomy_post_publish_transition |
action ($post_id, $delta, $created_at) |
A discussion enters or leaves publish: create, approve, restore, trash, scheduled publish, purge |
sync_discussion |
jetonomy_post_updated |
action ($post_id, $space_id, $user_id) |
A discussion is edited | sync_discussion |
jetonomy_after_create_post |
action ($post_id, $space_id) |
A discussion is created | on_post_created (cache refresh + buddynext_jetonomy_post_indexed) |
jetonomy_after_delete_post |
action ($post_id) |
A discussion is purged | on_post_hard_deleted |
jetonomy_reply_publish_transition |
action ($reply_id, $delta, $created_at) |
A reply enters or leaves publish |
sync_reply_mirror |
jetonomy_reply_updated |
action ($reply_id, ...) |
A reply is edited | sync_reply_edit_to_feed |
sync_discussion() reads the discussion as it is now, so every path gives the same answer. A published topic is indexed into bn_search_index as object_type = 'discussion' (visibility public, or private for a private topic). A published topic in a public forum gets a discussion feed card via Feed\IntegrationActivity: a withdrawn card is restored with its date, reactions and comments, an existing card is refreshed, and a topic with no card (for example one just approved) gets its first. Anything not published loses its search row and its card is withdrawn (hidden, not deleted), so a restore brings the same card back. A purge removes the card and its comments. The discussion URL is rebuilt from the jt_posts / jt_spaces slugs as {base}/s/{space}/t/{post}/.
A reply's comment on the card follows the reply the same way: a new or approved reply is copied onto the card (as Jetonomy's plain-text copy, dated when the reply was written), a trashed or unapproved reply's comment is removed, and a restore brings back the same comment. Edits sync both ways.
Note: The privacy gate is strict. A private/secret space or a private topic never produces a public feed activity - only the search index entry, which respects its own visibility.
Feed control
Discussion cards follow the Integrations toggle (buddynext_integration_enabled( 'jetonomy', 'feed' ), on by default). When it is off, discussions are still indexed for search but no feed card is published. Per-discussion control is available through the buddynext_jetonomy_discussion_activity filter (return false to skip a specific post).
A discussion card is checked as it renders, through the buddynext_discussion_card_url filter (string $url, array $link_meta). The bridge keeps the link only while the discussion exists and is public. Otherwise the card shows "This discussion is no longer available." and is withdrawn from every feed, and it is restored in place if the discussion is republished. This covers deletes and forum visibility changes that fire no hook. Return '' from the filter to mark a card unavailable yourself.
Profile hand-over
BuddyNext owns the member profile. Opening Jetonomy's own profile directly ({base}/u/{name}/) redirects (301) to the BuddyNext profile; its activity, replies and votes pages go to the profile too, posts goes to the profile's Discussions tab, and edit goes to the BuddyNext profile editor. The owner-only bookmarks and drafts pages and Jetonomy's badges page stay on Jetonomy.
Outbound (what it provides)
| Hook / surface | Purpose |
|---|---|
buddynext_rail_items (filter) |
Adds a "Discussions" item to the BuddyNext left rail, linking to the Jetonomy community home. Active state derived from REQUEST_URI. |
buddynext_register_nav (action) |
Registers a "Discussions" tab on both the profile surface (with a lazy count badge of the member's published discussions) and the space surface (linking to the linked forum, or the on-demand provision trigger). |
buddynext_context_nav (filter) |
Injects Home / Search / Leaderboard sub-nav when the active section is discussions. |
buddynext_hashtag_related_discussions (filter) |
Returns Jetonomy discussions sharing a hashtag slug, so the hashtag feed can show "Related Discussions". |
buddynext_jetonomy_post_indexed (action) |
Fires after a discussion is indexed, for per-space sync extensions. |
On-demand space forum
A BuddyNext space links to a Jetonomy forum through the option bn_space_{space_id}_jetonomy_forum_id. The forum is created the first time a member opens a forumless space's Discussions tab (no empty forums are pre-created):
- Web: the tab links to
/spaces/?bn_provision_forum={space_id};maybe_provision_and_redirect()(ontemplate_redirect:5) provisions the forum and redirects to it. - App / REST:
POST /buddynext/v1/spaces/{id}/forumprovisions (or fetches) the forum and returns{ forum_id, forum_url }.
Both paths gate provisioning on buddynext-moderate-space (space owner/moderator, plus site admins) - an arbitrary logged-in member cannot provision a forum on a space they do not moderate.
curl -X POST "https://example.com/wp-json/buddynext/v1/spaces/42/forum" \
-H "X-WP-Nonce: $NONCE" --cookie "$COOKIES"
# -> { "forum_id": 7, "forum_url": "https://example.com/community/s/general/" }
Messaging precedence
When BuddyNext messaging is available, the bridge filters option_jetonomy_pro_extensions at read time to drop Jetonomy Pro's private-messaging extension, so BuddyNext owns the /messages/ route. Nothing is persisted (the filter only changes the value front-end at read time) and it reverts automatically if BN messaging is disabled. The setting is left untouched in wp-admin so the Jetonomy extensions screen still reflects and saves the real value.
Notifications from Jetonomy, MediaVerse, Career Board and WB Gamification
These plugins send their notifications through the community notification contract, so BuddyNext has no listener of its own for them. Each plugin passes one payload as the last argument of its own notification hook (jetonomy_notification_created, mvs_notification_created, wcb_notification_created) and declares its types; IntegrationNotificationListener shows the row in the bell with the plugin's words, link and icon, never emails it, and drops it when the member has blocked the actor or the owner has switched the integration off. The full contract (payload, {prefix}_community_notification_types, _visible, _removed, grouped rows, quiet refresh) is in Hooks: Notifications and Email.
Cross-cutting rules:
- One notification per action. A member's action notifies once, from the plugin it happened in. A forum reply is Jetonomy's notification; the comment BuddyNext copies onto the discussion card never notifies, and neither do @mentions in a forum topic. BuddyNext mutes its own notifications while a bridge writes a mirrored comment (
buddynext_notification_should_send). - The plugin decides visibility. Each page of the bell asks the plugin which of its rows the viewer may still see (banned author, trashed content, private media).
- Deleting cleans up. A permanently deleted object removes its bell rows through the plugin's
_removedaction. - Collect-only. The plugin sends its own email; BuddyNext never emails these types.
- Minimum version. A plugin release without the contract sends nothing to the bell, so the release that adds it is the minimum BuddyNext supports.
WPMediaVerseBridge
Connects BuddyNext to the WPMediaVerse engine for media and direct messaging. Active only when WPMediaVerse\Core\Plugin exists. BuddyNext consumes WPMediaVerse at the REST/API level only and owns 100% of its own UX - WPMediaVerse JS/CSS is never enqueued on BuddyNext pages, and /messages/ is a fully native BuddyNext surface (templates/messages/native.php) backed by the mvs/v1 REST engine.
Which plugin owns which shared screen - and which of them are still open - is tracked in WPMediaVerse Surface Ownership Map. Read it before building a screen WPMediaVerse also renders.
Declaring BuddyNext active
add_filter( 'mvs_buddynext_active', '__return_true' );
This tells WPMediaVerse to suppress its own floating chat panel, standalone messages page, and duplicate notifications - BuddyNext takes over those surfaces.
DM gating
"Who can message you" is WPMediaVerse's rule: its site ceiling mvs_dm_access and the member's _mvs_dm_access (everyone / followers / mutual / nobody), enforced in MessagingService::can_message(), which reports its own reasons (dms_disabled, mutual_follow_required). BuddyNext keeps no copy of it. The BuddyNext privacy screen offers ProfileService::dm_access_options(), shows effective_dm_access(), and saves the dm_access key through ProfileService::update_profile(), so the ceiling always applies.
check_block() adds BuddyNext's one rule through mvs_can_send_message: a recipient who has blocked the sender (bn_blocks, via the blocks service has_blocked()) denies the send, reported as WPMediaVerse's default blocked reason. Site admins (manage_options) pass the block.
Message + favorite events
| Hook | Type | Result |
|---|---|---|
mvs_message_sent |
action (4 args) | Fires buddynext_dm_sent (sender perspective, once) and buddynext_dm_received (per recipient, sender stripped), then creates bn.new_message notifications. Restrict/mute on the recipient side suppresses the bell without blocking the message. |
mvs_favorite_toggled |
action (3 args) | On 'added' only, notifies the media owner with bn.media_favorited. |
mvs_comment_created |
action | Notifies the media owner with bn.media_commented (object mvs_media, grouped per media, block-aware, never the commenter), from the comment itself, so it works before the photo has a feed card and for media that never gets one. Then copies the comment onto the newest published post showing the media (found through bn_post_media), dated when it was written, and fires buddynext_comment_created (canonical 4-arg) with notifications muted, so the copy never notifies twice. Deduped against re-fires. |
mvs_user_profile_url |
filter | Repoints WPMediaVerse author links at the BuddyNext member profile (PageRouter::profile_url). |
One notification per action
For follows, media comments, favorites and direct messages, BuddyNext sends the notification (bell, email, push) and tells WPMediaVerse to skip its own copy (mvs_should_send_notification). Reactions and mentions stay WPMediaVerse's; BuddyNext only shows them. A media notification opens the post the media is in, or the media's own page when it has no post (buddynext_media_notification_url).
A photo's feed card is created by an Action Scheduler job (buddynext_mvs_media_activity) two minutes after the upload, so a composer post can claim the photo first. When that job creates the card it copies the comments already written onto it, inside the job, so the member's request never pays for it.
Two-way follow mirror
bn_follows (BuddyNext) and mvs_follows (WPMediaVerse) are kept in sync in both directions so a member's follow state is identical on either profile. The bridge listens on mvs_user_followed/unfollowed and buddynext_user_followed/unfollowed; a re-entrancy guard ($mirroring_follow) plus an is_following() short-circuit prevent the mirror from looping back on itself.
Media feed-card lifecycle
A non-photo media upload (video / audio) becomes a 'media' integration feed card keyed on the media permalink, storing only the media id - title and cover resolve at render, per viewer, so they never go stale or leak. The bridge keeps that card honest across the media's whole lifecycle:
| WPMediaVerse fires | Bridge does |
|---|---|
mvs_media_deleted (hard delete) |
on_media_deleted - withdraw the card (and any composer document card by id) |
mvs_media_trashed (soft delete) |
on_media_trashed - withdraw the card by permalink (reversible) |
mvs_media_restored |
on_media_restored - re-publish the card (reference-only, so it reconstructs exactly; idempotent by URL) |
mvs_media_privacy_changed |
not hooked - privacy is resolved at render instead: hydrate_media_preview() gates BOTH title and cover behind PrivacyService::can_view(), so a viewer who may not see the media gets the coverless, titleless compact card. Per viewer, for every transition - a blunt privacy-change withdrawal would also hide members-scoped media from members who may still see it. |
Photo posts are native photo posts holding media ids, not permalink cards, so sync_photo_posts() (called from the three media hooks above) keeps them in step. On trash, a post with no photo left to show goes to draft stamped link_meta.media_withdrawn (a post that still shows other photos stays; the renderer skips the trashed one). On restore, only stamped drafts come back, in place, so a member's own draft holding the same photo is never published. On permanent delete, the id is dropped from every post and a post left with no media is deleted through the normal cascade. The upload's own photo post is created inside IntegrationActivity::as_mirror(), so reward listeners do not pay the upload twice.
A composer document card follows its document the same way, keyed on link_meta.doc_id: every trash (member or admin) goes through the repository's trash(), which fires mvs_media_trashed, so on_media_trashed() withdraws the card; on_media_restored() brings the same card back with its text, reactions and comments; a permanent delete removes it. (Before 1.2.2 a trashed document's card was deleted and never restored; withdrawing keeps the post text, so restore is now safe.)
Media rail item
inject_media_nav_item() adds a "Media" link to the BuddyNext left rail, resolving the engine's mapped Explore page (mvs_page_explore) and falling back to /media/. This only adds a link on BuddyNext's own pages - it never alters a WPMediaVerse page.
Space Files: link a file into more than one space
A document has one home drive (mvs_media_index.drive_type/drive_id). A member can add an existing file to additional spaces without a copy - the same many-to-many model Eventonomy uses for events. The join table mvs_media_spaces (media_id, space_id, added_by, added_at) lives in WPMediaVerse (Migrator v33); MediaRepository::drive_documents() unions a space drive's own rows with the rows linked in, and SearchService unions them into that space's search. Cleanup is automatic - the table is in MediaRepository::MEDIA_CHILD_TABLES, so deleting the file drops its links too.
Requires WPMediaVerse + WPMediaVerse Pro 2.5.1 alongside this BuddyNext release.
Authorization reuses the drive-access seam plus one new filter:
| Filter | Answered by BuddyNext with | Governs |
|---|---|---|
mvs_document_drive_access |
owner -> own, moderator/member -> write, viewer -> read (space_drive_access) |
Who may link (needs write on the target space) and read the space drive. |
mvs_document_can_moderate_space |
true for a space owner/moderator or site admin (space_files_can_moderate, fail-closed) |
Who may remove another member's link. A member may always remove their own; the file owner may always remove their file. |
Because both a member and a moderator resolve to drive write, moderation cannot be read off the drive level alone - the new filter is what separates them (the same reason Eventonomy added evnm_user_can_unbind_space).
Linking grants that space's members view access to the file (PermissionService::permission_from_space_link returns view for anyone who can reach a linked space), exactly as a file uploaded straight into the space is visible - and to nobody outside that space's audience. Home-drive privacy is unchanged everywhere else.
Pro REST (all under mvs-pro/v1): POST /documents/{id}/spaces and POST /documents/link ({ ref, space_id }, where ref is a URL, slug, or id) attach; DELETE /documents/{id}/spaces/{space_id} detaches. The BuddyNext space Files tab renders a "Link a file" control and marks linked rows with a Linked badge; a linked row's Remove drops the link (the original file stays put), while a native space file's Remove re-homes it to the owner's drive.
GamificationBridge
The gamification integration is split into a write-side bridge (BuddyNext events -> engine) and an Achievements profile tab. BuddyNext ships zero gamification logic. It surfaces credential badges in the feed, and renders the engine's public read API for the Achievements tab. Point awards for BuddyNext activity are defined in the wb-gamification plugin's own BuddyNext manifest. The full contract is documented on the Gamification Engine Seam page; in summary:
GamificationBridgeposts a feed activity (on_badge_awarded_activityonwb_gam_badge_awarded) when a member earns a credential badge. Point awards for BuddyNext activity (follow, connection accepted, post created, space joined, reaction received, comment created, profile completion, strike issued) are defined in the wb-gamification plugin'sintegrations/buddynext.phpmanifest, not in this bridge.- WB Gamification sends its notifications (badges, level-ups, kudos, challenges, rewards, expired credentials, personal records, streaks) through its own notification contract, so BuddyNext has no listener for them; the bridge only points each bell row at the matching profile tab (
buddynext_notification_url). See Hooks: Notifications and Email. BuddyNext\Profile\GamificationAchievementsregisters an "Achievements" profile tab (badge grid + points/level/streak standing strip) read purely fromwb_gam_*functions. The tab is data-gated - it appears only once the member has a badge or any points.
Note: The conformance record
docs/conformance/contract-gamification-seam.mddescribes profile gamification being surfaced viabuddynext_profile_extra_data. The current implementation surfaces it through the dedicatedGamificationAchievementsprofile tab instead (registered onbuddynext_register_nav); theprofile_extra_datainjection is not present inGamificationBridge. Document the Achievements tab as the live surface.
MemberBlogBridge
Surfaces a member's published WordPress posts as an Articles profile tab. Unlike every other bridge on this page it consumes no companion hook - its job is knowing which member wrote a post, which needs nothing beyond WP_Query over the tracked post types (buddynext_site_tracking_post_types, default post). WB Member Blog is what gives a member a front-end way to write one; a post published from wp-admin lands on the same tab identically.
- Guard:
MemberBlogBridge::available()checksdefined( 'BUDDYPRESS_MEMBER_BLOG_VERSION' )- the plugin's own bootstrap constant - evaluated lazily inside each surface (the nav condition, the REST handler), not atinit(). Hooks attach unconditionally becauseplugins_loaded:25can still run before Member Blog's own late bootstrap on some sites; a presence check at hook time would silently disable the tab where the plugin is in fact present. - The feed side is NOT this bridge. Free's
Feed\BlogPostListeneralready publishes anarticlecard onpublishfor every tracked post type, independent of Member Blog. This bridge only adds the profile surface and the owner's route back to Member Blog's dashboard. - Registry entry: merges the
navaspect onto the SAMEblogintegration keyBlogPostListeneralready registers forfeed(register_integration()onbuddynext_integrations, merge not replace, so neither side can silently turn the other off). Reportsversion/tested_version(4.1.0) only once Member Blog is actually present; while the plugin is absent, site tracking's ownnullversion is left untouched. - Surfaces: an "Articles" profile tab (priority 65, after Discussions) listing the member's posts with pagination and a per-viewer status filter - the owner also sees drafts and pending review, everyone else sees published only.
articles_data()/MemberBlogRestControllerexpose the same list as JSON for the app. - Dashboard link:
dashboard_url()prefers Member Blog's ownMember_Blog_Compat::get_dashboard_url()when it happens to be loaded, falls back to the configured dashboard page option, then to wp-admin for a member who can reach it, and returns''(no link rendered) rather than a URL that 404s or bounces off a login wall.
Career Board bridge (registered in Pro)
Career Board is two Pro files (jobs are an application layer on the social core, so Free registers neither):
CareerBoardBridge(event sync) - a pure inbound listener onbuddynext_load_bridges, gated on thecareer_boardfeature anddefined( 'WCB_VERSION' ). It consumeswcb_job_created,wcb_job_updated,wcb_job_expired,wcbp_resume_published,wcb_notification_created, plus WP coretransition_post_status/before_delete_post. Jobs and (Pro) resumes become uniformjob/resumefeed cards viaFeed\IntegrationActivity, are indexed intobn_search_index, andwcb_notification_created(fired by WCB's email layer in free and the notifications-bell module in Pro) is mirrored intobn_notifications.wcb_job_createdpasses aWP_REST_Requestthe bridge ignores - title/description/author are read from the job post (post_author).- Job-edit sync:
on_job_updated(onwcb_job_updated) refreshes a published job's card in place viaIntegrationActivity::refresh(transition_post_statusignores a no-status-change edit andpublish()is idempotent, so a plain edit would otherwise leave a stale card). Resumes already re-firewcbp_resume_publishedon update.
- Job-edit sync:
CareerBoardSocial(AbstractSuitePanelProvider) - the member profile Jobs panel (/wcb/v1/jobs?author=) and, with Pro (WCBP_VERSION), the Resume panel (/wcb/v1/resumes?author=). Both render on BuddyNext with BuddyNext identity.
Identity: Career Board exposes no profile-URL / display-name / avatar filter (only generic wcb_rest_prepare_* REST shapers), so there is no seam to fill the way Jetonomy/MediaVerse are filled. Job/resume cards and profile panels are BuddyNext-rendered and already use BuddyNext identity; WCB's own /jobs/ board and company pages remain WCB's surface. Whether to also own identity there (by rewriting author/employer fields in wcb_rest_prepare_*) is an open owner decision, tracked as a Basecamp card.
Listora bridge (registered in Pro)
ListoraBridge connects WB Listora's directory listings (the listora_listing CPT) to the community - the same "business app lives in Pro" pattern as Career Board. Guarded on defined( 'WB_LISTORA_VERSION' ).
- Status-driven, not a dedicated approve hook. WB Listora has no "listing approved" hook - listings move through post statuses (
pending/pending_verification/publish) - so the bridge hooks WP-nativetransition_post_status: a transition INTOpublishfrom any other status broadcasts + indexes; a transition OUT ofpublishwithdraws the card (reversible - draft/trash, not deleted, so a re-publish restores the exact same card instead of orphaning its comments);before_delete_postremoves the card permanently, because a hard delete of a published post skips the trash transition entirely. - Card content: rendered through the shared bridge-card seam (
IntegrationActivity::render_bridge_card, typestore) with an excerpt - the author's own excerpt, falling back to the opening of the content - and a featured-image thumbnail when set. Added because the card originally shipped as an icon, a label and a title with no description, the least informative shape the feed can carry for a listing. - Both search and feed are gated on the owner's Integration Settings switches (
buddynext_integration_enabled( 'listora', 'search' | 'feed' ));on_search_disabled()purges the wholelistingsearch-index type when the owner switches Listora's search off, the same behavior every other bridge's search toggle has. - No notification mirror yet. Listora owns its own dashboard notification center; the bridge only adds feed activity plus the profile Portfolio panel (
Integrations\Listora\ListoraSocial), until Listora ships a creation hook to mirror from (tracked as a Basecamp card). - Businesses-in-Spaces - the curated per-space listing showcase members submit to and a space team approves - is a separate class,
ListoraSpaceShowcaseunderIntegrations\Listora, not part ofListoraBridgeitself. See the Listora integration page for the member/owner-facing walkthrough.
Learnomy bridge (registered in Pro)
Learnomy is a custom-table LMS. Its Space (B2B team) and cohort models are Pro (learnomy-pro). Multiple files:
LearnomyCommunityLink- the richest space link in the suite. A BuddyNext Space is linked to a Learnomy course, Learnomy Space, or cohort (link stored inbn_space_meta:learnomy_link_type/_id/_managed). Membership flows in one direction (Learnomy → community):learnomy_student_enrolled/_unenrolled,learnomy_pro_space_member_added/_removed/_suspended/_resumed,learnomy_pro_cohort_member_added/_removedadd/revoke the member on the linked Space (managed-only, so independent joiners are untouched). Setting a link runs an Action-Scheduler backfill to enrol existing members; source deletion (learnomy_course_deleted/_pro_space_deleted/_pro_cohort_deleted) releases the link. Reverse lookup:linked_bn_spaces( $type, $id )(public). Suspension mirrors as a revoke so a suspended member loses community access.LearnomyBridge- outcome activity:learnomy_course_completed→ "completed a course" card,learnomy_certificate_issued→ "earned a certificate" card (verify-URL). Enrolment/progress produce no activity (outcomes only). The card is stamped withlinked_space_id( $course_id ), so a completion in a linked course shows in both the member's profile and the linked Space's feed; unlinked courses stay profile/main-feed scoped.LearnomyLinkController(REST/learnomy-link*),LearnomyMembershipGrant(a membership plan grants a Space),LearnomyAdminBridge(Community tab on the Learnomy-Space admin),LearnomySocial(profile enrolled / certifications / teaching panels),LearnomyFrontendBridge.
Known gaps (carded): Learnomy-Space sub-groups (lrn_pro_space_groups) and learning paths are not yet linkable to a BuddyNext Space; Learnomy-Space roles are not mapped to BuddyNext-Space roles (members join as plain members).
Eventonomy bridge (registered in Pro)
EventonomyBridge connects the Eventonomy events engine (custom evnm_* tables, not CPTs). The most complete suite bridge - it needs no host takeover because events are natively space-aware: evnm_events.space_id links an event to a BuddyNext Space, and the bridge simply reads it.
- Feed activity, space-scoped:
evnm_after_create_event→ "scheduled an event",evnm_after_create_rsvp/_update_rsvp(statusgoing) → "is attending". BothIntegrationActivity::publish(..., (int) $event['space_id'], ...), so an event bound to a Space posts to that Space's feed AND the member's profile; unbound events stay profile/main-feed.evnm_event_status_changedpublishes on → published and removes on cancel;evnm_after_delete_eventremoves. - Edit sync:
on_event_updated→publish_event_surfaces, which re-indexes search and, whenpublish()dedups an existing card (returns 0), callsIntegrationActivity::refresh()to update the card in place. Note the refresh payload must includetitle+descriptionalongsideevent_card_meta()(image/date/venue) - the card's headline and preview arelink_meta['title']/['description'], whichevent_card_metadoes not carry; passing only the meta refreshed the date/venue but left the headline stale (fixed on 1.2.1). - Surfaces: profile Events tab (Organizing / Going / Interested / Maybe), space Events tab (
render_space_events- a List / Calendar toggle + Create event button), left-rail item, an upcoming-events sidebar widget, and notification mirroring viaevnm_notification_dispatch. - Identity: Eventonomy's
evnm_user_display_namesdefault is already WPdisplay_name(= BuddyNext's), avatars use coreget_avatar()(BuddyNext'sAvatarServicewins), and event pages are Eventonomy's own surface (linked viaevnm_event_permalink) - so nothing to take over. - No double activity: Eventonomy Pro ships its own BuddyPress
ActivityRecorder, but it is guarded onfunction_exists( 'bp_activity_add' )/bp_is_active()and is inert on a BuddyNext (non-BuddyPress) site - only this bridge records activity.
Space Events module (create-in-space)
Eventonomy's own group-events UX (GroupEventStamp + EventsGroupTab) binds to classic BuddyPress groups (bp_get_current_group_id, groups_*), which never resolve for a BuddyNext Space. SpaceEventStamp (includes/Integrations/Eventonomy/SpaceEventStamp.php) is the Space-equivalent: it feeds the same three Eventonomy seams so a member can create an event from a Space and have it auto-bound, without picking a space. Without it, nothing writes a Space space_id through the UI and the space Events tab has no feeder.
evnm_event_editor_fields- on the create URL carrying?bn_space={id}, injectsspace_id(NEW events only) so it rides the editor block's context intoPOST /events. Auto-bind, no picker.evnm_user_can_bind_space- authorises a bind to a real BN space id only (returns the prior decision otherwise, never vouching for another layer's ids). Re-checked on create AND update, so a forged?bn_spaceis refused server-side.evnm_available_spaces- offers the member's own bindable spaces to the editor picker.- Create button links to
evnm_event_create_link(the dashboard/manage-events/?evnm_section=createURL) +bn_space, NOT the Submit Event page - that page 302-redirects and drops query args, sobn_spacewould be lost. - Authorisation mirrors
Galleries::can_create_space_album: admin always; space manager/moderator always; any active member unless the owner set the per-spaceevent_creatorsfield toadmins. Also gated on Eventonomy's ownevnm_user_can_create_events.
List / Calendar views (render_space_events, view carried in ?bn_eview):
- List (default) - BuddyNext's own
render_event_grid+ pager overEventBuckets::resolve_space(matches the hub's card styling), or an inviting empty state. - Calendar - reuses Eventonomy's own
eventonomy/calendarblock viarender_block( [ 'spaceId' => $space_id ] ), exactly as Eventonomy Pro's group tab does; the block'srender.phpmapsspaceId→space_idin its range query, so the month grid is scoped to this space. No reimplemented calendar. Verified embedded in the hub: the block's Interactivity region hydrates (month nav works), events link out to their Eventonomy pages, and it is responsive (mobile agenda layout) and dark-mode-cohesive out of the box. The toggle links are ordinary full-load navigations so the block hydrates cleanly; degrades to the list if the block is unregistered.
Per-space owner controls (fields registered by the bridge on buddynext_register_space_fields, rendered in the space Settings → Integrations panel guarded by SpaceFieldRegistry::get_field('events_tab')):
events_tab(boolean, default0) - show the Events tab in this space. Mirrorsmvs_media_tab/mvs_documents_tab; the space nav item is gated on it, so the tab is owner-opt-in (shown even when empty, so members can create the first event).event_creators(select, defaultmembers;members|admins) - who may add events. Editing an event's content stays author-only (Eventonomy'sCapabilities::user_can_manage_event, unchanged - a space owner gets no edit rights over a member's event). This setting is the "who may add" door.
Multi-space linking (an event in several spaces). An event has one home space (evnm_events.space_id, set at creation) and any number of additional spaces via the many-to-many table evnm_event_spaces (Eventonomy 1.7.0; PRIMARY KEY(event_id, space_id), KEY(space_id)). Eventonomy's own space filter in EventRepository and OccurrenceRepository unions the two - space_id = X OR EXISTS(a link row for X) - so an event shows in every space it belongs to, in both the list and the calendar, with no per-space duplication of the event.
- API (Eventonomy
EventService):attach_space()(authorised viaevnm_user_can_bind_space),detach_space(),spaces_for($id)(home + links, de-duped). Links are cleaned up on event delete. - "Link event" (BN): a button beside Create in the space toolbar (same door as Create -
can_bind_space;event_creators=adminsrestricts both). Paste an event URL →SpaceEventStamp::resolve_event_ref()resolves it (slug viafind_by_slug, or numeric id) → must be published + public →attach_space. Rejects bad links and non-public events with a specific notice. No-JS<details>+ POST, handled ontemplate_redirect(PRG). Creation stays single home space (unchanged).
Organiser moderation - "Remove from space" (unlink). The removal half of space control: a space owner/manager/moderator (or site admin) can detach any event that belongs to their space, in the space Events List view. It never deletes or edits the event. Multi-space aware: if the space is the event's home, it clears space_id; if it's an additional link, it drops that link (detach_space) - either way the event stays in every OTHER space it belongs to. Membership is checked against spaces_for() (home and links), so a linked event can be removed, not only one created there.
- Authority:
SpaceEventStamp::can_moderate_space_events()(spacebuddynext-manage-space/buddynext-moderate-space, ormanage_options) - a space authority, deliberately separate from event authorship.unbind_from_space()also verifies the event is currently bound to that space, then calls Eventonomy's publicEventService::update( $id, ['space_id'=>0] )(no table writes). Eventonomy's ownauthorize_space_bindingalways permits an unbind (space_id<=0); the author-gate is only in its REST controller, so BN authorises the organiser itself. - Two entry points (portfolio rule): a progressive
<details>two-step POST form on the web (nonce; works without JS; handled ontemplate_redirectwith a PRG redirect + status notice), andPOST buddynext-pro/v1/spaces/{space_id}/events/{event_id}/unbindfor the app. The control renders only for organisers, only in List view, and never inside the row link. - The
on_event_updatedhook then re-syncs surfaces - the space feed card followsspace_idto 0.
Visibility: an event created in a space defaults to Eventonomy's public visibility (the creator can change it), and resolve_space already queries visibility='public', so the space tab and the global calendar both show it - a private space's events are therefore public unless the creator narrows them.
PWA
PwaService (includes/PWA/PwaService.php) is a first-party service, not a companion bridge, but it follows the same opt-out pattern. It is always wired (Plugin::init()), and on the front end it:
- Outputs the web-app manifest
<link>inwp_head. - Serves the manifest JSON and the generated service worker through REST routes (the service-worker response sets
Service-Worker-Allowed: /). - Enqueues the client bootstrap
assets/js/pwa/sw-register.js(no inline script).
Two extension seams:
// Customize the manifest array (name, theme_color, icons, ...).
add_filter( 'buddynext_pwa_manifest', function ( array $manifest ): array { /* ... */ return $manifest; } );
// Opt out of service-worker registration entirely (e.g. when another PWA owns the SW).
add_filter( 'buddynext_pwa_register_sw', '__return_false' );
The service skips entirely in wp-admin (the manifest only applies to the front end).
BuddyXBridge
A theme bridge, always wired because it self-guards on 'buddyx' === get_template(). Without it, BuddyX wraps every get_header() in a .container div that constrains plugin layouts. The bridge hooks buddyx_is_full_width_page -> true on WPMediaVerse front-end pages (detected via the mvs_page_* option page IDs) so the theme skips its container wrapper for those surfaces. A future seam will map BuddyX Customizer values to --bn-* tokens via buddynext_css_vars.
Notes / gotchas
Mirrored writes are flagged. When a bridge copies an action a partner already recorded (a MediaVerse follow or lightbox comment, a Jetonomy reply, or the reverse), it runs the write inside
IntegrationActivity::as_mirror(). A listener that rewards or counts actions should return early when\BuddyNext\Feed\IntegrationActivity::is_mirror()is true, so one member action is never paid twice. Guard the call withis_callable()for older BuddyNext versions.Bridges never call companion code directly outside a guard. Every companion class/function reference is wrapped in a
class_exists/function_exists/method_existscheck, so a partial or older companion build degrades instead of fataling.Feature toggle vs companion presence are independent gates. A bridge runs only when both its feature toggle is on (
buddynext_feature_enabled) and its companion is active. Disabling the toggle removes the bridge even if the companion is installed.Companion table access is the bridge's job. Jetonomy
jt_*reads and the WPMediaVerse follow-graph access live inside the bridge classes; downstream templates and services consume bridge methods (for exampleJetonomyBridge::user_discussions()), never the companion schema.Free/Pro boundary.
JetonomyBridge(+ listener),WPMediaVerseBridge,GamificationBridge(+ listener),MemberBlogBridge,BuddyXBridgeandPwaServiceall live in Free and run regardless of Pro.CareerBoardBridge,EventonomyBridge,LearnomyBridgeandListoraBridgeare business-application bridges that live in Pro and register on the samebuddynext_load_bridgesseam; all four are documented above. Pro also registersWooCommerceBridgeandPmproBridgeon a separate seam (GrantBridgeRegistrar, viabuddynextpro_membership_sources) - those are membership-grant bridges for monetization, a different contract (AbstractGrantBridge) than a community-surfacing bridge, and are documented on Membership Grant Bridges, not here.Docblocks may lag code.
JetonomyBridge's own docblock still mentionsjetonomy_show_community_nav; the live code no longer registers it. When a comment and the source disagree, the source is authoritative.