Compare commits
17
Commits
@@ -0,0 +1,23 @@
|
|||||||
|
# On a merge, .gitmodules keeps the answer this branch already has.
|
||||||
|
#
|
||||||
|
# master names the daemon at github.com because master is what a stranger
|
||||||
|
# clones, and development names it at the Gitea instance where the work
|
||||||
|
# happens. Without this, a merge that touched .gitmodules on both sides would
|
||||||
|
# stop and ask which host is right - and the answer is always "the one the
|
||||||
|
# branch being merged into already said".
|
||||||
|
#
|
||||||
|
# `merge=ours` needs a driver by that name, which is one line per clone and
|
||||||
|
# not something a repository can carry for you:
|
||||||
|
#
|
||||||
|
# git config merge.ours.driver true
|
||||||
|
#
|
||||||
|
# Without it git falls back to an ordinary merge, which conflicts rather than
|
||||||
|
# doing the wrong thing quietly - so a clone that has not run it is no worse
|
||||||
|
# off than before this file existed.
|
||||||
|
#
|
||||||
|
# The edge, because it is a real one: this keeps the *whole file*, not just the
|
||||||
|
# URL. If development ever adds a second submodule, master takes none of that
|
||||||
|
# entry while still taking the gitlink the merge brings with it - a submodule
|
||||||
|
# with nothing in .gitmodules to say where it comes from. Adding a submodule is
|
||||||
|
# therefore a thing to re-do by hand on master, and the only such thing.
|
||||||
|
/.gitmodules merge=ours
|
||||||
@@ -17,6 +17,34 @@ Each section has two parts:
|
|||||||
that made it, including dependency updates
|
that made it, including dependency updates
|
||||||
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
|
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
|
||||||
|
|
||||||
|
## [1.3.0]
|
||||||
|
|
||||||
|
### Major features
|
||||||
|
|
||||||
|
- **Telegram, ported:** your chats, groups, channels and bots in the same window as everything else. Sign in with your phone number, the code Telegram sends and your two-step password if you have one, using your own API id and hash from my.telegram.org (one is enough for every client you use); the session is kept, so a restart does not sign in again.
|
||||||
|
- **Telegram conversations:** private chats and bots as direct messages, groups and supergroups on the server rail like Discord's servers, channels with their posts, and a group's forum topics as conversations of their own. A horn marks a broadcast channel and a robot a bot. History loads as you scroll, new messages, edits and deletions arrive live, and messages you send, edit and delete go out the same way.
|
||||||
|
- **Telegram messages:** formatting, replies (with the quote shown above and a click to the original, even in another channel), forwards with a link back to where they came from, pictures, videos, files and voice messages drawn as they arrive and fetched when played, link cards, and Instant View articles opened in a window of their own.
|
||||||
|
- **Telegram on top of the chat:** reactions, pinned messages, polls (voting and making them), search, comments and thread counts on channel posts, a member list with moderation where you have the rights, muting, joining by link, and the GIF picker with your saved GIFs and Telegram's own search.
|
||||||
|
- **Telegram bots:** a bot's inline buttons are drawn under its message and pressed from the window, and the Menu button opens the bot's own command list.
|
||||||
|
- **Discord stays signed in:** a restart within a couple of minutes resumes the session Discord is already holding instead of logging in again, only the conversations that have something new are asked about, and a limit on fresh logins (3 an hour, 8 a day) holds the connection and says why, with a Connect anyway button when you mean it.
|
||||||
|
- **One video player on every service:** play, seek, a volume slider that opens on hover, elapsed or remaining time, speed, full screen, save, and edit on your own messages.
|
||||||
|
|
||||||
|
### Fixes, patches and changes
|
||||||
|
|
||||||
|
- Select text in a message or in the message box, right-click it and search the web for it, in your default browser, with the engine chosen in Settings > General: DuckDuckGo, Google, Bing, Brave Search, Startpage, Ecosia, Qwant, Yandex, Kagi, or an address of your own ([moho `4233439`](https://github.com/Moho-Chat/moho/commit/4233439))
|
||||||
|
- Formatting in the message box can be taken off again with the same button or key, over a whole run, part of one or a mixed selection, and undo and redo step over each change and leave the selection where it was ([moho `496d18f`](https://github.com/Moho-Chat/moho/commit/496d18f))
|
||||||
|
- Discord sends this install's id, language and timezone with its requests, as its own clients do; it still says it is moho ([moho `3a6c8da`](https://github.com/Moho-Chat/moho/commit/3a6c8da), [nobilis `3bf68e7`](https://github.com/Moho-Chat/nobilis/commit/3bf68e7))
|
||||||
|
- Discord resumes its session after a restart, asks only about conversations that moved on, posts typing at most every eight seconds and does not repeat a read mark ([moho `ab8f40f`](https://github.com/Moho-Chat/moho/commit/ab8f40f), [nobilis `e8b25d9`](https://github.com/Moho-Chat/nobilis/commit/e8b25d9))
|
||||||
|
- Discord limits fresh logins per account, holds the connection when it is over, and lets you through with Connect anyway ([moho `59f978d`](https://github.com/Moho-Chat/moho/commit/59f978d), [nobilis `8a7d733`](https://github.com/Moho-Chat/nobilis/commit/8a7d733), [moho `8f62f44`](https://github.com/Moho-Chat/moho/commit/8f62f44), [nobilis `58bf5ac`](https://github.com/Moho-Chat/nobilis/commit/58bf5ac))
|
||||||
|
- Discord's membership-screening dialog is drawn as Discord draws it, with the rules, the questions and the answers sent ([moho `23afcb6`](https://github.com/Moho-Chat/moho/commit/23afcb6), [nobilis `63093e1`](https://github.com/Moho-Chat/nobilis/commit/63093e1))
|
||||||
|
- Discord messages made of layout components (containers, sections, text, pictures) are drawn as cards with their buttons ([moho `c64812c`](https://github.com/Moho-Chat/moho/commit/c64812c), [nobilis `41c2780`](https://github.com/Moho-Chat/nobilis/commit/41c2780))
|
||||||
|
- Discord's APP mark on bots and applications, a check on verified ones, and OFFICIAL on the platform's own account and its messages; badges sit between the name and the time ([moho `2f4c204`](https://github.com/Moho-Chat/moho/commit/2f4c204), [nobilis `dba7731`](https://github.com/Moho-Chat/nobilis/commit/dba7731), [moho `510627f`](https://github.com/Moho-Chat/moho/commit/510627f), [nobilis `5501675`](https://github.com/Moho-Chat/nobilis/commit/5501675), [moho `5d2d0fe`](https://github.com/Moho-Chat/moho/commit/5d2d0fe))
|
||||||
|
- Discord: an emoji picked from another server's section goes in as its token and not the number that names it, and the hover toolbar and recent list no longer show bare numbers ([moho `9a86141`](https://github.com/Moho-Chat/moho/commit/9a86141), [moho `f1bf63a`](https://github.com/Moho-Chat/moho/commit/f1bf63a))
|
||||||
|
- Discord reads a backslash before punctuation as an escape in messages, link previews and embed field values ([moho `eb549f7`](https://github.com/Moho-Chat/moho/commit/eb549f7), [moho `a2a8949`](https://github.com/Moho-Chat/moho/commit/a2a8949))
|
||||||
|
- Videos go full screen by themselves on every service, filling the screen, with no second expand button and no viewer ([moho `bf5c33d`](https://github.com/Moho-Chat/moho/commit/bf5c33d), [moho `1387907`](https://github.com/Moho-Chat/moho/commit/1387907), [moho `aab10c7`](https://github.com/Moho-Chat/moho/commit/aab10c7), [moho `72d5b83`](https://github.com/Moho-Chat/moho/commit/72d5b83))
|
||||||
|
- A left click on the tray icon puts the window away when it is showing and brings it back when it is not ([moho `4ebabb2`](https://github.com/Moho-Chat/moho/commit/4ebabb2))
|
||||||
|
- The GIF picker is offered only where a GIF link shows as a picture: not on Steam, Twitch, Sneedchat or IRC ([moho `40505af`](https://github.com/Moho-Chat/moho/commit/40505af), [moho `7a2fda0`](https://github.com/Moho-Chat/moho/commit/7a2fda0))
|
||||||
|
|
||||||
## [1.2.1]
|
## [1.2.1]
|
||||||
|
|
||||||
### Fixes, patches and changes
|
### Fixes, patches and changes
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Maintainer: Salastil <[email protected]>
|
|
||||||
pkgname=moho-git
|
pkgname=moho-git
|
||||||
pkgver=r1.268751e
|
pkgver=r1.268751e
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unified desktop chat client for IRC, Discord, Sneedchat and Matrix"
|
pkgdesc="Unified desktop chat client for IRC, Discord, Matrix, Kick, Sneedchat and Steam"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Moho-Chat/moho"
|
url="https://github.com/Moho-Chat/moho"
|
||||||
license=('GPL3')
|
license=('GPL3')
|
||||||
|
|||||||
+1
-1
Submodule nobilis updated: ad597ea2d2...264ee7a1f5
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "moho",
|
"name": "moho",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "moho",
|
"name": "moho",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moho",
|
"name": "moho",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
|
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
|
||||||
"author": "Salastil <[email protected]>",
|
"author": "Salastil <[email protected]>",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Formatting in the message box, in a scratch moho with injected state (nothing is contacted): a format applied
|
||||||
|
* to a selection can be taken off again - by the keyboard shortcut or the strip's button, over the whole run,
|
||||||
|
* over part of it, or over a selection reaching past it - and each step can be undone with the box's own undo.
|
||||||
|
*
|
||||||
|
* node scripts/check-composer-format.mjs
|
||||||
|
*/
|
||||||
|
import { launchApp, sleep } from './lib/harness.mjs'
|
||||||
|
|
||||||
|
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||||
|
const ev = (code) => page.evaluate(code)
|
||||||
|
const results = []
|
||||||
|
const check = (what, ok, detail = '') => {
|
||||||
|
results.push(ok)
|
||||||
|
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
|
||||||
|
}
|
||||||
|
const BOX = '.composer-input'
|
||||||
|
const html = () => ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML.replace(/ data-[a-z-]+="[^"]*"/g, (m) => m.startsWith(' data-fmt') ? m : '')`)
|
||||||
|
/** The text the box would send, with the markers of what is bold, italic, and so on in it. */
|
||||||
|
const marked = () =>
|
||||||
|
ev(`(() => {
|
||||||
|
const mark = { bold: '**', italic: '_', underline: '__', strike: '~~', code: '\`', spoiler: '||', quote: '> ' }
|
||||||
|
const walk = (n) => n.nodeType === 3 ? n.nodeValue : n instanceof HTMLElement && n.dataset.fmt ? (mark[n.dataset.fmt] ?? '') + [...n.childNodes].map(walk).join('') + (n.dataset.fmt === 'quote' ? '' : mark[n.dataset.fmt] ?? '') : [...n.childNodes].map(walk).join('')
|
||||||
|
return walk(document.querySelector(${JSON.stringify(BOX)}))
|
||||||
|
})()`)
|
||||||
|
const type = async (text) => {
|
||||||
|
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); b.innerHTML = '' })()`)
|
||||||
|
await page.call('Input.insertText', { text })
|
||||||
|
}
|
||||||
|
const select = (needle, nth = 0) =>
|
||||||
|
ev(`(() => {
|
||||||
|
const box = document.querySelector(${JSON.stringify(BOX)})
|
||||||
|
const text = []
|
||||||
|
const w = document.createTreeWalker(box, NodeFilter.SHOW_TEXT)
|
||||||
|
for (let n; (n = w.nextNode()); ) text.push(n)
|
||||||
|
const all = text.map((t) => t.nodeValue).join('')
|
||||||
|
let at = -1
|
||||||
|
for (let i = 0; i <= ${nth}; i++) at = all.indexOf(${JSON.stringify(needle)}, at + 1)
|
||||||
|
if (at < 0) return false
|
||||||
|
const locate = (offset) => { let seen = 0; for (const t of text) { if (offset <= seen + t.nodeValue.length) return [t, offset - seen]; seen += t.nodeValue.length } }
|
||||||
|
const [a, ao] = locate(at)
|
||||||
|
const [b, bo] = locate(at + ${JSON.stringify(needle)}.length)
|
||||||
|
const r = document.createRange()
|
||||||
|
r.setStart(a, ao); r.setEnd(b, bo)
|
||||||
|
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
|
||||||
|
box.focus()
|
||||||
|
return true
|
||||||
|
})()`)
|
||||||
|
const key = (k, { ctrl = false, commands } = {}) =>
|
||||||
|
page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: 'Key' + k.toUpperCase(), windowsVirtualKeyCode: k.toUpperCase().charCodeAt(0), modifiers: ctrl ? 2 : 0, commands })
|
||||||
|
const bold = () => key('b', { ctrl: true })
|
||||||
|
const undo = () => key('z', { ctrl: true, commands: ['undo'] })
|
||||||
|
const redo = () => key('z', { ctrl: true, commands: ['redo'] })
|
||||||
|
const strip = async (title) => {
|
||||||
|
await sleep(150)
|
||||||
|
return ev(`(() => { const b = [...document.querySelectorAll('.format-bar button')].find((x) => x.title === ${JSON.stringify(title)}); if (!b) return false; b.click(); return true })()`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(2500)
|
||||||
|
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||||
|
await ev(`(() => {
|
||||||
|
const s = window.__mohoShots.store
|
||||||
|
const acct = { id: 'discord:me', service: 'discord', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
|
||||||
|
const buf = { id: 'discord:me|chat', accountId: 'discord:me', kind: 'channel', name: 'chat', lastActivityTs: 5, category: 'Channels', groupId: 'account:discord:me' }
|
||||||
|
s.set({ accounts: [acct], groups: [{ id: 'account:discord:me', accountId: 'discord:me', service: 'discord', kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { 'discord:me|chat': [] }, activeBufferId: 'discord:me|chat' })
|
||||||
|
s.selectGroup('account:discord:me')
|
||||||
|
})()`)
|
||||||
|
await sleep(1200)
|
||||||
|
|
||||||
|
console.log('Applying and taking off')
|
||||||
|
await type('one two three four')
|
||||||
|
await select('two')
|
||||||
|
await bold()
|
||||||
|
check('bold goes on', (await marked()) === 'one **two** three four', await marked())
|
||||||
|
await bold()
|
||||||
|
check('the same key again takes it off', (await marked()) === 'one two three four', await marked())
|
||||||
|
await bold()
|
||||||
|
check('and again puts it back', (await marked()) === 'one **two** three four', await marked())
|
||||||
|
|
||||||
|
console.log('Undo')
|
||||||
|
await undo()
|
||||||
|
check('undo takes the bold back off', (await marked()) === 'one two three four', await marked())
|
||||||
|
await redo()
|
||||||
|
check('redo puts it on again', (await marked()) === 'one **two** three four', await marked())
|
||||||
|
check('redo leaves the word selected, not the whole message', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||||
|
await bold()
|
||||||
|
await undo()
|
||||||
|
check('undo puts it back after it was taken off', (await marked()) === 'one **two** three four', await marked())
|
||||||
|
check('and leaves the word selected', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||||
|
|
||||||
|
console.log('Undo leaves a selection that is safe to type over')
|
||||||
|
await type('one two three four')
|
||||||
|
await select('two')
|
||||||
|
await bold()
|
||||||
|
await undo()
|
||||||
|
check('after undo the word is selected, not the whole message', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||||
|
|
||||||
|
console.log('The ends of the box')
|
||||||
|
await type('one two')
|
||||||
|
await select('one')
|
||||||
|
await bold()
|
||||||
|
check('the first word goes bold', (await marked()) === '**one** two', await marked())
|
||||||
|
await bold()
|
||||||
|
check('and comes off', (await marked()) === 'one two', await marked())
|
||||||
|
await select('two')
|
||||||
|
await bold()
|
||||||
|
check('the last word goes bold', (await marked()) === 'one **two**', await marked())
|
||||||
|
await bold()
|
||||||
|
check('and comes off', (await marked()) === 'one two', await marked())
|
||||||
|
|
||||||
|
console.log('Other formats')
|
||||||
|
await type('one two three')
|
||||||
|
await select('two')
|
||||||
|
await key('i', { ctrl: true })
|
||||||
|
check('italic goes on', (await marked()) === 'one _two_ three', await marked())
|
||||||
|
await select('one two thr')
|
||||||
|
await bold()
|
||||||
|
check('bold over italic keeps both', (await marked()) === '**one _two_ thr**ee', await marked())
|
||||||
|
await key('i', { ctrl: true })
|
||||||
|
check('italic over text that is only partly italic makes all of it italic', (await marked()) === '**_one two thr_**ee', await marked())
|
||||||
|
await key('i', { ctrl: true })
|
||||||
|
check('and again takes italic off all of it, leaving the bold', (await marked()) === '**one two thr**ee', await marked())
|
||||||
|
|
||||||
|
console.log('Lines')
|
||||||
|
await type('first line')
|
||||||
|
await page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, modifiers: 8 })
|
||||||
|
await page.call('Input.insertText', { text: 'second line' })
|
||||||
|
if (!(await select('line\nsecond'))) await select('linesecond')
|
||||||
|
await bold()
|
||||||
|
const lines = await ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML`)
|
||||||
|
check('a selection over two lines is bold, and the break is still there', /data-fmt="bold"/.test(lines) && (/<br/.test(lines) || /\n/.test(lines)) && (await marked()).replace(/\n/g, '').includes('**'), lines)
|
||||||
|
await bold()
|
||||||
|
check('and comes off whole', !/data-fmt/.test(await html()), await html())
|
||||||
|
|
||||||
|
console.log('Part of a run')
|
||||||
|
await type('one two three four')
|
||||||
|
await select('two three')
|
||||||
|
await bold()
|
||||||
|
await select('wo th')
|
||||||
|
await bold()
|
||||||
|
check('part of a run is taken off, the rest stays', (await marked()) === 'one **t**wo th**ree** four', await marked())
|
||||||
|
|
||||||
|
console.log('Reaching past a run')
|
||||||
|
await type('one two three four')
|
||||||
|
await select('two')
|
||||||
|
await bold()
|
||||||
|
await select('one two thr')
|
||||||
|
await bold()
|
||||||
|
check('a selection partly bold is made all bold, not doubled', (await marked()) === '**one two thr**ee four', await marked())
|
||||||
|
await bold()
|
||||||
|
check('and the next press takes it all off', (await marked()) === 'one two three four', await marked())
|
||||||
|
|
||||||
|
console.log('The strip')
|
||||||
|
await type('one two three four')
|
||||||
|
await select('three')
|
||||||
|
await sleep(300)
|
||||||
|
check('the strip offers bold', await strip('Bold'))
|
||||||
|
check('and it goes on', (await marked()) === 'one two **three** four', await marked())
|
||||||
|
await sleep(300)
|
||||||
|
check('the strip button again takes it off', (await strip('Bold')) && (await marked()) === 'one two three four', await marked())
|
||||||
|
await undo()
|
||||||
|
check('and undo puts it back', (await marked()) === 'one two **three** four', await marked())
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' FAIL', e.message)
|
||||||
|
results.push(false)
|
||||||
|
} finally {
|
||||||
|
await stop()
|
||||||
|
}
|
||||||
|
process.exit(results.every(Boolean) ? 0 : 1)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Searching the web for selected text, in a scratch moho with injected state (nothing is contacted): the message
|
||||||
|
* menu offers it for a selection only, names the engine chosen in Settings > General, follows a change of engine,
|
||||||
|
* and Settings shows the drop-down (and the address field for an engine of one's own).
|
||||||
|
*
|
||||||
|
* node scripts/check-search-menu.mjs [output directory]
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { launchApp, sleep } from './lib/harness.mjs'
|
||||||
|
|
||||||
|
const out = process.argv[2]
|
||||||
|
if (out) fs.mkdirSync(out, { recursive: true })
|
||||||
|
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||||
|
const ev = (code) => page.evaluate(code)
|
||||||
|
const results = []
|
||||||
|
const check = (what, ok, detail = '') => {
|
||||||
|
results.push(ok)
|
||||||
|
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
|
||||||
|
}
|
||||||
|
const shot = async (name) => {
|
||||||
|
if (!out) return
|
||||||
|
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||||
|
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||||
|
}
|
||||||
|
const mouse = (type, x, y, button = 'left') => page.call('Input.dispatchMouseEvent', { type, x, y, button, buttons: type === 'mouseReleased' ? 0 : button === 'right' ? 2 : 1, clickCount: 1 })
|
||||||
|
const menuLabels = () => ev(`[...document.querySelectorAll('.context-menu .context-menu-item')].map((e) => e.innerText.trim().split('\\n').pop())`)
|
||||||
|
/** Sets a field the way a person's edit does, so the window's own handlers run. */
|
||||||
|
const setField = (selector, value, how) => ev(`(() => {
|
||||||
|
const el = document.querySelector(${JSON.stringify(selector)})
|
||||||
|
if (!el) return false
|
||||||
|
if (${JSON.stringify(how)} === 'input') el.focus()
|
||||||
|
const proto = el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype
|
||||||
|
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)})
|
||||||
|
el.dispatchEvent(new Event(${JSON.stringify(how)}, { bubbles: true }))
|
||||||
|
if (${JSON.stringify(how)} === 'input') el.blur()
|
||||||
|
return true
|
||||||
|
})()`)
|
||||||
|
const openGeneral = async () => {
|
||||||
|
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
|
||||||
|
await sleep(500)
|
||||||
|
await ev(`(() => { const b = [...document.querySelectorAll('button, [role=tab], a')].find((e) => e.innerText.trim() === 'General'); b?.click() })()`)
|
||||||
|
await sleep(500)
|
||||||
|
}
|
||||||
|
const closeMenu = async () => {
|
||||||
|
await page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
|
||||||
|
await sleep(200)
|
||||||
|
}
|
||||||
|
/** Selects `needle` in the first message and right-clicks on it. */
|
||||||
|
const rightClickOn = async (needle) => {
|
||||||
|
const at = await ev(`(() => {
|
||||||
|
const body = document.querySelector('.message-body') ?? document.querySelector('[data-msg-id]')
|
||||||
|
const w = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
|
||||||
|
for (let n; (n = w.nextNode()); ) {
|
||||||
|
const i = n.nodeValue.indexOf(${JSON.stringify(needle)})
|
||||||
|
if (i < 0) continue
|
||||||
|
const r = document.createRange()
|
||||||
|
r.setStart(n, i); r.setEnd(n, i + ${JSON.stringify(needle)}.length)
|
||||||
|
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
|
||||||
|
const b = r.getBoundingClientRect()
|
||||||
|
return { x: b.left + b.width / 2, y: b.top + b.height / 2 }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()`)
|
||||||
|
if (!at) return false
|
||||||
|
await mouse('mouseMoved', at.x, at.y)
|
||||||
|
await mouse('mousePressed', at.x, at.y, 'right')
|
||||||
|
await mouse('mouseReleased', at.x, at.y, 'right')
|
||||||
|
await sleep(400)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sleep(2500)
|
||||||
|
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||||
|
await ev(`(() => {
|
||||||
|
const s = window.__mohoShots.store
|
||||||
|
const acct = { id: 'discord:me', service: 'discord', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
|
||||||
|
const buf = { id: 'discord:me|chat', accountId: 'discord:me', kind: 'channel', name: 'chat', lastActivityTs: 5, category: 'Channels', groupId: 'account:discord:me' }
|
||||||
|
const t = Math.floor(Date.now() / 1000) - 600
|
||||||
|
const messages = [{ id: 'm1', bufferId: 'discord:me|chat', from: 'ada', body: 'the Antikythera mechanism is older than anyone expected', ts: t, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false }]
|
||||||
|
s.set({ accounts: [acct], groups: [{ id: 'account:discord:me', accountId: 'discord:me', service: 'discord', kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { 'discord:me|chat': messages }, activeBufferId: 'discord:me|chat' })
|
||||||
|
s.selectGroup('account:discord:me')
|
||||||
|
})()`)
|
||||||
|
await sleep(1200)
|
||||||
|
|
||||||
|
console.log('Message menu')
|
||||||
|
check('a message with a word selected', await rightClickOn('Antikythera mechanism'))
|
||||||
|
let labels = await menuLabels()
|
||||||
|
check('offers a search for it, by the default engine', labels.some((l) => l === 'Search DuckDuckGo for “Antikythera mechanism”'), JSON.stringify(labels))
|
||||||
|
await shot('search-menu.png')
|
||||||
|
await closeMenu()
|
||||||
|
|
||||||
|
await openGeneral()
|
||||||
|
check('Settings > General has the engine drop-down', await setField('select[aria-label="Search engine"]', 'google', 'change'))
|
||||||
|
await sleep(300)
|
||||||
|
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||||
|
await sleep(500)
|
||||||
|
await rightClickOn('Antikythera mechanism is older')
|
||||||
|
labels = await menuLabels()
|
||||||
|
check('names the engine chosen in Settings, and shortens a long selection', labels.some((l) => /^Search Google for “Antikythera mechanism is ol…”$/.test(l)), JSON.stringify(labels))
|
||||||
|
await closeMenu()
|
||||||
|
|
||||||
|
await openGeneral()
|
||||||
|
await setField('select[aria-label="Search engine"]', 'custom', 'change')
|
||||||
|
await sleep(300)
|
||||||
|
check('an own engine asks for an address', await setField('input[placeholder="https://example.org/search?q=%s"]', 'https://www.example.org/find?q=%s', 'input'))
|
||||||
|
await shot('search-settings.png')
|
||||||
|
await sleep(300)
|
||||||
|
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||||
|
await sleep(500)
|
||||||
|
await rightClickOn('Antikythera')
|
||||||
|
labels = await menuLabels()
|
||||||
|
check('an engine of one’s own is named by its host', labels.some((l) => l === 'Search example.org for “Antikythera”'), JSON.stringify(labels))
|
||||||
|
await closeMenu()
|
||||||
|
|
||||||
|
await openGeneral()
|
||||||
|
await setField('input[placeholder="https://example.org/search?q=%s"]', 'nonsense', 'input')
|
||||||
|
await sleep(300)
|
||||||
|
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||||
|
await sleep(500)
|
||||||
|
await rightClickOn('Antikythera')
|
||||||
|
labels = await menuLabels()
|
||||||
|
check('an address that cannot be searched is not offered', !labels.some((l) => l.startsWith('Search ')), JSON.stringify(labels))
|
||||||
|
await closeMenu()
|
||||||
|
|
||||||
|
await openGeneral()
|
||||||
|
await setField('select[aria-label="Search engine"]', 'duckduckgo', 'change')
|
||||||
|
await sleep(300)
|
||||||
|
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||||
|
await sleep(500)
|
||||||
|
await ev(`getSelection().removeAllRanges()`)
|
||||||
|
const row = await ev(`(() => { const r = document.querySelector('[data-msg-id]').getBoundingClientRect(); return { x: r.right - 20, y: r.top + r.height / 2 } })()`)
|
||||||
|
await mouse('mouseMoved', row.x, row.y)
|
||||||
|
await mouse('mousePressed', row.x, row.y, 'right')
|
||||||
|
await mouse('mouseReleased', row.x, row.y, 'right')
|
||||||
|
await sleep(400)
|
||||||
|
labels = await menuLabels()
|
||||||
|
check('without a selection there is no search entry', labels.length > 0 && !labels.some((l) => l.startsWith('Search ')), JSON.stringify(labels))
|
||||||
|
await closeMenu()
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' FAIL', e.message)
|
||||||
|
results.push(false)
|
||||||
|
} finally {
|
||||||
|
await stop()
|
||||||
|
}
|
||||||
|
process.exit(results.every(Boolean) ? 0 : 1)
|
||||||
+13
-3
@@ -101,8 +101,18 @@ try {
|
|||||||
const klipy = rows.find((r) => r.text.includes('klipy.com'))
|
const klipy = rows.find((r) => r.text.includes('klipy.com'))
|
||||||
check(!!klipy && klipy.videos.length === 0, 'a Klipy page nothing could read stays a link', JSON.stringify(klipy))
|
check(!!klipy && klipy.videos.length === 0, 'a Klipy page nothing could read stays a link', JSON.stringify(klipy))
|
||||||
|
|
||||||
// The picker.
|
// The picker. It is offered only where a GIF link shows as a picture - Discord, Matrix, Kick - and none of those can be
|
||||||
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where there is a Discord account')
|
// stood up here, so the IRC account is shown to the window as a Matrix one: the picker appears, and what it sends still
|
||||||
|
// goes through the daemon to the fake IRC server.
|
||||||
|
check(!(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')')), 'no GIF button on an IRC conversation, which would show a GIF link as a link')
|
||||||
|
await page.evaluate(`(() => {
|
||||||
|
const shots = window.__mohoShots
|
||||||
|
const s = shots.state()
|
||||||
|
const account = s.accounts.find((a) => a.id === s.buffers.find((b) => b.id === s.activeBufferId)?.accountId)
|
||||||
|
shots.patch({ accounts: s.accounts.map((a) => (a === account ? { ...a, service: 'matrix' } : a)) })
|
||||||
|
})()`)
|
||||||
|
await sleep(500)
|
||||||
|
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where a GIF link shows as a picture')
|
||||||
await page.evaluate(click('button[title="GIFs"]'))
|
await page.evaluate(click('button[title="GIFs"]'))
|
||||||
await until(page, 'document.querySelector(".gif-picker")', 'the picker')
|
await until(page, 'document.querySelector(".gif-picker")', 'the picker')
|
||||||
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 3, 'it lists what it was given')
|
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 3, 'it lists what it was given')
|
||||||
@@ -117,7 +127,7 @@ try {
|
|||||||
// Choosing one sends it - to IRC, the file, which anything can show as a link.
|
// Choosing one sends it - to IRC, the file, which anything can show as a link.
|
||||||
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
|
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
|
||||||
await sleep(1200)
|
await sleep(1200)
|
||||||
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord', JSON.stringify(sent))
|
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord (here IRC\'s wire, dressed as Matrix)', JSON.stringify(sent))
|
||||||
check(!(await page.evaluate('!!document.querySelector(".gif-picker")')), 'and the picker closes')
|
check(!(await page.evaluate('!!document.querySelector(".gif-picker")')), 'and the picker closes')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('FAIL', e.message)
|
console.log('FAIL', e.message)
|
||||||
|
|||||||
+2
-1
@@ -356,7 +356,8 @@ function wireEditMenu(win: BrowserWindow): void {
|
|||||||
delete: f.canDelete,
|
delete: f.canDelete,
|
||||||
selectAll: f.canSelectAll
|
selectAll: f.canSelectAll
|
||||||
},
|
},
|
||||||
hasSelection: params.selectionText.length > 0
|
hasSelection: params.selectionText.length > 0,
|
||||||
|
selection: params.selectionText.slice(0, 1000)
|
||||||
} satisfies EditMenuRequest)
|
} satisfies EditMenuRequest)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6264,6 +6264,11 @@ video.lightbox-media {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
/* A held login is explained in full: the time it will connect is at the end. */
|
||||||
|
.account-detail.held {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- presence on avatars ---------------------------------------------- */
|
/* --- presence on avatars ---------------------------------------------- */
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
|||||||
// What the daemon last said about why. Only meaningful while something is
|
// What the daemon last said about why. Only meaningful while something is
|
||||||
// wrong, which is exactly when the state line alone explains nothing.
|
// wrong, which is exactly when the state line alone explains nothing.
|
||||||
const detail = useConnectionDetail(account.id)
|
const detail = useConnectionDetail(account.id)
|
||||||
|
const held = account.service === 'discord' && !!detail?.startsWith('Holding the Discord connection')
|
||||||
|
|
||||||
const call = (method: string, params: Record<string, unknown>): void => {
|
const call = (method: string, params: Record<string, unknown>): void => {
|
||||||
void window.moho
|
void window.moho
|
||||||
@@ -353,7 +354,20 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
|||||||
<div className="small muted">
|
<div className="small muted">
|
||||||
{serviceLabel(account.service)} · {account.state}
|
{serviceLabel(account.service)} · {account.state}
|
||||||
</div>
|
</div>
|
||||||
{detail && <div className="small muted ellipsis account-detail" title={detail}>{detail}</div>}
|
{detail && <div className={classes('small muted account-detail', held && 'held')} title={detail}>{detail}</div>}
|
||||||
|
{/* Held for having logged in too often (see nobilis's identify_budget): said in full, with the way past it. */}
|
||||||
|
{held && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button subtle"
|
||||||
|
title="Many fresh logins can get an account flagged as spam by Discord. This one is counted."
|
||||||
|
onClick={() => call('connectDiscordAnyway', { accountId: account.id })}
|
||||||
|
>
|
||||||
|
Connect anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormatBar } from './FormatBar'
|
import { FormatBar } from './FormatBar'
|
||||||
import { formatsFor, serialize, type FormatKind } from '../lib/composeFormat'
|
import { formatsFor, serialize, type FormatKind } from '../lib/composeFormat'
|
||||||
import { applyFormat } from '../lib/composeFormatDom'
|
import { applyFormat, restoreSelectionAfterHistory } from '../lib/composeFormatDom'
|
||||||
import { useMediaUrl } from '../lib/route'
|
import { useMediaUrl } from '../lib/route'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Icon, IconButton, MaskIcon } from './Icon'
|
import { Icon, IconButton, MaskIcon } from './Icon'
|
||||||
@@ -51,6 +51,9 @@ interface StagedAttachment {
|
|||||||
*/
|
*/
|
||||||
const TYPING_REPEAT_MS = 4000
|
const TYPING_REPEAT_MS = 4000
|
||||||
|
|
||||||
|
/** The services whose conversations can take a GIF: the picker sends a link, and these show it as the picture. */
|
||||||
|
const GIF_SERVICES = new Set(['discord', 'matrix', 'kick'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the box holds, as the text that will be sent.
|
* What the box holds, as the text that will be sent.
|
||||||
*
|
*
|
||||||
@@ -1178,6 +1181,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
|||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
|
aria-label={thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
|
restoreSelectionAfterHistory(e.currentTarget, (e.nativeEvent as InputEvent).inputType)
|
||||||
setText(composerText(e.currentTarget))
|
setText(composerText(e.currentTarget))
|
||||||
noteTyping()
|
noteTyping()
|
||||||
noteMention()
|
noteMention()
|
||||||
@@ -1343,10 +1347,12 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
|||||||
<Icon name="mood" size={18} />
|
<Icon name="mood" size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* GIFs, wherever there is a Discord account to ask for them: the
|
{/* GIFs, wherever there is a Discord account to ask for them: the picker borrows that account's way of looking.
|
||||||
picker borrows that account's way of looking, and what it finds can
|
What it finds is sent as a link, so it is offered only on the services that show a link to one as a picture
|
||||||
be sent in any conversation - except Twitch's, whose chat takes no pictures. */}
|
(GIF_SERVICES): not IRC, Steam, Twitch or Sneedchat, which take none. Telegram has GIFs of its own - the account's
|
||||||
{!thread && service !== 'steam' && service !== 'twitch' && accounts.some((a) => a.service === 'discord') && (
|
saved ones and Telegram's GIF search - and gets the picker with those, asking Telegram rather than Discord. A service not on the list is
|
||||||
|
one nobody has checked, and gets no button. */}
|
||||||
|
{!thread && ((GIF_SERVICES.has(service ?? '') && accounts.some((a) => a.service === 'discord')) || service === 'telegram') && (
|
||||||
<button
|
<button
|
||||||
ref={gifButtonRef}
|
ref={gifButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1426,6 +1432,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
|||||||
bufferId={buffer.id}
|
bufferId={buffer.id}
|
||||||
accountId={buffer.accountId}
|
accountId={buffer.accountId}
|
||||||
discord={service === 'discord'}
|
discord={service === 'discord'}
|
||||||
|
telegram={service === 'telegram'}
|
||||||
onClose={() => setGifPicker(false)}
|
onClose={() => setGifPicker(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { ContextMenu, type MenuEntry } from './ContextMenu'
|
import { ContextMenu, type MenuEntry } from './ContextMenu'
|
||||||
import type { EditAction, EditMenuRequest } from '../../../shared/ipc'
|
import type { EditAction, EditMenuRequest } from '../../../shared/ipc'
|
||||||
|
import { usePref } from '../state/hooks'
|
||||||
|
import { DEFAULT_SEARCH_ENGINE, engineName, searchUrl, shortened } from '../lib/searchengines'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The right-click menu for anywhere text is typed: the message box, and every
|
* The right-click menu for anywhere text is typed: the message box, and every
|
||||||
@@ -14,6 +16,8 @@ import type { EditAction, EditMenuRequest } from '../../../shared/ipc'
|
|||||||
export function EditMenu(): JSX.Element | null {
|
export function EditMenu(): JSX.Element | null {
|
||||||
const [menu, setMenu] = useState<EditMenuRequest | null>(null)
|
const [menu, setMenu] = useState<EditMenuRequest | null>(null)
|
||||||
const focus = useRef<{ element: HTMLElement | null; range: Range | null }>({ element: null, range: null })
|
const focus = useRef<{ element: HTMLElement | null; range: Range | null }>({ element: null, range: null })
|
||||||
|
const [searchEngine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
|
||||||
|
const [searchCustom] = usePref<string>('search.customUrl', '')
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
@@ -79,6 +83,18 @@ export function EditMenu(): JSX.Element | null {
|
|||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: 'Select all', icon: 'select_all', disabled: !menu.can.selectAll, onClick: act('selectAll') }
|
{ label: 'Select all', icon: 'select_all', disabled: !menu.can.selectAll, onClick: act('selectAll') }
|
||||||
)
|
)
|
||||||
|
// Looking up what is selected, in the browser the person already uses.
|
||||||
|
const address = searchUrl(menu.selection ?? '', searchEngine, searchCustom)
|
||||||
|
if (address) {
|
||||||
|
entries.push(
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: `Search ${engineName(searchEngine, searchCustom)} for “${shortened(menu.selection)}”`,
|
||||||
|
icon: 'search',
|
||||||
|
onClick: () => void window.moho.openExternal(address)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={() => setMenu(null)} />
|
return <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={() => setMenu(null)} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function GifPicker({
|
|||||||
bufferId,
|
bufferId,
|
||||||
accountId,
|
accountId,
|
||||||
discord,
|
discord,
|
||||||
|
telegram,
|
||||||
onClose
|
onClose
|
||||||
}: {
|
}: {
|
||||||
anchor: HTMLElement | null
|
anchor: HTMLElement | null
|
||||||
@@ -50,6 +51,8 @@ export function GifPicker({
|
|||||||
accountId: string
|
accountId: string
|
||||||
/** Whether the conversation is on Discord, which decides what is sent. */
|
/** Whether the conversation is on Discord, which decides what is sent. */
|
||||||
discord: boolean
|
discord: boolean
|
||||||
|
/** Whether the conversation is on Telegram, which has GIFs of its own: the account's saved ones, and its GIF search. */
|
||||||
|
telegram?: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
@@ -116,7 +119,7 @@ export function GifPicker({
|
|||||||
const wait = setTimeout(
|
const wait = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
window.moho
|
window.moho
|
||||||
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>('listGifs', { query: query.trim() })
|
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>(telegram ? 'listTelegramGifs' : 'listGifs', telegram ? { bufferId, query: query.trim() } : { query: query.trim() })
|
||||||
.then((answer) => {
|
.then((answer) => {
|
||||||
if (!current) return
|
if (!current) return
|
||||||
setGifs(answer.gifs)
|
setGifs(answer.gifs)
|
||||||
@@ -135,10 +138,15 @@ export function GifPicker({
|
|||||||
current = false
|
current = false
|
||||||
clearTimeout(wait)
|
clearTimeout(wait)
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query, telegram, bufferId])
|
||||||
|
|
||||||
const pick = (gif: GifEntry): void => {
|
const pick = (gif: GifEntry): void => {
|
||||||
onClose()
|
onClose()
|
||||||
|
// Telegram's are files on Telegram: sending one is sending that file, not a link to it.
|
||||||
|
if (telegram) {
|
||||||
|
void window.moho.rpc('sendTelegramGif', { bufferId, id: gif.id }).catch((e: Error) => store.toast('error', e.message))
|
||||||
|
return
|
||||||
|
}
|
||||||
// Discord's own client sends the link, which Discord turns into the GIF.
|
// Discord's own client sends the link, which Discord turns into the GIF.
|
||||||
// Everything else is sent the file, since a page address would be only
|
// Everything else is sent the file, since a page address would be only
|
||||||
// that.
|
// that.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { reactionLabel, reactorsSentence } from '../lib/reactions'
|
|||||||
import { UploadMeter } from './UploadMeter'
|
import { UploadMeter } from './UploadMeter'
|
||||||
import { RichText } from '../lib/richtext'
|
import { RichText } from '../lib/richtext'
|
||||||
import { useChat, usePref, useStore } from '../state/hooks'
|
import { useChat, usePref, useStore } from '../state/hooks'
|
||||||
|
import { DEFAULT_SEARCH_ENGINE, engineName, searchUrl, shortened } from '../lib/searchengines'
|
||||||
import { useTick } from '../lib/clock'
|
import { useTick } from '../lib/clock'
|
||||||
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
|
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
|
||||||
import { useMediaUrl, useStrictRoute } from '../lib/route'
|
import { useMediaUrl, useStrictRoute } from '../lib/route'
|
||||||
@@ -567,6 +568,12 @@ function MessageRowBody({
|
|||||||
// have let go of the selection.
|
// have let go of the selection.
|
||||||
const selectedRef = useRef('')
|
const selectedRef = useRef('')
|
||||||
const selected = selectedRef.current
|
const selected = selectedRef.current
|
||||||
|
const [searchEngine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
|
||||||
|
const [searchCustom] = usePref<string>('search.customUrl', '')
|
||||||
|
const searchAddress = selected ? searchUrl(selected, searchEngine, searchCustom) : null
|
||||||
|
const search = searchAddress
|
||||||
|
? { url: searchAddress, name: engineName(searchEngine, searchCustom), shown: shortened(selected) }
|
||||||
|
: null
|
||||||
const canOpenProfile = !!message.from && !isSystem && !message.isOwn
|
const canOpenProfile = !!message.from && !isSystem && !message.isOwn
|
||||||
const openProfile = (): void => store.showProfile(bufferId, message.from, message.senderId)
|
const openProfile = (): void => store.showProfile(bufferId, message.from, message.senderId)
|
||||||
// Back to what this answers, bringing it into view first if it is a long
|
// Back to what this answers, bringing it into view first if it is a long
|
||||||
@@ -618,6 +625,12 @@ function MessageRowBody({
|
|||||||
...(selected
|
...(selected
|
||||||
? ([{ label: 'Copy', icon: 'content_copy', onClick: () => void window.moho.copyText(selected) }] as MenuEntry[])
|
? ([{ label: 'Copy', icon: 'content_copy', onClick: () => void window.moho.copyText(selected) }] as MenuEntry[])
|
||||||
: []),
|
: []),
|
||||||
|
// Looking up what is selected: in the browser the person already uses, with
|
||||||
|
// the engine they chose in Settings. Only for a selection - there is no
|
||||||
|
// sensible thing to search for in a whole message.
|
||||||
|
...(search
|
||||||
|
? ([{ label: `Search ${search.name} for “${search.shown}”`, icon: 'search', onClick: () => void window.moho.openExternal(search.url) }] as MenuEntry[])
|
||||||
|
: []),
|
||||||
...(message.body && !isSystem
|
...(message.body && !isSystem
|
||||||
? ([{ label: 'Copy text', icon: 'content_copy', onClick: () => void window.moho.copyText(message.body) }] as MenuEntry[])
|
? ([{ label: 'Copy text', icon: 'content_copy', onClick: () => void window.moho.copyText(message.body) }] as MenuEntry[])
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DirectorySetting,
|
DirectorySetting,
|
||||||
SavedMark,
|
SavedMark,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
|
DropdownSetting,
|
||||||
SelectionSetting,
|
SelectionSetting,
|
||||||
StringSetting,
|
StringSetting,
|
||||||
ToggleSetting,
|
ToggleSetting,
|
||||||
@@ -17,6 +18,7 @@ import { acceleratorLabel, acceleratorOf } from '../../lib/accelerator'
|
|||||||
import { AccountsPanel } from '../AccountsPanel'
|
import { AccountsPanel } from '../AccountsPanel'
|
||||||
import { DownloadsPanel } from '../DownloadsPanel'
|
import { DownloadsPanel } from '../DownloadsPanel'
|
||||||
import { humanBytes } from '../../lib/util'
|
import { humanBytes } from '../../lib/util'
|
||||||
|
import { DEFAULT_SEARCH_ENGINE, SEARCH_ENGINES, validTemplate } from '../../lib/searchengines'
|
||||||
import { TunnelAllSwitch, useNetSettings } from './Tunnel'
|
import { TunnelAllSwitch, useNetSettings } from './Tunnel'
|
||||||
import type { Account, DccPrefs } from '../../../../shared/wire'
|
import type { Account, DccPrefs } from '../../../../shared/wire'
|
||||||
|
|
||||||
@@ -846,6 +848,41 @@ function AppearanceSettings(): JSX.Element {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which search engine "Search for…" uses on selected text. The search opens in
|
||||||
|
* the system's default browser, so moho sends the engine nothing itself - and
|
||||||
|
* neither Tor nor a proxy set up here applies to it.
|
||||||
|
*/
|
||||||
|
function SearchSettings(): JSX.Element {
|
||||||
|
const [engine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
|
||||||
|
const [custom] = usePref<string>('search.customUrl', '')
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title="Search"
|
||||||
|
description="Select text in a message, right-click it and search for it on the web. The search opens in your default browser, outside moho, so it does not go through Tor or a proxy set here."
|
||||||
|
>
|
||||||
|
<DropdownSetting
|
||||||
|
settingKey="search.engine"
|
||||||
|
label="Search engine"
|
||||||
|
defaultValue={DEFAULT_SEARCH_ENGINE}
|
||||||
|
options={SEARCH_ENGINES.map((e) => ({ label: e.label, value: e.id }))}
|
||||||
|
/>
|
||||||
|
{engine === 'custom' && (
|
||||||
|
<StringSetting
|
||||||
|
settingKey="search.customUrl"
|
||||||
|
label="Address"
|
||||||
|
description={
|
||||||
|
custom && !validTemplate(custom)
|
||||||
|
? 'Not usable yet: it has to start with http:// or https:// and contain %s where the search goes.'
|
||||||
|
: 'A web address with %s where the search goes, for example https://example.org/search?q=%s'
|
||||||
|
}
|
||||||
|
placeholder="https://example.org/search?q=%s"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function GeneralSettings(): JSX.Element {
|
function GeneralSettings(): JSX.Element {
|
||||||
const [defaultDir, setDefaultDir] = useState('')
|
const [defaultDir, setDefaultDir] = useState('')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -891,6 +928,8 @@ function GeneralSettings(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SearchSettings />
|
||||||
|
|
||||||
<SettingsSection title="Media">
|
<SettingsSection title="Media">
|
||||||
<ToggleSetting
|
<ToggleSetting
|
||||||
settingKey="media.autoplay"
|
settingKey="media.autoplay"
|
||||||
|
|||||||
@@ -158,6 +158,40 @@ export function SelectionSetting({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A choice among too many to lay out as buttons: a drop-down of them. */
|
||||||
|
export function DropdownSetting({
|
||||||
|
settingKey,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
defaultValue,
|
||||||
|
options
|
||||||
|
}: {
|
||||||
|
settingKey: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
defaultValue: string
|
||||||
|
options: { label: string; value: string }[]
|
||||||
|
}): JSX.Element {
|
||||||
|
const [value, setValue] = usePref<string>(settingKey, defaultValue)
|
||||||
|
return (
|
||||||
|
<div className="setting-row">
|
||||||
|
<div className="setting-text">
|
||||||
|
<div>{label}</div>
|
||||||
|
{description && <div className="small muted">{description}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="setting-actions">
|
||||||
|
<select className="text-field" value={value} aria-label={label} onChange={(e) => setValue(e.target.value)}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A folder chosen through the system picker, with the platform default shown
|
* A folder chosen through the system picker, with the platform default shown
|
||||||
* (and used) until the user overrides it.
|
* (and used) until the user overrides it.
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { textNodes, transformFormat } from './composeFormatDom'
|
||||||
|
import type { FormatKind } from './composeFormat'
|
||||||
|
|
||||||
|
/** What the box says, with the formats marked as they would be on Discord. */
|
||||||
|
function marked(root: Node): string {
|
||||||
|
const mark: Record<string, string> = { bold: '**', italic: '_', underline: '__', strike: '~~', code: '`', spoiler: '||', quote: '> ' }
|
||||||
|
return Array.from(root.childNodes)
|
||||||
|
.map((n) => {
|
||||||
|
if (n.nodeType === 3) return n.nodeValue
|
||||||
|
if (n instanceof HTMLElement && n.dataset.fmt === 'color') return `{${n.dataset.color}:${marked(n)}}`
|
||||||
|
if (n instanceof HTMLElement && n.dataset.fmt) return `${mark[n.dataset.fmt]}${marked(n)}${mark[n.dataset.fmt]}`
|
||||||
|
return marked(n)
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A box holding `html`, with `needle` selected in its text. */
|
||||||
|
function box(html: string, needle: string): { root: HTMLElement; range: Range } {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.innerHTML = html
|
||||||
|
const nodes = textNodes(root)
|
||||||
|
const all = nodes.map((n) => n.nodeValue).join('')
|
||||||
|
const at = all.indexOf(needle)
|
||||||
|
expect(at).toBeGreaterThanOrEqual(0)
|
||||||
|
const locate = (offset: number, preferEnd: boolean): [Text, number] => {
|
||||||
|
let seen = 0
|
||||||
|
for (const n of nodes) {
|
||||||
|
const len = n.nodeValue!.length
|
||||||
|
if (preferEnd ? offset <= seen + len : offset < seen + len) return [n, offset - seen]
|
||||||
|
seen += len
|
||||||
|
}
|
||||||
|
throw new Error('outside')
|
||||||
|
}
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(...locate(at, false))
|
||||||
|
range.setEnd(...locate(at + needle.length, true))
|
||||||
|
return { root, range }
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(html: string, needle: string, kind: FormatKind, colour?: string): { text: string; selected: string; done: boolean } {
|
||||||
|
const { root, range } = box(html, needle)
|
||||||
|
const done = transformFormat(root, range, kind, colour)
|
||||||
|
return { text: marked(root), selected: range.toString(), done }
|
||||||
|
}
|
||||||
|
|
||||||
|
const B = (s: string): string => `<span data-fmt="bold">${s}</span>`
|
||||||
|
|
||||||
|
describe('putting a format on', () => {
|
||||||
|
it('wraps the selection and leaves it selected', () => {
|
||||||
|
expect(run('one two three', 'two', 'bold')).toEqual({ text: 'one **two** three', selected: 'two', done: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makes a selection that is partly bold all bold, once', () => {
|
||||||
|
const r = run(`one ${B('two')} three four`, 'one two thr', 'bold')
|
||||||
|
expect(r.text).toBe('**one two thr**ee four')
|
||||||
|
expect(r.selected).toBe('one two thr')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins a run that ends where the new one begins', () => {
|
||||||
|
expect(run(`${B('ab')}cd`, 'cd', 'bold').text).toBe('**abcd**')
|
||||||
|
expect(run(`ab${B('cd')}`, 'ab', 'bold').text).toBe('**abcd**')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stacks on another format without breaking it', () => {
|
||||||
|
const r = run('one <span data-fmt="italic">two three</span> four', 'three four', 'bold')
|
||||||
|
expect(r.text).toBe('one _two _**_three_ four**')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing with nothing selected', () => {
|
||||||
|
const { root, range } = box('one two', 'two')
|
||||||
|
range.collapse(true)
|
||||||
|
expect(transformFormat(root, range, 'bold')).toBe(false)
|
||||||
|
expect(marked(root)).toBe('one two')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('taking a format off', () => {
|
||||||
|
it('takes it off a whole run', () => {
|
||||||
|
expect(run(`one ${B('two')} three`, 'two', 'bold')).toEqual({ text: 'one two three', selected: 'two', done: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes it off part of a run and leaves the rest of the run bold', () => {
|
||||||
|
const r = run(`one ${B('two three')} four`, 'wo th', 'bold')
|
||||||
|
expect(r.text).toBe('one **t**wo th**ree** four')
|
||||||
|
expect(r.selected).toBe('wo th')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes it off the end of a run', () => {
|
||||||
|
expect(run(`one ${B('two three')}`, 'three', 'bold').text).toBe('one **two **three')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes it off from two runs side by side when all of the selection has it', () => {
|
||||||
|
expect(run(`${B('one')}${B('two')}`, 'netw', 'bold').text).toBe('**o**ne' + 'tw' + '**o**')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not take it off a selection that reaches a space which is not bold: that is put on instead', () => {
|
||||||
|
expect(run(`${B('one')} ${B('two')}`, 'e t', 'bold').text).toBe('**one two**')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only the format asked for', () => {
|
||||||
|
const r = run('<span data-fmt="bold"><span data-fmt="italic">two</span></span>', 'two', 'bold')
|
||||||
|
expect(r.text).toBe('_two_')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('colour', () => {
|
||||||
|
it('puts a colour on, and a different one replaces it', () => {
|
||||||
|
const on = box('one two', 'two')
|
||||||
|
expect(transformFormat(on.root, on.range, 'color', '#ff0000')).toBe(true)
|
||||||
|
expect(marked(on.root)).toBe('one {#ff0000:two}')
|
||||||
|
expect(transformFormat(on.root, on.range, 'color', '#00ff00')).toBe(true)
|
||||||
|
expect(marked(on.root)).toBe('one {#00ff00:two}')
|
||||||
|
expect(transformFormat(on.root, on.range, 'color')).toBe(true)
|
||||||
|
expect(marked(on.root)).toBe('one two')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes no colour that is not a plain hex value', () => {
|
||||||
|
expect(run('one two', 'two', 'color', 'red; background:url(x)').done).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has nothing to take off when there is no colour', () => {
|
||||||
|
expect(run('one two', 'two', 'color').done).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,68 +1,303 @@
|
|||||||
import type { FormatKind } from './composeFormat'
|
import type { FormatKind } from './composeFormat'
|
||||||
|
|
||||||
/** The nearest enclosing element in the box that stands for `kind`, if any. */
|
|
||||||
function holderOf(node: Node | null, root: HTMLElement, kind: FormatKind): HTMLElement | null {
|
|
||||||
for (let n: Node | null = node; n && n !== root; n = n.parentNode) {
|
|
||||||
if (n instanceof HTMLElement && n.dataset.fmt === kind) return n
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Colours reach a style attribute, so only a plain hex value gets there. */
|
/** Colours reach a style attribute, so only a plain hex value gets there. */
|
||||||
const HEX = /^#[0-9a-fA-F]{6}$/
|
const HEX = /^#[0-9a-fA-F]{6}$/
|
||||||
|
|
||||||
|
const isRun = (node: Node | null): node is HTMLElement => node instanceof HTMLElement && node.dataset.fmt !== undefined
|
||||||
|
const isKind = (node: Node | null, kind: FormatKind): node is HTMLElement => isRun(node) && node.dataset.fmt === kind
|
||||||
|
const isMarker = (node: Node | null): boolean => node instanceof HTMLElement && node.dataset.mark !== undefined
|
||||||
|
const sameRun = (a: HTMLElement, b: HTMLElement): boolean => a.dataset.fmt === b.dataset.fmt && a.dataset.color === b.dataset.color
|
||||||
|
|
||||||
|
function nearestRun(from: Node, root: Node, kind: FormatKind): HTMLElement | null {
|
||||||
|
for (let n: Node | null = from.parentNode; n && n !== root; n = n.parentNode) {
|
||||||
|
if (isKind(n, kind)) return n
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function marker(which: 's' | 'e'): HTMLElement {
|
||||||
|
const m = document.createElement('i')
|
||||||
|
m.dataset.mark = which
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a format to what is selected in the box, or takes it off again if
|
* Takes a marker out of every run of `kind` around it, splitting each run in
|
||||||
* the selection is exactly that format already. Returns whether it did
|
* two at the marker. A start marker ends up in front of the second half and
|
||||||
* anything.
|
* an end marker behind the first, so what lies between the two markers is
|
||||||
*
|
* never inside a run that reaches beyond it.
|
||||||
* Goes through `insertHTML` rather than rearranging nodes, so the box's own
|
|
||||||
* undo still steps back over it.
|
|
||||||
*/
|
*/
|
||||||
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
|
function lift(root: Node, point: HTMLElement, kind: FormatKind, atStart: boolean): void {
|
||||||
const selection = window.getSelection()
|
for (let run = nearestRun(point, root, kind); run; run = nearestRun(point, root, kind)) {
|
||||||
if (!selection || selection.rangeCount === 0) return false
|
const rest = document.createRange()
|
||||||
const range = selection.getRangeAt(0)
|
if (atStart) rest.setStartBefore(point)
|
||||||
if (range.collapsed || !root.contains(range.commonAncestorContainer)) return false
|
else rest.setStartAfter(point)
|
||||||
root.focus()
|
rest.setEnd(run, run.childNodes.length)
|
||||||
|
const tail = run.cloneNode(false) as HTMLElement
|
||||||
// The selection can be a whole formatted run (it is, right after one is
|
tail.append(rest.extractContents())
|
||||||
// applied, so another can be stacked on), which names the run's parent as
|
run.after(tail)
|
||||||
// its ancestor and the run itself only by position.
|
if (atStart) tail.before(point)
|
||||||
const whole =
|
else run.after(point)
|
||||||
range.startContainer === range.endContainer && range.endOffset - range.startOffset === 1
|
|
||||||
? range.startContainer.childNodes[range.startOffset]
|
|
||||||
: null
|
|
||||||
const holder = holderOf(whole ?? range.commonAncestorContainer, root, kind)
|
|
||||||
if (holder && (kind === 'color' ? !colour : holder.textContent === range.toString())) {
|
|
||||||
const around = document.createRange()
|
|
||||||
around.selectNode(holder)
|
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(around)
|
|
||||||
document.execCommand('insertHTML', false, holder.innerHTML)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
if (kind === 'color' && !(colour && HEX.test(colour))) return false
|
}
|
||||||
|
|
||||||
const scratch = document.createElement('div')
|
/** Every text node under `node`, in document order. */
|
||||||
scratch.appendChild(range.cloneContents())
|
export function textNodes(node: Node, into: Text[] = []): Text[] {
|
||||||
const colourAttrs = kind === 'color' ? ` data-color="${colour}" style="color:${colour}"` : ''
|
for (const child of Array.from(node.childNodes)) {
|
||||||
document.execCommand(
|
if (child.nodeType === 3) into.push(child as Text)
|
||||||
'insertHTML',
|
else textNodes(child, into)
|
||||||
false,
|
}
|
||||||
`<span data-fmt="${kind}"${colourAttrs} data-new="1">${scratch.innerHTML}</span>`
|
return into
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text between two markers, in document order. */
|
||||||
|
function textBetween(root: Node, from: Node, to: Node): Text[] {
|
||||||
|
return textNodes(root).filter(
|
||||||
|
(n) =>
|
||||||
|
n.nodeValue &&
|
||||||
|
from.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING &&
|
||||||
|
to.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING
|
||||||
)
|
)
|
||||||
// Left selected, so another format can be stacked on without selecting again.
|
}
|
||||||
const made = root.querySelector('[data-new]')
|
|
||||||
if (made) {
|
function charactersBefore(root: Node, point: Node): number {
|
||||||
made.removeAttribute('data-new')
|
return textNodes(root).reduce((sum, n) => sum + (point.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING ? n.nodeValue!.length : 0), 0)
|
||||||
// The run itself rather than its contents: Chromium replaces the whole
|
}
|
||||||
// element when what is selected is all of what is inside it, which would
|
|
||||||
// swallow the format just applied.
|
/** The point in the text that `count` characters in: a start prefers the beginning of the next piece, an end the finish of the last. */
|
||||||
const around = document.createRange()
|
function place(root: Node, count: number, atStart: boolean): [Node, number] {
|
||||||
around.selectNode(made)
|
const text = textNodes(root)
|
||||||
selection.removeAllRanges()
|
let seen = 0
|
||||||
selection.addRange(around)
|
for (const n of text) {
|
||||||
|
const length = n.nodeValue!.length
|
||||||
|
if (atStart ? count < seen + length : count <= seen + length) return [n, count - seen]
|
||||||
|
seen += length
|
||||||
}
|
}
|
||||||
|
const last = text[text.length - 1]
|
||||||
|
return last ? [last, last.nodeValue!.length] : [root, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap(run: HTMLElement): void {
|
||||||
|
run.replaceWith(...Array.from(run.childNodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits leave runs and wrappers with nothing in them; they would serialise as stray markers. */
|
||||||
|
function dropEmpty(parent: Node): void {
|
||||||
|
for (const child of Array.from(parent.childNodes)) {
|
||||||
|
if (child.nodeType === 3) {
|
||||||
|
if (!child.nodeValue) child.remove()
|
||||||
|
} else if (child instanceof HTMLElement && !isMarker(child)) {
|
||||||
|
dropEmpty(child)
|
||||||
|
if (!child.hasChildNodes() && child.nodeName !== 'BR' && child.nodeName !== 'IMG') child.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two runs of one format side by side are one run, or they would be marked up twice. Markers between them stay in order. */
|
||||||
|
function mergeRuns(parent: Node): void {
|
||||||
|
let child = parent.firstChild
|
||||||
|
while (child) {
|
||||||
|
if (isRun(child)) {
|
||||||
|
const between: Node[] = []
|
||||||
|
let next = child.nextSibling
|
||||||
|
while (next && (isMarker(next) || (next.nodeType === 3 && !next.nodeValue))) {
|
||||||
|
between.push(next)
|
||||||
|
next = next.nextSibling
|
||||||
|
}
|
||||||
|
if (isRun(next) && sameRun(child, next)) {
|
||||||
|
child.append(...between, ...Array.from(next.childNodes))
|
||||||
|
next.remove()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child = child.nextSibling
|
||||||
|
}
|
||||||
|
for (const element of Array.from(parent.childNodes)) if (element instanceof HTMLElement && !isMarker(element)) mergeRuns(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection of `range` with `kind` put on - or taken off, if all of it
|
||||||
|
* already has it. Works on `root` (a copy of the box, in practice) and
|
||||||
|
* leaves `range` on the same text afterwards. False when there was nothing
|
||||||
|
* to do.
|
||||||
|
*
|
||||||
|
* Applying over text that is part bold makes all of it bold, once, and
|
||||||
|
* taking it off from the middle of a run leaves the rest of the run as it was;
|
||||||
|
* both come from cutting every run of the kind at the selection's ends first.
|
||||||
|
*/
|
||||||
|
export function transformFormat(root: HTMLElement, range: Range, kind: FormatKind, colour?: string): boolean {
|
||||||
|
if (kind === 'color' && colour && !HEX.test(colour)) return false
|
||||||
|
const start = marker('s')
|
||||||
|
const end = marker('e')
|
||||||
|
const atEnd = range.cloneRange()
|
||||||
|
atEnd.collapse(false)
|
||||||
|
atEnd.insertNode(end)
|
||||||
|
const atStart = range.cloneRange()
|
||||||
|
atStart.collapse(true)
|
||||||
|
atStart.insertNode(start)
|
||||||
|
|
||||||
|
const leave = (done: boolean): boolean => {
|
||||||
|
start.remove()
|
||||||
|
end.remove()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
if (textBetween(root, start, end).length === 0) return leave(false)
|
||||||
|
|
||||||
|
lift(root, end, kind, false)
|
||||||
|
lift(root, start, kind, true)
|
||||||
|
|
||||||
|
const text = textBetween(root, start, end)
|
||||||
|
const runs = Array.from(root.querySelectorAll<HTMLElement>(`[data-fmt="${kind}"]`)).filter(
|
||||||
|
(run) => start.compareDocumentPosition(run) & Node.DOCUMENT_POSITION_FOLLOWING && end.compareDocumentPosition(run) & Node.DOCUMENT_POSITION_PRECEDING
|
||||||
|
)
|
||||||
|
const covered = text.every((t) => {
|
||||||
|
for (let n: Node | null = t.parentNode; n && n !== root; n = n.parentNode) if (isKind(n, kind)) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (kind === 'color' && !colour) {
|
||||||
|
if (runs.length === 0) return leave(false)
|
||||||
|
}
|
||||||
|
// A colour is never toggled by choosing it again: a different one replaces.
|
||||||
|
const off = kind === 'color' ? !colour : covered
|
||||||
|
for (const run of runs) unwrap(run)
|
||||||
|
if (!off) {
|
||||||
|
const around = document.createRange()
|
||||||
|
around.setStartAfter(start)
|
||||||
|
around.setEndBefore(end)
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.dataset.fmt = kind
|
||||||
|
if (kind === 'color' && colour) {
|
||||||
|
span.dataset.color = colour
|
||||||
|
span.style.color = colour
|
||||||
|
}
|
||||||
|
span.append(around.extractContents())
|
||||||
|
around.insertNode(span)
|
||||||
|
}
|
||||||
|
|
||||||
|
dropEmpty(root)
|
||||||
|
mergeRuns(root)
|
||||||
|
|
||||||
|
// The markers say where the selection was, as a count of characters; the
|
||||||
|
// text nodes are about to be joined, and a count survives that.
|
||||||
|
const from = charactersBefore(root, start)
|
||||||
|
const to = charactersBefore(root, end)
|
||||||
|
start.remove()
|
||||||
|
end.remove()
|
||||||
|
root.normalize()
|
||||||
|
range.setStart(...place(root, from, true))
|
||||||
|
range.setEnd(...place(root, to, false))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a point in the box is, by child positions from the box down, so it can be found again in a copy. */
|
||||||
|
function pathOf(root: Node, node: Node, offset: number): number[] {
|
||||||
|
const path = [offset]
|
||||||
|
for (let n: Node = node; n !== root; n = n.parentNode as Node) {
|
||||||
|
path.unshift(Array.prototype.indexOf.call((n.parentNode as Node).childNodes, n))
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(root: Node, path: number[]): [Node, number] {
|
||||||
|
let node = root
|
||||||
|
for (const index of path.slice(0, -1)) node = node.childNodes[index]
|
||||||
|
return [node, path[path.length - 1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a format to what is selected in the box, or takes it off again if
|
||||||
|
* all of the selection already has it. Returns whether it did anything.
|
||||||
|
*
|
||||||
|
* The change is worked out on a copy of the box and put back by replacing the
|
||||||
|
* box's contents with one `insertHTML`, so it is a single step of the box's
|
||||||
|
* own undo - and so the browser's habit of keeping the formatting of what a
|
||||||
|
* replacement lands in cannot put back a format that was just taken off.
|
||||||
|
*/
|
||||||
|
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
|
||||||
|
const before = root.innerHTML
|
||||||
|
const beforeSelection = currentSelection(root)
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return false
|
||||||
|
const picked = selection.getRangeAt(0)
|
||||||
|
if (picked.collapsed || !root.contains(picked.commonAncestorContainer)) return false
|
||||||
|
root.focus()
|
||||||
|
|
||||||
|
const copy = root.cloneNode(true) as HTMLElement
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(...resolve(copy, pathOf(root, picked.startContainer, picked.startOffset)))
|
||||||
|
range.setEnd(...resolve(copy, pathOf(root, picked.endContainer, picked.endOffset)))
|
||||||
|
if (!transformFormat(copy, range, kind, colour)) return false
|
||||||
|
|
||||||
|
const start = pathOf(copy, range.startContainer, range.startOffset)
|
||||||
|
const end = pathOf(copy, range.endContainer, range.endOffset)
|
||||||
|
selection.selectAllChildren(root)
|
||||||
|
document.execCommand('insertHTML', false, copy.innerHTML)
|
||||||
|
|
||||||
|
// Left selected, so another format can be stacked on without selecting again.
|
||||||
|
select(root, { start, end })
|
||||||
|
if (beforeSelection) remember({ before, beforeSelection, after: root.innerHTML, afterSelection: { start, end } })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Picked {
|
||||||
|
start: number[]
|
||||||
|
end: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the box held and had selected either side of a format, so undo and redo can put the selection back. */
|
||||||
|
interface Edit {
|
||||||
|
before: string
|
||||||
|
beforeSelection: Picked
|
||||||
|
after: string
|
||||||
|
afterSelection: Picked
|
||||||
|
}
|
||||||
|
|
||||||
|
const edits: Edit[] = []
|
||||||
|
|
||||||
|
function remember(edit: Edit): void {
|
||||||
|
edits.push(edit)
|
||||||
|
if (edits.length > 100) edits.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSelection(root: HTMLElement): Picked | null {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return null
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null
|
||||||
|
return { start: pathOf(root, range.startContainer, range.startOffset), end: pathOf(root, range.endContainer, range.endOffset) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(root: HTMLElement, picked: Picked): void {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
try {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(...resolve(root, picked.start))
|
||||||
|
range.setEnd(...resolve(root, picked.end))
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
} catch {
|
||||||
|
/* the contents are not built the way they were; the caret is wherever the browser left it */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo and redo step over a format as the whole box being replaced, and the
|
||||||
|
* browser leaves everything selected afterwards - where the next key typed
|
||||||
|
* would replace the message. Called after either, this puts back the
|
||||||
|
* selection that was there at that point, when the box holds exactly what it
|
||||||
|
* held then.
|
||||||
|
*/
|
||||||
|
export function restoreSelectionAfterHistory(root: HTMLElement, inputType: string): void {
|
||||||
|
const undo = inputType === 'historyUndo'
|
||||||
|
if (!undo && inputType !== 'historyRedo') return
|
||||||
|
const now = root.innerHTML
|
||||||
|
for (let i = edits.length - 1; i >= 0; i--) {
|
||||||
|
const edit = edits[i]
|
||||||
|
if ((undo ? edit.before : edit.after) === now) {
|
||||||
|
select(root, undo ? edit.beforeSelection : edit.afterSelection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { SEARCH_ENGINES, cleanQuery, engineName, searchUrl, shortened, validTemplate } from './searchengines'
|
||||||
|
|
||||||
|
describe('where a search goes', () => {
|
||||||
|
it('encodes the selection into the engine the person chose', () => {
|
||||||
|
expect(searchUrl('hello world & more', 'google')).toBe('https://www.google.com/search?q=hello%20world%20%26%20more')
|
||||||
|
expect(searchUrl('café', 'duckduckgo')).toBe('https://duckduckgo.com/?q=caf%C3%A9')
|
||||||
|
expect(searchUrl('x', 'yandex')).toBe('https://yandex.com/search/?text=x')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the first engine for one it does not know', () => {
|
||||||
|
expect(searchUrl('x', 'nonsense')).toBe(searchUrl('x', SEARCH_ENGINES[0].id))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has every engine ask for the query once', () => {
|
||||||
|
for (const e of SEARCH_ENGINES.filter((e) => e.id !== 'custom')) {
|
||||||
|
expect(e.template.split('%s')).toHaveLength(2)
|
||||||
|
expect(e.template.startsWith('https://')).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches nothing for nothing', () => {
|
||||||
|
expect(searchUrl(' \n ', 'google')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a replacement pattern in the query as the text it is', () => {
|
||||||
|
expect(searchUrl('$& $1', 'google')).toBe('https://www.google.com/search?q=%24%26%20%241')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the query', () => {
|
||||||
|
it('is one line, trimmed', () => {
|
||||||
|
expect(cleanQuery(' two\n\nlines\there ')).toBe('two lines here')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is cut short rather than sent whole', () => {
|
||||||
|
expect(cleanQuery('a'.repeat(1000)).length).toBe(300)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is shortened for a menu entry', () => {
|
||||||
|
expect(shortened('short')).toBe('short')
|
||||||
|
expect(shortened('a very long selected sentence that goes on and on')).toMatch(/^a very long.*…$/)
|
||||||
|
expect(shortened('a very long selected sentence that goes on and on').length).toBeLessThanOrEqual(28)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('an engine of the person’s own', () => {
|
||||||
|
it('needs a web address with a place for the query', () => {
|
||||||
|
expect(validTemplate('https://example.org/s?q=%s')).toBe(true)
|
||||||
|
expect(validTemplate('http://example.org/%s')).toBe(true)
|
||||||
|
expect(validTemplate('https://example.org/s')).toBe(false)
|
||||||
|
expect(validTemplate('javascript:alert(%s)')).toBe(false)
|
||||||
|
expect(validTemplate('file:///etc/passwd?%s')).toBe(false)
|
||||||
|
expect(validTemplate('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is used when valid and refuses to send anywhere when not', () => {
|
||||||
|
expect(searchUrl('a b', 'custom', 'https://example.org/s?q=%s')).toBe('https://example.org/s?q=a%20b')
|
||||||
|
expect(searchUrl('a b', 'custom', 'not a url')).toBeNull()
|
||||||
|
expect(searchUrl('a b', 'custom')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is named by its host', () => {
|
||||||
|
expect(engineName('custom', 'https://www.example.org/s?q=%s')).toBe('example.org')
|
||||||
|
expect(engineName('custom', 'garbage')).toBe('the web')
|
||||||
|
expect(engineName('google', '')).toBe('Google')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* The web search engines "Search for…" can send selected text to. Each is a
|
||||||
|
* template with `%s` where the (percent-encoded) query goes; the person picks
|
||||||
|
* one in Settings > General, or gives a template of their own.
|
||||||
|
*/
|
||||||
|
export interface SearchEngine {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
/** Where the query goes; `%s` is replaced. Empty for the custom entry, whose template is the person's own. */
|
||||||
|
template: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEARCH_ENGINES: SearchEngine[] = [
|
||||||
|
{ id: 'duckduckgo', label: 'DuckDuckGo', template: 'https://duckduckgo.com/?q=%s' },
|
||||||
|
{ id: 'google', label: 'Google', template: 'https://www.google.com/search?q=%s' },
|
||||||
|
{ id: 'bing', label: 'Bing', template: 'https://www.bing.com/search?q=%s' },
|
||||||
|
{ id: 'brave', label: 'Brave Search', template: 'https://search.brave.com/search?q=%s' },
|
||||||
|
{ id: 'startpage', label: 'Startpage', template: 'https://www.startpage.com/do/search?q=%s' },
|
||||||
|
{ id: 'ecosia', label: 'Ecosia', template: 'https://www.ecosia.org/search?q=%s' },
|
||||||
|
{ id: 'qwant', label: 'Qwant', template: 'https://www.qwant.com/?q=%s' },
|
||||||
|
{ id: 'yandex', label: 'Yandex', template: 'https://yandex.com/search/?text=%s' },
|
||||||
|
{ id: 'kagi', label: 'Kagi', template: 'https://kagi.com/search?q=%s' },
|
||||||
|
{ id: 'custom', label: 'Other…', template: '' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_SEARCH_ENGINE = 'duckduckgo'
|
||||||
|
|
||||||
|
/** The longest query sent: a whole pasted wall of text is not a search, and a URL has limits. */
|
||||||
|
const MAX_QUERY = 300
|
||||||
|
|
||||||
|
/** A custom template is usable if it is a web address with a place for the query. */
|
||||||
|
export function validTemplate(template: string): boolean {
|
||||||
|
return /^https?:\/\/[^\s%]+/i.test(template.trim()) && template.includes('%s')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the person chose, as a template - or null if it is custom and not (yet) usable. */
|
||||||
|
function templateFor(engineId: string, custom: string): string | null {
|
||||||
|
const engine = SEARCH_ENGINES.find((e) => e.id === engineId) ?? SEARCH_ENGINES[0]
|
||||||
|
if (engine.id === 'custom') return validTemplate(custom) ? custom.trim() : null
|
||||||
|
return engine.template
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name shown on the menu entry: the engine's, or the custom address's host. */
|
||||||
|
export function engineName(engineId: string, custom: string): string {
|
||||||
|
const engine = SEARCH_ENGINES.find((e) => e.id === engineId) ?? SEARCH_ENGINES[0]
|
||||||
|
if (engine.id !== 'custom') return engine.label
|
||||||
|
try {
|
||||||
|
return new URL(custom.trim().replace('%s', 'x')).hostname.replace(/^www\./, '') || 'the web'
|
||||||
|
} catch {
|
||||||
|
return 'the web'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The query as it will be sent: one line, trimmed, and not absurdly long. */
|
||||||
|
export function cleanQuery(text: string): string {
|
||||||
|
const one = text.replace(/\s+/g, ' ').trim()
|
||||||
|
return one.length > MAX_QUERY ? one.slice(0, MAX_QUERY).trimEnd() : one
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The address to open to search for `text`, or null if there is nothing to search for or nowhere to send it. */
|
||||||
|
export function searchUrl(text: string, engineId: string, custom = ''): string | null {
|
||||||
|
const query = cleanQuery(text)
|
||||||
|
const template = templateFor(engineId, custom)
|
||||||
|
if (!query || !template) return null
|
||||||
|
// Split rather than replaced: a `$&` in the query is not a replacement pattern.
|
||||||
|
return template.split('%s').join(encodeURIComponent(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A selection shortened to fit a menu entry. */
|
||||||
|
export function shortened(text: string, max = 28): string {
|
||||||
|
const one = cleanQuery(text)
|
||||||
|
return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one
|
||||||
|
}
|
||||||
@@ -119,4 +119,6 @@ export interface EditMenuRequest {
|
|||||||
suggestions: string[]
|
suggestions: string[]
|
||||||
can: Record<EditAction, boolean>
|
can: Record<EditAction, boolean>
|
||||||
hasSelection: boolean
|
hasSelection: boolean
|
||||||
|
/** What is selected, for searching the web for it. */
|
||||||
|
selection: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user