Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb2034080 | ||
|
|
400ee76b63 | ||
|
|
47c1e435b7 | ||
|
|
4650796120 | ||
|
|
d55a8ba8a5 | ||
|
|
4b8c3219e9 | ||
|
|
47328d6fee | ||
|
|
b0eb3da830 | ||
|
|
2700aad209 | ||
|
|
a8a7e639a9 |
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
JoinRule,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -72,7 +71,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
||||
import { getStateEvent, getMemberName } from '../utils/room';
|
||||
import { isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
@@ -581,12 +581,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||
// restricted rooms, and public rooms must never trigger ringing.
|
||||
if (room.isCallRoom()) return;
|
||||
const isDirect = directs.has(room.roomId);
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
const joinRule = room.getJoinRule();
|
||||
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
||||
if (!isDirect && !isPrivateInviteGroup) return;
|
||||
if (!isRingableRoom(room, directs.has(room.roomId))) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
|
||||
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
|
||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||
};
|
||||
|
||||
const light: CSSProperties = {
|
||||
@@ -75,7 +75,7 @@ const light: CSSProperties = {
|
||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||
};
|
||||
|
||||
export const animAurora: ChatBgVariants = { dark, light };
|
||||
|
||||
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
|
||||
'0 0', // wash A (static)
|
||||
'0 0', // wash B (static)
|
||||
].join(','),
|
||||
animation: `${firefliesDrift} 44s linear infinite`,
|
||||
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||
},
|
||||
|
||||
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
||||
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
|
||||
'0 0', // wash A (static)
|
||||
'0 0', // wash B (static)
|
||||
].join(','),
|
||||
animation: `${firefliesDrift} 44s linear infinite`,
|
||||
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
|
||||
'0 0', // base wash
|
||||
'0 0', // vignette
|
||||
].join(','),
|
||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||
},
|
||||
|
||||
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
||||
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
|
||||
'0 0', // base wash
|
||||
'0 0', // vignette
|
||||
].join(','),
|
||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
|
||||
].join(','),
|
||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||
animation: `${rainFall} 12s linear infinite`,
|
||||
animation: `${rainFall} 12s steps(288) infinite`,
|
||||
},
|
||||
|
||||
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||||
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
|
||||
].join(','),
|
||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||
animation: `${rainFall} 12s linear infinite`,
|
||||
animation: `${rainFall} 12s steps(288) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
|
||||
'0 0', // nebula A
|
||||
'0 0', // nebula B
|
||||
].join(','),
|
||||
animation: `${starDrift} 90s linear infinite`,
|
||||
animation: `${starDrift} 90s steps(1080) infinite`,
|
||||
},
|
||||
|
||||
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
||||
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
|
||||
'0 0', // wash A
|
||||
'0 0', // wash B
|
||||
].join(','),
|
||||
animation: `${starDrift} 100s linear infinite`,
|
||||
animation: `${starDrift} 100s steps(1200) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,6 +149,11 @@ export const getChatBg = (
|
||||
}
|
||||
// For animated backgrounds, promote the element to its own compositor layer so
|
||||
// background-position keyframes don't trigger repaints on descendant elements.
|
||||
// [Gitea #337] background-position can't run on the compositor, so every
|
||||
// animation frame repaints the whole chat area (45 to 70% main-thread busy
|
||||
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
|
||||
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
|
||||
// same, a fraction of the repaints.
|
||||
if (style.animation) {
|
||||
return { ...style, willChange: 'background-position', contain: 'paint' };
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { MsgTypeFilter } from './useMessageSearch';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
||||
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
||||
@@ -137,14 +138,19 @@ type SelectRoomButtonProps = {
|
||||
onChange: (rooms?: string[]) => void;
|
||||
};
|
||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||
|
||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||
(rId) => mx.getRoom(rId)?.name ?? rId,
|
||||
[mx],
|
||||
(rId) => {
|
||||
const name = mx.getRoom(rId)?.name ?? rId;
|
||||
// [Gitea #336] match the personal "Rename for me" name too
|
||||
return localNames[rId] ? [localNames[rId], name] : name;
|
||||
},
|
||||
[mx, localNames],
|
||||
);
|
||||
|
||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
||||
}
|
||||
>
|
||||
<Text truncate size="T300">
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
@@ -701,6 +707,7 @@ export function SearchFilters({
|
||||
pinnedOnly,
|
||||
onTogglePinnedOnly,
|
||||
}: SearchFiltersProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
@@ -748,7 +755,7 @@ export function SearchFilters({
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">{room.name}</Text>
|
||||
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
room: Room;
|
||||
@@ -73,6 +74,7 @@ export function SearchResultGroup({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: SearchResultGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||
@@ -212,7 +214,7 @@ export function SearchResultGroup({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
@@ -138,6 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { isRingableRoom } from '../../utils/callRing';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
@@ -2030,6 +2031,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
if (!senderId || senderId === mx.getUserId()) return null;
|
||||
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||
// channels, where starting a call is just joining the channel.
|
||||
if (!isRingableRoom(room, direct)) return null;
|
||||
const content = mEvent.getContent();
|
||||
const mention =
|
||||
content['m.mentions']?.room ||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { getMemberName } from '../../../utils/room';
|
||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type ThreadPanelHeaderProps = {
|
||||
room: Room;
|
||||
@@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||
const { format } = useTimestampFormatter();
|
||||
// [Gitea #165] Who started the thread and when — the first thing you want
|
||||
@@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
|
||||
Thread
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
@@ -235,6 +236,7 @@ export type ThreadsListPanelProps = {
|
||||
onOpenThread: (threadId: string) => void;
|
||||
};
|
||||
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const handleJump = useCallback(
|
||||
@@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
||||
Threads
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{totalUnread > 0 && (
|
||||
|
||||
@@ -98,6 +98,7 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type RoomNotificationsGroup = {
|
||||
roomId: string;
|
||||
@@ -223,6 +224,7 @@ function RoomNotificationsGroupComp({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: RoomNotificationsGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
@@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { isRingableRoom } from './callRing';
|
||||
|
||||
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
||||
({
|
||||
isCallRoom: () => !!opts.callRoom,
|
||||
getJoinRule: () => opts.joinRule ?? 'invite',
|
||||
getLiveTimeline: () => ({
|
||||
getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }),
|
||||
}),
|
||||
}) as unknown as Room;
|
||||
|
||||
test('voice rooms never ring or show "missed call", even as DMs', () => {
|
||||
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false);
|
||||
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false);
|
||||
});
|
||||
|
||||
test('DMs and invite-only groups outside a space ring', () => {
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true);
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true);
|
||||
});
|
||||
|
||||
test('space channels, public and restricted rooms do not ring', () => {
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false);
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getStateEvents } from './room';
|
||||
|
||||
/**
|
||||
* Whether a call notification in this room may ring — and, by the same rule,
|
||||
* whether an unanswered one may show as "Missed call" (Gitea #162 follow-up).
|
||||
* Only DMs and invite-only groups outside any space ring, i.e. rooms where the
|
||||
* call button is shown. Persistent voice rooms, space channels, restricted and
|
||||
* public rooms never do: joining a voice channel alone is normal and isn't a
|
||||
* "call" anyone missed.
|
||||
*/
|
||||
export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
|
||||
if (room.isCallRoom()) return false;
|
||||
if (isDirect) return true;
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
|
||||
};
|
||||
@@ -38,3 +38,38 @@ test('hostFromText only accepts URL/host-shaped text; IDN becomes punycode', ()
|
||||
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true);
|
||||
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true);
|
||||
});
|
||||
|
||||
test('a trusted name inside a sentence still exposes a different destination (#335)', () => {
|
||||
const a = analyzeLink('Log in at chat.lotusguild.org', 'https://evil.example/');
|
||||
assert.equal(a?.mismatch, true);
|
||||
assert.equal(a?.shownHost, 'chat.lotusguild.org');
|
||||
assert.equal(
|
||||
analyzeLink('chat.lotusguild.org (official)', 'https://evil.example/')?.mismatch,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
analyzeLink('Open https://paypal.com/login now', 'https://evil.example/')?.mismatch,
|
||||
true,
|
||||
);
|
||||
// honest sentences are not flagged
|
||||
assert.equal(
|
||||
analyzeLink('Log in at chat.lotusguild.org', 'https://chat.lotusguild.org/login')?.mismatch,
|
||||
false,
|
||||
);
|
||||
assert.equal(analyzeLink('read the docs', 'https://evil.example/')?.mismatch, false);
|
||||
assert.equal(analyzeLink('see main.py and report.pdf', 'https://github.com/x')?.mismatch, false);
|
||||
assert.equal(analyzeLink('release v1.2.3 notes', 'https://github.com/x')?.mismatch, false);
|
||||
});
|
||||
|
||||
test('shared hosting suffixes are separate sites (#335)', () => {
|
||||
assert.equal(registrableDomain('alice.github.io'), 'alice.github.io');
|
||||
assert.equal(registrableDomain('www.alice.github.io'), 'alice.github.io');
|
||||
assert.equal(registrableDomain('bucket.s3.amazonaws.com'), 'bucket.s3.amazonaws.com');
|
||||
assert.equal(analyzeLink('alice.github.io', 'https://evil.github.io/')?.mismatch, true);
|
||||
assert.equal(analyzeLink('lotusguild.pages.dev', 'https://evil.pages.dev/')?.mismatch, true);
|
||||
assert.equal(
|
||||
analyzeLink('alice.github.io/blog', 'https://alice.github.io/blog/1')?.mismatch,
|
||||
false,
|
||||
);
|
||||
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
|
||||
});
|
||||
|
||||
+131
-7
@@ -48,11 +48,53 @@ const TWO_LABEL_SUFFIXES = new Set([
|
||||
'com.tw',
|
||||
]);
|
||||
|
||||
// [Gitea #335] Shared-hosting suffixes (the Public Suffix List's "private"
|
||||
// section, the ones phishing actually uses): every customer gets their own
|
||||
// subdomain, so `alice.github.io` and `evil.github.io` are different sites.
|
||||
// A curated list rather than the full PSL keeps this out of the bundle budget.
|
||||
const SHARED_HOSTING_SUFFIXES = new Set([
|
||||
'github.io',
|
||||
'gitlab.io',
|
||||
'codeberg.page',
|
||||
'pages.dev',
|
||||
'workers.dev',
|
||||
'trycloudflare.com',
|
||||
'netlify.app',
|
||||
'vercel.app',
|
||||
'web.app',
|
||||
'firebaseapp.com',
|
||||
'appspot.com',
|
||||
'herokuapp.com',
|
||||
'onrender.com',
|
||||
'fly.dev',
|
||||
'glitch.me',
|
||||
'replit.app',
|
||||
'repl.co',
|
||||
'surge.sh',
|
||||
'blogspot.com',
|
||||
'wordpress.com',
|
||||
'wixsite.com',
|
||||
'weebly.com',
|
||||
'azurewebsites.net',
|
||||
'cloudfront.net',
|
||||
'ngrok.io',
|
||||
'ngrok-free.app',
|
||||
'duckdns.org',
|
||||
's3.amazonaws.com',
|
||||
]);
|
||||
|
||||
const isPublicSuffix = (suffix: string): boolean =>
|
||||
TWO_LABEL_SUFFIXES.has(suffix) || SHARED_HOSTING_SUFFIXES.has(suffix);
|
||||
|
||||
export const registrableDomain = (host: string): string => {
|
||||
const labels = host.toLowerCase().replace(/\.$/, '').split('.');
|
||||
if (labels.length <= 2) return labels.join('.');
|
||||
const lastTwo = labels.slice(-2).join('.');
|
||||
return TWO_LABEL_SUFFIXES.has(lastTwo) ? labels.slice(-3).join('.') : lastTwo;
|
||||
// Longest matching public suffix wins (3 labels, e.g. s3.amazonaws.com, then 2).
|
||||
for (const n of [3, 2]) {
|
||||
if (labels.length > n && isPublicSuffix(labels.slice(-n).join('.'))) {
|
||||
return labels.slice(-(n + 1)).join('.');
|
||||
}
|
||||
}
|
||||
return labels.slice(-2).join('.');
|
||||
};
|
||||
|
||||
const HOST_LIKE =
|
||||
@@ -72,6 +114,85 @@ export const hostFromText = (text: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
// Words that look like hosts but are usually files or versions in chat
|
||||
// ("see main.py", "report.pdf", "v1.2.3"): skipped inside sentences unless
|
||||
// written as a URL (scheme, www., or a path) so ordinary links aren't flagged.
|
||||
const FILE_EXTENSIONS = new Set([
|
||||
'py',
|
||||
'sh',
|
||||
'md',
|
||||
'rs',
|
||||
'js',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'json',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'txt',
|
||||
'log',
|
||||
'csv',
|
||||
'pdf',
|
||||
'zip',
|
||||
'gz',
|
||||
'tar',
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'svg',
|
||||
'mp3',
|
||||
'mp4',
|
||||
'mov',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'exe',
|
||||
'dll',
|
||||
'lock',
|
||||
'cfg',
|
||||
'conf',
|
||||
'ini',
|
||||
'xml',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'apk',
|
||||
'iso',
|
||||
'jar',
|
||||
'java',
|
||||
'go',
|
||||
'rb',
|
||||
'php',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
]);
|
||||
const TRIM_PUNCT = /^[\s"'“”‘’«»([{<]+|[\s"'“”‘’«»)\]}>.,;:!?]+$/g;
|
||||
|
||||
/** Every host a sentence names (Gitea #335), e.g. "Log in at chat.lotusguild.org". */
|
||||
export const hostsFromText = (text: string): string[] => {
|
||||
const hosts: string[] = [];
|
||||
text.split(/\s+/).forEach((raw) => {
|
||||
const word = raw.replace(TRIM_PUNCT, '');
|
||||
const host = word && hostFromText(word);
|
||||
if (!host) return;
|
||||
const tld = host.split('.').pop() ?? '';
|
||||
if (!/[a-z]/i.test(tld)) return; // version numbers, IPs-as-words
|
||||
const urlShaped =
|
||||
/^[a-z][a-z0-9+.-]*:\/\//i.test(word) || /^www\./i.test(word) || word.includes('/');
|
||||
if (!urlShaped && host.split('.').length === 2 && FILE_EXTENSIONS.has(tld.toLowerCase()))
|
||||
return;
|
||||
if (!hosts.includes(host)) hosts.push(host);
|
||||
});
|
||||
return hosts;
|
||||
};
|
||||
|
||||
export type LinkSafety = {
|
||||
/** The visible text names a different site than the destination. */
|
||||
mismatch: boolean;
|
||||
@@ -92,8 +213,11 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
|
||||
if (url.hostname === 'matrix.to') return null;
|
||||
const realHost = url.hostname;
|
||||
const punycode = realHost.split('.').some((l) => l.startsWith('xn--'));
|
||||
const shownHost = hostFromText(text) ?? undefined;
|
||||
const mismatch =
|
||||
shownHost !== undefined && registrableDomain(shownHost) !== registrableDomain(realHost);
|
||||
return { mismatch, shownHost, realHost, punycode };
|
||||
// A single URL/host-shaped text, or (Gitea #335) host-shaped words inside a
|
||||
// sentence: "Log in at chat.lotusguild.org" must not hide a different site.
|
||||
const single = hostFromText(text);
|
||||
const shown = single ? [single] : hostsFromText(text);
|
||||
const realSite = registrableDomain(realHost);
|
||||
const other = shown.find((h) => registrableDomain(h) !== realSite);
|
||||
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user