Compare commits
3 Commits
3bf1bfd1be
...
4af07109c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4af07109c3 | |||
| 69249d1746 | |||
| c72cd7fef3 |
+1
Submodule .claude/worktrees/agent-a4cb7be95d74ab7e7 added at f7c39e20a9
+1
Submodule .claude/worktrees/agent-aaee4049508b9f175 added at f7c39e20a9
+1
Submodule .claude/worktrees/agent-ac5c7e09bae2b0939 added at f7c39e20a9
@@ -1,206 +0,0 @@
|
|||||||
import React, {
|
|
||||||
ChangeEventHandler,
|
|
||||||
KeyboardEventHandler,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { Avatar, Box, Icon, Icons, Input, Text, config } from 'folds';
|
|
||||||
import { Room } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
||||||
import { allRoomsAtom } from '../state/room-list/roomList';
|
|
||||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
|
||||||
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
|
||||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
|
|
||||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
|
||||||
import { nameInitials } from '../utils/common';
|
|
||||||
|
|
||||||
const MAX_RESULTS = 10;
|
|
||||||
|
|
||||||
function RoomFallback({ room }: { room: Room }) {
|
|
||||||
return (
|
|
||||||
<Text as="span" size="H6">
|
|
||||||
{nameInitials(room.name)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuickSwitcherProps = {
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function QuickSwitcher({ onClose }: QuickSwitcherProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const allRoomIds = useAtomValue(allRoomsAtom);
|
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
const { navigateRoom } = useRoomNavigate();
|
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredRooms = query.trim()
|
|
||||||
? allRoomIds
|
|
||||||
.map((id) => ({ id, room: mx.getRoom(id) }))
|
|
||||||
.filter(({ room }) => room && room.name.toLowerCase().includes(query.trim().toLowerCase()))
|
|
||||||
.slice(0, MAX_RESULTS)
|
|
||||||
: allRoomIds
|
|
||||||
.map((id) => ({ id, room: mx.getRoom(id) }))
|
|
||||||
.filter(({ room }) => !!room)
|
|
||||||
.slice(0, MAX_RESULTS);
|
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
|
||||||
setQuery(evt.currentTarget.value);
|
|
||||||
setSelectedIdx(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigateToRoom = useCallback(
|
|
||||||
(roomId: string) => {
|
|
||||||
navigateRoom(roomId);
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
[navigateRoom, onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
|
|
||||||
(evt) => {
|
|
||||||
if (evt.key === 'ArrowDown') {
|
|
||||||
evt.preventDefault();
|
|
||||||
setSelectedIdx((i) => Math.min(i + 1, filteredRooms.length - 1));
|
|
||||||
} else if (evt.key === 'ArrowUp') {
|
|
||||||
evt.preventDefault();
|
|
||||||
setSelectedIdx((i) => Math.max(i - 1, 0));
|
|
||||||
} else if (evt.key === 'Enter') {
|
|
||||||
const item = filteredRooms[selectedIdx];
|
|
||||||
if (item) navigateToRoom(item.id);
|
|
||||||
} else if (evt.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[filteredRooms, selectedIdx, navigateToRoom, onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
role="presentation"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0,0,0,0.5)',
|
|
||||||
zIndex: 9899,
|
|
||||||
}}
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Quick Room Switcher"
|
|
||||||
aria-modal="true"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: '20%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
width: 'min(560px, 90vw)',
|
|
||||||
zIndex: 9900,
|
|
||||||
background: 'var(--bg-surface)',
|
|
||||||
borderRadius: config.radii.R400,
|
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Search input */}
|
|
||||||
<Box style={{ padding: config.space.S300 }}>
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
value={query}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
before={<Icon size="200" src={Icons.Search} />}
|
|
||||||
placeholder="Search rooms…"
|
|
||||||
size="500"
|
|
||||||
variant="Background"
|
|
||||||
outlined
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Results list */}
|
|
||||||
{filteredRooms.length > 0 && (
|
|
||||||
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
|
||||||
{filteredRooms.map(({ id, room }, idx) => {
|
|
||||||
if (!room) return null;
|
|
||||||
const dm = mDirects.has(id);
|
|
||||||
const avatarUrl = dm
|
|
||||||
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
|
|
||||||
: getRoomAvatarUrl(mx, room, 32, useAuthentication);
|
|
||||||
const isSelected = idx === selectedIdx;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
aria-selected={isSelected}
|
|
||||||
onClick={() => navigateToRoom(id)}
|
|
||||||
onMouseEnter={() => setSelectedIdx(idx)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: config.space.S200,
|
|
||||||
padding: `${config.space.S200} ${config.space.S300}`,
|
|
||||||
background: isSelected
|
|
||||||
? 'var(--bg-surface-hover, rgba(255,255,255,0.08))'
|
|
||||||
: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'left',
|
|
||||||
color: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar size="200" radii={dm ? '400' : '300'}>
|
|
||||||
{dm || room.isSpaceRoom() ? (
|
|
||||||
<RoomAvatar
|
|
||||||
roomId={id}
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={room.name}
|
|
||||||
renderFallback={() => <RoomFallback room={room} />}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<RoomIcon
|
|
||||||
size="200"
|
|
||||||
joinRule={room.getJoinRule()}
|
|
||||||
roomType={room.getType()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Avatar>
|
|
||||||
<Text truncate size="T400">
|
|
||||||
{room.name}
|
|
||||||
</Text>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredRooms.length === 0 && (
|
|
||||||
<Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S500 }}>
|
|
||||||
<Text size="T300" align="Center">
|
|
||||||
{query.trim() ? `No rooms matching "${query}"` : 'No rooms'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -68,11 +68,16 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const [linkCopied, setLinkCopied] = useState(false);
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
const [showQr, setShowQr] = useState(false);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const inviteUrl = (() => {
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||||
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
return getMatrixToRoom(roomIdOrAlias, viaServers);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
copyToClipboard(inviteUrl);
|
||||||
setLinkCopied(true);
|
setLinkCopied(true);
|
||||||
setTimeout(() => setLinkCopied(false), 2000);
|
setTimeout(() => setLinkCopied(false), 2000);
|
||||||
};
|
};
|
||||||
@@ -202,11 +207,47 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
>
|
>
|
||||||
<Text size="B300">{linkCopied ? 'Copied!' : 'Copy Link'}</Text>
|
<Text size="B300">{linkCopied ? 'Copied!' : 'Copy Link'}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant={showQr ? 'Primary' : 'Surface'}
|
||||||
|
fill={showQr ? 'Soft' : 'None'}
|
||||||
|
aria-label="Show QR code"
|
||||||
|
aria-pressed={showQr}
|
||||||
|
onClick={() => setShowQr((v) => !v)}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.BlockCode} size="100" />
|
||||||
|
</IconButton>
|
||||||
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
|
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
</Header>
|
||||||
|
{showQr && (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderBottom: `1px solid var(--bg-surface-border)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(inviteUrl)}`}
|
||||||
|
alt="QR code for room invite link"
|
||||||
|
width={180}
|
||||||
|
height={180}
|
||||||
|
style={{ display: 'block', borderRadius: config.radii.R300 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{ opacity: 0.6, wordBreak: 'break-all', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{inviteUrl}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box
|
<Box
|
||||||
as="form"
|
as="form"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@@ -211,36 +211,65 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
>
|
>
|
||||||
{events.map((mEvent) => {
|
{events.map((mEvent) => {
|
||||||
const content = mEvent.getContent();
|
const content = mEvent.getContent();
|
||||||
|
const isEncrypted = !!content.file;
|
||||||
const mxcUrl: string | undefined = content.url ?? content.file?.url;
|
const mxcUrl: string | undefined = content.url ?? content.file?.url;
|
||||||
if (!mxcUrl) return null;
|
if (!mxcUrl) return null;
|
||||||
const thumbUrl =
|
|
||||||
mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? '';
|
|
||||||
const body: string = content.body ?? '';
|
const body: string = content.body ?? '';
|
||||||
|
// Use unauthenticated thumbnail URL — the v1 authenticated endpoint adds
|
||||||
|
// allow_redirect=true which Synapse rejects with 400.
|
||||||
|
const thumbUrl = isEncrypted
|
||||||
|
? null
|
||||||
|
: (mxcUrlToHttp(mx, mxcUrl, false, 120, 120, 'crop') ?? null);
|
||||||
|
const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#';
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
href={mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#'}
|
href={isEncrypted ? '#' : fullUrl}
|
||||||
target="_blank"
|
target={isEncrypted ? undefined : '_blank'}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
aspectRatio: '1',
|
aspectRatio: '1',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
background: 'var(--bg-surface)',
|
background: 'var(--bg-surface-low)',
|
||||||
|
cursor: isEncrypted ? 'default' : 'pointer',
|
||||||
}}
|
}}
|
||||||
title={body}
|
title={body}
|
||||||
>
|
>
|
||||||
<img
|
{thumbUrl ? (
|
||||||
src={thumbUrl}
|
<img
|
||||||
alt={body}
|
src={thumbUrl}
|
||||||
style={{
|
alt={body}
|
||||||
width: '100%',
|
onError={(e) => {
|
||||||
height: '100%',
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
objectFit: 'cover',
|
}}
|
||||||
display: 'block',
|
style={{
|
||||||
}}
|
width: '100%',
|
||||||
/>
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
style={{ padding: config.space.S100 }}
|
||||||
|
>
|
||||||
|
<Icon src={isEncrypted ? Icons.Lock : Icons.Photo} size="400" />
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
truncate
|
||||||
|
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
{body || (isEncrypted ? 'Encrypted' : 'Image')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1316,43 +1316,6 @@ function Messages() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyboardShortcuts() {
|
|
||||||
const [quickSwitcherKey, setQuickSwitcherKey] = useSetting(settingsAtom, 'quickSwitcherKey');
|
|
||||||
const qsBind = useKeyBind(setQuickSwitcherKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Keyboard Shortcuts</Text>
|
|
||||||
<SequenceCard
|
|
||||||
className={SequenceCardStyle}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
direction="Column"
|
|
||||||
gap="400"
|
|
||||||
>
|
|
||||||
<SettingTile
|
|
||||||
title="Quick Room Switcher"
|
|
||||||
description="Open the quick room switcher overlay. Ctrl/Cmd + this key."
|
|
||||||
after={
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant={qsBind.listening ? 'Warning' : 'Secondary'}
|
|
||||||
fill={qsBind.listening ? 'Solid' : 'Soft'}
|
|
||||||
radii="300"
|
|
||||||
outlined
|
|
||||||
onClick={qsBind.startListening}
|
|
||||||
style={{ minWidth: '90px' }}
|
|
||||||
>
|
|
||||||
<Text size="B300">
|
|
||||||
{qsBind.listening ? 'Press a key…' : `Ctrl+${keyLabel(quickSwitcherKey)}`}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SequenceCard>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeneralProps = {
|
type GeneralProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -1383,7 +1346,6 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<Messages />
|
<Messages />
|
||||||
<Privacy />
|
<Privacy />
|
||||||
<Calls />
|
<Calls />
|
||||||
<KeyboardShortcuts />
|
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
|||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||||
import { QuickSwitcher } from '../../components/QuickSwitcher';
|
|
||||||
|
|
||||||
function SystemEmojiFeature() {
|
function SystemEmojiFeature() {
|
||||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||||
@@ -262,27 +261,6 @@ function MessageNotifications() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickSwitcherFeature() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [quickSwitcherKey] = useSetting(settingsAtom, 'quickSwitcherKey');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === quickSwitcherKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen((prev) => !prev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [quickSwitcherKey]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
return <QuickSwitcher onClose={() => setOpen(false)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ClientNonUIFeaturesProps = {
|
type ClientNonUIFeaturesProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -296,7 +274,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<PresenceUpdater />
|
<PresenceUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<QuickSwitcherFeature />
|
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export interface Settings {
|
|||||||
nightLightOpacity: number;
|
nightLightOpacity: number;
|
||||||
|
|
||||||
deafenKey: string;
|
deafenKey: string;
|
||||||
quickSwitcherKey: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -134,7 +133,6 @@ const defaultSettings: Settings = {
|
|||||||
nightLightOpacity: 30,
|
nightLightOpacity: 30,
|
||||||
|
|
||||||
deafenKey: 'KeyM',
|
deafenKey: 'KeyM',
|
||||||
quickSwitcherKey: 'KeyP',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = (): Settings => {
|
export const getSettings = (): Settings => {
|
||||||
|
|||||||
Reference in New Issue
Block a user