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 {