diff --git a/src/app/components/bridge/BridgeBadge.tsx b/src/app/components/bridge/BridgeBadge.tsx
new file mode 100644
index 000000000..620f6a441
--- /dev/null
+++ b/src/app/components/bridge/BridgeBadge.tsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import { Room } from 'matrix-js-sdk';
+import { Chip, Icon, IconSrc, Text, Tooltip, TooltipProvider } from 'folds';
+import {
+ BRIDGE_NETWORK_NAME,
+ BridgeNetwork,
+ describeRoomBridge,
+ getBridgedUserNetwork,
+} from '../../utils/bridge';
+import { useRoomBridges } from '../../hooks/useRoomBridges';
+
+// Discord glyph (simple-icons, 24x24 viewBox like the folds icons).
+const DiscordIcon: IconSrc = () => (
+
+);
+
+const BRIDGE_ICON: Record = {
+ discord: DiscordIcon,
+};
+
+const BRIDGE_COLOR: Record = {
+ discord: '#5865F2',
+};
+
+type UserBridgeBadgeProps = {
+ userId: string;
+ size?: '50' | '100' | '200';
+};
+
+/** Network icon for a user who is bridged in from another chat network, else nothing. */
+export function UserBridgeBadge({ userId, size = '100' }: UserBridgeBadgeProps) {
+ const network = getBridgedUserNetwork(userId);
+ if (!network) return null;
+ const label = `On ${BRIDGE_NETWORK_NAME[network]} (bridged)`;
+ return (
+
+ {label}
+
+ }
+ >
+ {(ref) => (
+
+
+
+ )}
+
+ );
+}
+
+type RoomBridgeChipProps = {
+ room: Room;
+};
+
+/** Header chip for a room bridged to another network; links to the channel when known. */
+export function RoomBridgeChip({ room }: RoomBridgeChipProps) {
+ const bridges = useRoomBridges(room);
+ const bridge = bridges[0];
+ if (!bridge) return null;
+ const description = describeRoomBridge(bridge);
+ const linkProps = bridge.externalUrl
+ ? { as: 'a' as const, href: bridge.externalUrl, target: '_blank', rel: 'noreferrer noopener' }
+ : {};
+ return (
+
+
+ {bridge.externalUrl
+ ? `${description}. Open in ${BRIDGE_NETWORK_NAME[bridge.network]}.`
+ : description}
+
+
+ }
+ >
+ {(triggerRef) => (
+
+ }
+ {...linkProps}
+ >
+ {BRIDGE_NETWORK_NAME[bridge.network]}
+
+ )}
+
+ );
+}
diff --git a/src/app/components/bridge/index.ts b/src/app/components/bridge/index.ts
new file mode 100644
index 000000000..78a118374
--- /dev/null
+++ b/src/app/components/bridge/index.ts
@@ -0,0 +1 @@
+export * from './BridgeBadge';
diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx
index 74b20e961..1d4e6a2be 100644
--- a/src/app/components/user-profile/UserHero.tsx
+++ b/src/app/components/user-profile/UserHero.tsx
@@ -27,6 +27,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useLocalTime } from '../../hooks/useLocalTime';
+import { UserBridgeBadge } from '../bridge';
type UserHeroProps = {
userId: string;
@@ -143,6 +144,7 @@ export function UserHeroName({
>
{displayName ?? username ?? userId}
+
{pronouns && (
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index af054bbb4..783cebc1f 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -71,6 +71,7 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/use
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
+import { UserBridgeBadge } from '../../components/bridge';
import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
@@ -178,6 +179,7 @@ function MemberItem({
{presence && (
)}
+
{showEncryption && }
{typing && (
diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx
index fe6847da4..3bd291a48 100644
--- a/src/app/features/room/RoomViewHeader.tsx
+++ b/src/app/features/room/RoomViewHeader.tsx
@@ -34,6 +34,7 @@ import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
+import { RoomBridgeChip } from '../../components/bridge';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
@@ -690,6 +691,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)}
)}
+
{topic && (
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 9bf72e2a4..debcac549 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -90,6 +90,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power';
+import { UserBridgeBadge } from '../../../components/bridge';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
@@ -1084,6 +1085,7 @@ export const Message = React.memo(
{tagIconSrc && }
+
{messageLayout === MessageLayout.Modern && hover && (
diff --git a/src/app/hooks/useRoomBridges.ts b/src/app/hooks/useRoomBridges.ts
new file mode 100644
index 000000000..b50885a9a
--- /dev/null
+++ b/src/app/hooks/useRoomBridges.ts
@@ -0,0 +1,32 @@
+import { Room } from 'matrix-js-sdk';
+import { useCallback, useMemo } from 'react';
+import { useStateEventCallback } from './useStateEventCallback';
+import { useForceUpdate } from './useForceUpdate';
+import { getRoomBridges, RoomBridgeInfo } from '../utils/bridge';
+import { StateEvent } from '../../types/matrix/room';
+
+export const useRoomBridges = (room: Room): RoomBridgeInfo[] => {
+ const [updateCount, forceUpdate] = useForceUpdate();
+
+ useStateEventCallback(
+ room.client,
+ useCallback(
+ (event) => {
+ const type = event.getType();
+ if (
+ event.getRoomId() === room.roomId &&
+ (type === StateEvent.Bridge || type === StateEvent.BridgeUnstable)
+ ) {
+ forceUpdate();
+ }
+ },
+ [room, forceUpdate],
+ ),
+ );
+
+ return useMemo(
+ () => getRoomBridges(room),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [room, updateCount],
+ );
+};
diff --git a/src/app/utils/bridge.test.ts b/src/app/utils/bridge.test.ts
new file mode 100644
index 000000000..e3a99273f
--- /dev/null
+++ b/src/app/utils/bridge.test.ts
@@ -0,0 +1,86 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import {
+ describeRoomBridge,
+ getBridgedUserNetwork,
+ getRoomBridges,
+ parseBridgeEvent,
+} from './bridge';
+
+const bridgeEvent = (type: string, stateKey: string, content: Record) =>
+ new MatrixEvent({ type, state_key: stateKey, content, room_id: '!r:x', sender: '@discordbot:x' });
+
+// Shape of what mautrix-discord 0.7.7 sends for a guild channel.
+const discordContent = {
+ bridgebot: '@discordbot:matrix.lotusguild.org',
+ protocol: { id: 'discordgo', displayname: 'Discord' },
+ channel: {
+ id: '1565159372122882070',
+ displayname: '#general',
+ external_url: 'https://discord.com/channels/1/1565159372122882070',
+ },
+ network: { id: '1', displayname: 'Lotus Guild' },
+};
+
+const roomWithState = (events: MatrixEvent[]): Room =>
+ ({
+ getLiveTimeline: () => ({
+ getState: () => ({
+ getStateEvents: (type: string) => events.filter((e) => e.getType() === type),
+ }),
+ }),
+ }) as unknown as Room;
+
+test('Discord ghosts are recognised; the bridge bot and lookalikes are not', () => {
+ assert.equal(
+ getBridgedUserNetwork('@discord_123456789012345678:matrix.lotusguild.org'),
+ 'discord',
+ );
+ assert.equal(getBridgedUserNetwork('@discordbot:matrix.lotusguild.org'), undefined);
+ assert.equal(getBridgedUserNetwork('@discord_fan:matrix.lotusguild.org'), undefined);
+ assert.equal(getBridgedUserNetwork('@discord-relay:matrix.lotusguild.org'), undefined);
+ assert.equal(getBridgedUserNetwork('@jared:matrix.lotusguild.org'), undefined);
+});
+
+test('parseBridgeEvent reads a mautrix-discord m.bridge event', () => {
+ assert.deepEqual(parseBridgeEvent(bridgeEvent('m.bridge', 'k', discordContent)), {
+ network: 'discord',
+ channelName: '#general',
+ networkName: 'Lotus Guild',
+ externalUrl: 'https://discord.com/channels/1/1565159372122882070',
+ });
+});
+
+test('parseBridgeEvent ignores unknown protocols and empty content', () => {
+ assert.equal(
+ parseBridgeEvent(bridgeEvent('m.bridge', 'k', { protocol: { id: 'telegram' } })),
+ undefined,
+ );
+ assert.equal(parseBridgeEvent(bridgeEvent('m.bridge', 'k', {})), undefined);
+});
+
+test('only http(s) channel links are kept', () => {
+ const content = {
+ ...discordContent,
+ channel: { displayname: '#x', external_url: ['javascript', 'alert(1)'].join(':') },
+ };
+ assert.equal(parseBridgeEvent(bridgeEvent('m.bridge', 'k', content))?.externalUrl, undefined);
+});
+
+test('getRoomBridges merges m.bridge with its unstable copy, one entry per channel', () => {
+ const room = roomWithState([
+ bridgeEvent('m.bridge', 'fi.mau.discord://discord/1/2', discordContent),
+ bridgeEvent('uk.half-shot.bridge', 'fi.mau.discord://discord/1/2', discordContent),
+ ]);
+ assert.equal(getRoomBridges(room).length, 1);
+ assert.equal(getRoomBridges(roomWithState([])).length, 0);
+});
+
+test('describeRoomBridge degrades gracefully without names', () => {
+ assert.equal(
+ describeRoomBridge({ network: 'discord', channelName: '#general', networkName: 'Lotus Guild' }),
+ 'Bridged to #general in Lotus Guild on Discord',
+ );
+ assert.equal(describeRoomBridge({ network: 'discord' }), 'Bridged to Discord');
+});
diff --git a/src/app/utils/bridge.ts b/src/app/utils/bridge.ts
new file mode 100644
index 000000000..fa955896e
--- /dev/null
+++ b/src/app/utils/bridge.ts
@@ -0,0 +1,80 @@
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import { StateEvent } from '../../types/matrix/room';
+import { getStateEvents } from './room';
+
+export type BridgeNetwork = 'discord';
+
+export const BRIDGE_NETWORK_NAME: Record = {
+ discord: 'Discord',
+};
+
+// Ghost (puppet) users a bridge creates for people on the other network.
+// mautrix-discord's default username_template is discord_{{.}} with a numeric
+// Discord snowflake; the bridge bot itself (@discordbot) is not a ghost.
+const GHOST_PATTERNS: [RegExp, BridgeNetwork][] = [[/^@discord_\d+:/, 'discord']];
+
+export const getBridgedUserNetwork = (userId: string): BridgeNetwork | undefined =>
+ GHOST_PATTERNS.find(([pattern]) => pattern.test(userId))?.[1];
+
+// m.bridge protocol.id values that mean each network.
+const PROTOCOL_NETWORKS: Record = {
+ discordgo: 'discord',
+ discord: 'discord',
+};
+
+export type RoomBridgeInfo = {
+ network: BridgeNetwork;
+ channelName?: string;
+ networkName?: string;
+ externalUrl?: string;
+};
+
+const asString = (value: unknown): string | undefined =>
+ typeof value === 'string' && value.trim() ? value.trim() : undefined;
+
+// Only http(s) links are made clickable; the bridge (or anyone with state
+// power in the room) controls this value.
+const safeExternalUrl = (value: unknown): string | undefined => {
+ const raw = asString(value);
+ if (!raw) return undefined;
+ try {
+ const url = new URL(raw);
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+export const parseBridgeEvent = (event: MatrixEvent): RoomBridgeInfo | undefined => {
+ const content = event.getContent();
+ const protocolId = asString(content?.protocol?.id)?.toLowerCase();
+ const network = protocolId ? PROTOCOL_NETWORKS[protocolId] : undefined;
+ if (!network) return undefined;
+ return {
+ network,
+ channelName: asString(content.channel?.displayname),
+ networkName: asString(content.network?.displayname),
+ externalUrl: safeExternalUrl(content.channel?.external_url),
+ };
+};
+
+/** Known bridges in a room, one per bridged channel (m.bridge preferred over the unstable copy). */
+export const getRoomBridges = (room: Room): RoomBridgeInfo[] => {
+ const byKey = new Map();
+ [StateEvent.BridgeUnstable, StateEvent.Bridge].forEach((type) => {
+ getStateEvents(room, type).forEach((event) => {
+ const info = parseBridgeEvent(event);
+ if (info) byKey.set(event.getStateKey() ?? '', info);
+ });
+ });
+ return Array.from(byKey.values());
+};
+
+export const describeRoomBridge = (info: RoomBridgeInfo): string => {
+ const network = BRIDGE_NETWORK_NAME[info.network];
+ if (info.channelName && info.networkName) {
+ return `Bridged to ${info.channelName} in ${info.networkName} on ${network}`;
+ }
+ if (info.channelName) return `Bridged to ${info.channelName} on ${network}`;
+ return `Bridged to ${network}`;
+};
diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts
index 2fb7113ac..eb0cf40a5 100644
--- a/src/types/matrix/room.ts
+++ b/src/types/matrix/room.ts
@@ -50,6 +50,10 @@ export enum StateEvent {
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
// state-key, aggregated with parent-space packs like custom emoji.
LotusSoundboardRoom = 'io.lotus.soundboard',
+ // MSC2346 bridge info; state_key identifies the bridged channel. Bridges
+ // still send the pre-stable uk.half-shot.bridge copy alongside it.
+ Bridge = 'm.bridge',
+ BridgeUnstable = 'uk.half-shot.bridge',
}
export enum MessageEvent {