fix(ui): isMacOS always returned false on Macs + plugin-logic tests (+49)
Coverage work found a 3rd real bug: isMacOS() compared os.name against the legacy 'Mac OS' string, but ua-parser-js v2 reports 'macOS' — so it was dead, and Mac users saw "Ctrl + k" instead of "⌘ + k" in the editor toolbar, search, and settings shortcut hints. Now accepts both 'macOS' and 'Mac OS'. Suites (via subagent, verified): via-servers (10 — power/popularity server selection), bad-words (9), syntaxHighlight tokenize (14), plugins/utils getEmoticonSearchStr (5), imageCompression formatFileSize/isCompressible (5), user-agent (6, now asserting the fixed behavior). Full suite now 501 tests, all passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { testBadWords, BAD_WORDS_REGEX } from './bad-words';
|
||||
|
||||
test('testBadWords returns false for clean text', () => {
|
||||
assert.equal(testBadWords('hello world'), false);
|
||||
assert.equal(testBadWords('this is clean text'), false);
|
||||
assert.equal(testBadWords(''), false);
|
||||
});
|
||||
|
||||
test('testBadWords matches custom additions', () => {
|
||||
// 'torture' and 't0rture' are appended in additionalBadWords
|
||||
assert.equal(testBadWords('torture'), true);
|
||||
assert.equal(testBadWords('t0rture'), true);
|
||||
});
|
||||
|
||||
test('testBadWords is case-insensitive', () => {
|
||||
assert.equal(testBadWords('Torture'), true);
|
||||
assert.equal(testBadWords('TORTURE'), true);
|
||||
assert.equal(testBadWords('DamN'), true);
|
||||
});
|
||||
|
||||
test('testBadWords matches words from the base list', () => {
|
||||
assert.equal(testBadWords('damn'), true);
|
||||
assert.equal(testBadWords('hell'), true);
|
||||
assert.equal(testBadWords('crap'), true);
|
||||
});
|
||||
|
||||
test('testBadWords respects word boundaries (no match inside a larger word)', () => {
|
||||
// alphanumeric extension on either side prevents a match
|
||||
assert.equal(testBadWords('tortured'), false);
|
||||
assert.equal(testBadWords('tortures'), false);
|
||||
assert.equal(testBadWords('damning'), false);
|
||||
assert.equal(testBadWords('hello'), false);
|
||||
assert.equal(testBadWords('shell'), false);
|
||||
assert.equal(testBadWords('crappy'), false);
|
||||
});
|
||||
|
||||
test('testBadWords does not match a bad word as a substring of an unrelated word', () => {
|
||||
assert.equal(testBadWords('class'), false);
|
||||
assert.equal(testBadWords('pass'), false);
|
||||
assert.equal(testBadWords('grass'), false);
|
||||
});
|
||||
|
||||
test('underscore acts as a word boundary', () => {
|
||||
// surrounding underscores still allow a match, since `_` is a boundary char
|
||||
assert.equal(testBadWords('word_torture_word'), true);
|
||||
// but an alphanumeric char glued directly to the word still blocks it
|
||||
assert.equal(testBadWords('tor_ture'), false);
|
||||
});
|
||||
|
||||
test('testBadWords matches a bad word embedded in a sentence with spaces', () => {
|
||||
assert.equal(testBadWords('what the hell is this'), true);
|
||||
assert.equal(testBadWords('I think this is fine'), false);
|
||||
});
|
||||
|
||||
test('BAD_WORDS_REGEX is a global regex; reuse it via String.match', () => {
|
||||
// testBadWords lowercases the input before matching
|
||||
assert.ok('torture'.toLowerCase().match(BAD_WORDS_REGEX));
|
||||
assert.equal('innocent'.toLowerCase().match(BAD_WORDS_REGEX), null);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getEmoticonSearchStr } from './utils';
|
||||
import { PackImageReader } from './custom-emoji';
|
||||
import { IEmoji } from './emoji';
|
||||
|
||||
test('getEmoticonSearchStr for a PackImageReader with a body returns shortcode + body', () => {
|
||||
const reader = new PackImageReader('cat', 'mxc://server/cat', { body: 'kitten' });
|
||||
assert.deepEqual(getEmoticonSearchStr(reader), [':cat:', 'kitten']);
|
||||
});
|
||||
|
||||
test('getEmoticonSearchStr for a PackImageReader without a body returns just the shortcode string', () => {
|
||||
const reader = new PackImageReader('cat', 'mxc://server/cat', {});
|
||||
assert.equal(getEmoticonSearchStr(reader), ':cat:');
|
||||
});
|
||||
|
||||
test('getEmoticonSearchStr ignores a non-string body on a PackImageReader', () => {
|
||||
const reader = new PackImageReader('cat', 'mxc://server/cat', {
|
||||
body: 123 as unknown as string,
|
||||
});
|
||||
assert.equal(getEmoticonSearchStr(reader), ':cat:');
|
||||
});
|
||||
|
||||
test('getEmoticonSearchStr for an IEmoji concats shortcode, label and shortcodes', () => {
|
||||
const emoji = {
|
||||
shortcode: 'smile',
|
||||
label: 'Smiling Face',
|
||||
shortcodes: ['smile', 'happy'],
|
||||
} as unknown as IEmoji;
|
||||
assert.deepEqual(getEmoticonSearchStr(emoji), [':smile:', 'Smiling Face', 'smile', 'happy']);
|
||||
});
|
||||
|
||||
test('getEmoticonSearchStr for an IEmoji without a shortcodes array returns shortcode + label', () => {
|
||||
const emoji = {
|
||||
shortcode: 'smile',
|
||||
label: 'Smiling Face',
|
||||
} as unknown as IEmoji;
|
||||
assert.deepEqual(getEmoticonSearchStr(emoji), [':smile:', 'Smiling Face']);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { getViaServers } from './via-servers';
|
||||
|
||||
type StubEvent =
|
||||
| {
|
||||
content: Record<string, unknown>;
|
||||
sender?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
type RoomStub = {
|
||||
members: string[];
|
||||
create?: StubEvent;
|
||||
power?: StubEvent;
|
||||
};
|
||||
|
||||
const makeRoom = (opts: RoomStub): Room => {
|
||||
const stateEvents: Record<string, StubEvent> = {};
|
||||
if (opts.create) stateEvents['m.room.create'] = opts.create;
|
||||
if (opts.power) stateEvents['m.room.power_levels'] = opts.power;
|
||||
|
||||
const mkEvent = (e: StubEvent) =>
|
||||
e
|
||||
? {
|
||||
getContent: () => e.content,
|
||||
getSender: () => e.sender,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
getMembers: () => opts.members.map((userId) => ({ userId })),
|
||||
getLiveTimeline: () => ({
|
||||
getState: () => ({
|
||||
getStateEvents: (type: string) => mkEvent(stateEvents[type]),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Room;
|
||||
};
|
||||
|
||||
test('with no power user it returns the top 3 most-populated servers, descending', () => {
|
||||
const room = makeRoom({
|
||||
// s3 x2, s1 x2, s2 x1, s4 x1 — order between equal counts follows insertion
|
||||
members: ['@a:s1.com', '@b:s1.com', '@c:s2.com', '@d:s3.com', '@e:s3.com', '@f:s4.com'],
|
||||
});
|
||||
assert.deepEqual(getViaServers(room), ['s1.com', 's3.com', 's2.com']);
|
||||
});
|
||||
|
||||
test('the highest-power user server is placed first, ahead of the populous servers', () => {
|
||||
const room = makeRoom({
|
||||
// s3 is most populous (3), but the power user is on s2
|
||||
members: ['@a:s1.com', '@b:s1.com', '@c:s2.com', '@d:s3.com', '@e:s3.com', '@f:s3.com'],
|
||||
power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } },
|
||||
});
|
||||
// power-user server first, then the top populated servers, s2 deduped
|
||||
assert.deepEqual(getViaServers(room), ['s2.com', 's3.com', 's1.com']);
|
||||
});
|
||||
|
||||
test('a power-user server that is also the most populous is deduped, result capped at 3', () => {
|
||||
const room = makeRoom({
|
||||
members: [
|
||||
'@a:s1.com',
|
||||
'@b:s1.com',
|
||||
'@c:s1.com',
|
||||
'@d:s2.com',
|
||||
'@e:s3.com',
|
||||
'@f:s4.com',
|
||||
'@g:s5.com',
|
||||
],
|
||||
power: { content: { users: { '@a:s1.com': 100 }, users_default: 0 } },
|
||||
});
|
||||
// s1 (power + most pop) first, then next 2 of the top-3 populated list
|
||||
assert.deepEqual(getViaServers(room), ['s1.com', 's2.com', 's3.com']);
|
||||
});
|
||||
|
||||
test('picks the user with the strictly highest power level', () => {
|
||||
const room = makeRoom({
|
||||
members: ['@low:s1.com', '@high:s2.com', '@c:s3.com'],
|
||||
power: {
|
||||
content: {
|
||||
users: { '@low:s1.com': 50, '@high:s2.com': 100 },
|
||||
users_default: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(getViaServers(room)[0], 's2.com');
|
||||
});
|
||||
|
||||
test('ignores users whose power is not above users_default', () => {
|
||||
const room = makeRoom({
|
||||
members: ['@a:s1.com', '@b:s2.com'],
|
||||
// @a power equals users_default, so it is not treated as a power user
|
||||
power: { content: { users: { '@a:s1.com': 50 }, users_default: 50 } },
|
||||
});
|
||||
// falls back to populated servers only
|
||||
assert.deepEqual(getViaServers(room), ['s1.com', 's2.com']);
|
||||
});
|
||||
|
||||
test('uses the create-event sender when the room version supports creators', () => {
|
||||
const room = makeRoom({
|
||||
members: ['@a:s1.com', '@b:s2.com', '@c:s2.com'],
|
||||
create: { content: { room_version: '12' }, sender: '@a:s1.com' },
|
||||
// power levels would point elsewhere, but creatorsSupported short-circuits
|
||||
power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } },
|
||||
});
|
||||
assert.equal(getViaServers(room)[0], 's1.com');
|
||||
});
|
||||
|
||||
test('falls back to power levels when the room version does not support creators', () => {
|
||||
const room = makeRoom({
|
||||
members: ['@a:s1.com', '@b:s2.com', '@c:s2.com'],
|
||||
create: { content: { room_version: '11' }, sender: '@a:s1.com' },
|
||||
power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } },
|
||||
});
|
||||
assert.equal(getViaServers(room)[0], 's2.com');
|
||||
});
|
||||
|
||||
test('ignores members with unparseable user ids when counting populations', () => {
|
||||
const room = makeRoom({
|
||||
members: ['@a:s1.com', 'broken-id', '@b:s1.com', '@c:s2.com'],
|
||||
});
|
||||
assert.deepEqual(getViaServers(room), ['s1.com', 's2.com']);
|
||||
});
|
||||
|
||||
test('returns an empty array for a room with no resolvable servers', () => {
|
||||
const room = makeRoom({ members: ['broken-id', 'also-broken'] });
|
||||
assert.deepEqual(getViaServers(room), []);
|
||||
});
|
||||
|
||||
test('result never exceeds three servers', () => {
|
||||
const room = makeRoom({
|
||||
members: ['@a:s1.com', '@b:s2.com', '@c:s3.com', '@d:s4.com', '@e:s5.com'],
|
||||
power: { content: { users: { '@a:s1.com': 100 }, users_default: 0 } },
|
||||
});
|
||||
assert.ok(getViaServers(room).length <= 3);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatFileSize, isCompressible } from './imageCompression';
|
||||
|
||||
test('formatFileSize formats bytes below 1KB as B', () => {
|
||||
assert.equal(formatFileSize(0), '0 B');
|
||||
assert.equal(formatFileSize(1), '1 B');
|
||||
assert.equal(formatFileSize(512), '512 B');
|
||||
assert.equal(formatFileSize(1023), '1023 B');
|
||||
});
|
||||
|
||||
test('formatFileSize formats values in the KB range', () => {
|
||||
assert.equal(formatFileSize(1024), '1.0 KB');
|
||||
assert.equal(formatFileSize(1536), '1.5 KB');
|
||||
// 1024 * 1024 - 1 is still under the MB boundary
|
||||
assert.equal(formatFileSize(1024 * 1024 - 1), '1024.0 KB');
|
||||
});
|
||||
|
||||
test('formatFileSize formats values in the MB range', () => {
|
||||
assert.equal(formatFileSize(1024 * 1024), '1.0 MB');
|
||||
assert.equal(formatFileSize(1024 * 1024 * 1.5), '1.5 MB');
|
||||
assert.equal(formatFileSize(1024 * 1024 * 10), '10.0 MB');
|
||||
});
|
||||
|
||||
test('isCompressible accepts raster image types', () => {
|
||||
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
|
||||
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
|
||||
assert.equal(isCompressible({ type: 'image/gif' } as Blob), true);
|
||||
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
|
||||
});
|
||||
|
||||
test('isCompressible rejects svg, empty type, and non-images', () => {
|
||||
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
|
||||
assert.equal(isCompressible({ type: '' } as Blob), false);
|
||||
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
|
||||
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
|
||||
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tokenize, tokenStyle, SyntaxToken } from './syntaxHighlight';
|
||||
|
||||
const find = (tokens: SyntaxToken[], text: string) => tokens.find((t) => t.text === text);
|
||||
|
||||
test('tokenize falls back to a single plain token for unsupported languages', () => {
|
||||
assert.deepEqual(tokenize('plain text here', 'unknownlang'), [
|
||||
{ text: 'plain text here', type: 'plain' },
|
||||
]);
|
||||
// empty lang is also unsupported
|
||||
assert.deepEqual(tokenize('hello', ''), [{ text: 'hello', type: 'plain' }]);
|
||||
});
|
||||
|
||||
test('tokenize returns an empty array for empty input', () => {
|
||||
assert.deepEqual(tokenize('', 'js'), []);
|
||||
});
|
||||
|
||||
test('tokenize strips a leading "language-" prefix', () => {
|
||||
const tokens = tokenize('const x', 'language-javascript');
|
||||
assert.equal(find(tokens, 'const')?.type, 'kw');
|
||||
});
|
||||
|
||||
test('tokenize classifies keywords, numbers, plain words and punctuation', () => {
|
||||
const tokens = tokenize('const x = 5;', 'js');
|
||||
assert.deepEqual(tokens, [
|
||||
{ text: 'const', type: 'kw' },
|
||||
{ text: ' ', type: 'plain' },
|
||||
{ text: 'x', type: 'plain' },
|
||||
{ text: ' = ', type: 'plain' },
|
||||
{ text: '5', type: 'num' },
|
||||
{ text: ';', type: 'plain' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('tokenize detects function-call identifiers by a following paren', () => {
|
||||
const tokens = tokenize('foo(1)', 'js');
|
||||
assert.equal(find(tokens, 'foo')?.type, 'fn');
|
||||
assert.equal(find(tokens, '1')?.type, 'num');
|
||||
});
|
||||
|
||||
test('tokenize handles line comments up to the newline', () => {
|
||||
const tokens = tokenize('// hi\ncode', 'js');
|
||||
assert.deepEqual(tokens[0], { text: '// hi', type: 'cmt' });
|
||||
// the newline and the rest are plain
|
||||
assert.equal(find(tokens, 'code')?.type, 'plain');
|
||||
});
|
||||
|
||||
test('tokenize handles block comments including the closing delimiter', () => {
|
||||
const tokens = tokenize('/* block */x', 'js');
|
||||
assert.deepEqual(tokens[0], { text: '/* block */', type: 'cmt' });
|
||||
assert.equal(find(tokens, 'x')?.type, 'plain');
|
||||
});
|
||||
|
||||
test('tokenize captures string literals including escaped quotes', () => {
|
||||
assert.deepEqual(tokenize('"str"', 'js'), [{ text: '"str"', type: 'str' }]);
|
||||
assert.deepEqual(tokenize("'a\\'b'", 'js'), [{ text: "'a\\'b'", type: 'str' }]);
|
||||
});
|
||||
|
||||
test('tokenize treats # as a comment only in python', () => {
|
||||
// python: # after a space starts a comment
|
||||
const py = tokenize('a # not comment', 'python');
|
||||
assert.equal(
|
||||
py.some((t) => t.type === 'cmt' && t.text === '# not comment'),
|
||||
true,
|
||||
);
|
||||
// js: # is not a comment marker
|
||||
const js = tokenize('a # b', 'js');
|
||||
assert.equal(
|
||||
js.some((t) => t.type === 'cmt'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('tokenize uses the python keyword set for python', () => {
|
||||
const tokens = tokenize('def foo():', 'python');
|
||||
assert.equal(find(tokens, 'def')?.type, 'kw');
|
||||
assert.equal(find(tokens, 'foo')?.type, 'fn');
|
||||
});
|
||||
|
||||
test('tokenize uses the rust keyword set for rust', () => {
|
||||
const tokens = tokenize('fn main() {}', 'rust');
|
||||
assert.equal(find(tokens, 'fn')?.type, 'kw');
|
||||
assert.equal(find(tokens, 'main')?.type, 'fn');
|
||||
});
|
||||
|
||||
test('tokenize keeps a plain word that is a keyword in another language plain', () => {
|
||||
// "def" is a python keyword but not a js keyword
|
||||
const tokens = tokenize('def x', 'js');
|
||||
assert.equal(find(tokens, 'def')?.type, 'plain');
|
||||
});
|
||||
|
||||
test('tokenize re-concatenates to the original source', () => {
|
||||
const samples: Array<[string, string]> = [
|
||||
['const x = foo(42); // done', 'js'],
|
||||
['def add(a, b):\n return a + b # sum', 'python'],
|
||||
['let mut v = vec![1, 2, 3];', 'rust'],
|
||||
];
|
||||
for (const [code, lang] of samples) {
|
||||
assert.equal(
|
||||
tokenize(code, lang)
|
||||
.map((t) => t.text)
|
||||
.join(''),
|
||||
code,
|
||||
`roundtrip for ${lang}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('tokenStyle returns distinct styles per token kind', () => {
|
||||
assert.deepEqual(tokenStyle('kw'), { color: 'var(--prism-keyword)' });
|
||||
assert.deepEqual(tokenStyle('str'), { color: 'var(--prism-selector)' });
|
||||
assert.deepEqual(tokenStyle('num'), { color: 'var(--prism-boolean)' });
|
||||
assert.deepEqual(tokenStyle('cmt'), { color: 'var(--prism-comment)', fontStyle: 'italic' });
|
||||
assert.deepEqual(tokenStyle('fn'), { color: 'var(--prism-atrule)' });
|
||||
assert.deepEqual(tokenStyle('plain'), {});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isMacOS, mobileOrTablet } from './user-agent';
|
||||
|
||||
// `ua()` reads `window.navigator.userAgent` lazily on every call, so we can swap
|
||||
// the global mock per test case and restore it afterwards.
|
||||
const UA = {
|
||||
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
windows:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
linux:
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
iphone:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
ipad: 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
android:
|
||||
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as { window?: unknown };
|
||||
const originalWindow = g.window;
|
||||
|
||||
const setUserAgent = (userAgent: string) => {
|
||||
g.window = { navigator: { userAgent } };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
g.window = originalWindow;
|
||||
});
|
||||
|
||||
test('isMacOS returns true for a Mac user agent (handles both macOS and legacy Mac OS)', () => {
|
||||
setUserAgent(UA.mac);
|
||||
assert.equal(isMacOS(), true);
|
||||
});
|
||||
|
||||
test('isMacOS returns false for non-Mac user agents', () => {
|
||||
setUserAgent(UA.windows);
|
||||
assert.equal(isMacOS(), false);
|
||||
setUserAgent(UA.linux);
|
||||
assert.equal(isMacOS(), false);
|
||||
setUserAgent(UA.android);
|
||||
assert.equal(isMacOS(), false);
|
||||
});
|
||||
|
||||
test('mobileOrTablet is true for an iPhone', () => {
|
||||
setUserAgent(UA.iphone);
|
||||
assert.equal(mobileOrTablet(), true);
|
||||
});
|
||||
|
||||
test('mobileOrTablet is true for an iPad', () => {
|
||||
setUserAgent(UA.ipad);
|
||||
assert.equal(mobileOrTablet(), true);
|
||||
});
|
||||
|
||||
test('mobileOrTablet is true for an Android device', () => {
|
||||
setUserAgent(UA.android);
|
||||
assert.equal(mobileOrTablet(), true);
|
||||
});
|
||||
|
||||
test('mobileOrTablet is false for desktop user agents', () => {
|
||||
setUserAgent(UA.mac);
|
||||
assert.equal(mobileOrTablet(), false);
|
||||
setUserAgent(UA.windows);
|
||||
assert.equal(mobileOrTablet(), false);
|
||||
setUserAgent(UA.linux);
|
||||
assert.equal(mobileOrTablet(), false);
|
||||
});
|
||||
@@ -2,7 +2,12 @@ import { UAParser } from 'ua-parser-js';
|
||||
|
||||
export const ua = () => UAParser(window.navigator.userAgent);
|
||||
|
||||
export const isMacOS = () => ua().os.name === 'Mac OS';
|
||||
// ua-parser-js reports macOS as 'macOS' (v2+); older versions used 'Mac OS'.
|
||||
// Accept both so the ⌘-vs-Ctrl shortcut hints render correctly on real Macs.
|
||||
export const isMacOS = () => {
|
||||
const name = ua().os.name;
|
||||
return name === 'macOS' || name === 'Mac OS';
|
||||
};
|
||||
|
||||
export const mobileOrTablet = (): boolean => {
|
||||
const userAgent = ua();
|
||||
|
||||
Reference in New Issue
Block a user