Merge pull request 'feat(bridge): Discord badge on bridged users and a header chip on bridged rooms' (#479) from feat/discord-bridge-badge into lotus
CI / Secret scan (gitleaks) (push) Successful in 18s
CI / Build & Quality Checks (push) Successful in 2m40s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 23m48s
CI / Secret scan (gitleaks) (push) Successful in 18s
CI / Build & Quality Checks (push) Successful in 2m40s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 23m48s
This commit was merged in pull request #479.
This commit is contained in:
@@ -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 = () => (
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
|
||||
/>
|
||||
);
|
||||
|
||||
const BRIDGE_ICON: Record<BridgeNetwork, IconSrc> = {
|
||||
discord: DiscordIcon,
|
||||
};
|
||||
|
||||
const BRIDGE_COLOR: Record<BridgeNetwork, string> = {
|
||||
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 (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{label}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
||||
>
|
||||
<Icon size={size} src={BRIDGE_ICON[network]} style={{ color: BRIDGE_COLOR[network] }} />
|
||||
</span>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>
|
||||
{bridge.externalUrl
|
||||
? `${description}. Open in ${BRIDGE_NETWORK_NAME[bridge.network]}.`
|
||||
: description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Chip
|
||||
ref={triggerRef}
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
aria-label={description}
|
||||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={BRIDGE_ICON[bridge.network]}
|
||||
style={{ color: BRIDGE_COLOR[bridge.network] }}
|
||||
/>
|
||||
}
|
||||
{...linkProps}
|
||||
>
|
||||
<Text size="T200">{BRIDGE_NETWORK_NAME[bridge.network]}</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './BridgeBadge';
|
||||
@@ -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}
|
||||
</Text>
|
||||
<UserBridgeBadge userId={userId} size="200" />
|
||||
</Box>
|
||||
{pronouns && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
||||
|
||||
@@ -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 && (
|
||||
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
|
||||
)}
|
||||
<UserBridgeBadge userId={member.userId} />
|
||||
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
|
||||
{typing && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
|
||||
@@ -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 }) {
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<RoomBridgeChip room={room} />
|
||||
</Box>
|
||||
{topic && (
|
||||
<UseStateProvider initial={false}>
|
||||
|
||||
@@ -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(
|
||||
</Text>
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
<UserBridgeBadge userId={senderId} />
|
||||
</Box>
|
||||
<Box shrink="No" gap="100">
|
||||
{messageLayout === MessageLayout.Modern && hover && (
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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<string, unknown>) =>
|
||||
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');
|
||||
});
|
||||
@@ -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<BridgeNetwork, string> = {
|
||||
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<string, BridgeNetwork> = {
|
||||
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<string, RoomBridgeInfo>();
|
||||
[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}`;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user