Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 a39dad3ac3 Merge development into master: 1.3.0, Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:28:47 -04:00
SalastilandClaude Sonnet 5.5 0c331abf2c The GIF check shows IRC as a Matrix account to reach the picker (IRC has none); CHANGELOG: search and formatting
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:28:39 -04:00
SalastilandClaude Sonnet 5.5 4233439565 Search the web for selected text: in a message's menu and the box's, with the engine chosen in Settings > General
DuckDuckGo (default), Google, Bing, Brave, Startpage, Ecosia, Qwant, Yandex, Kagi, or an address of one's own with %s; opened in the default browser.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:18:28 -04:00
SalastilandClaude Sonnet 5.5 496d18f6fd The message box: a format toggles off, over a whole run, part of one or a mixed selection, and undo steps back over it with the selection kept
Worked out on a copy of the box and put back with one insertHTML (one undo step, and the browser no longer re-applies the format of what it replaced); undo and redo put back the selection that was there.

Fixes #336

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:18:28 -04:00
SalastilandClaude Sonnet 5.5 c3f1f1c413 Merge development into master: 1.3.0, Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:03:07 -04:00
SalastilandClaude Sonnet 5.5 6bc4db8880 1.3.0: Telegram; CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:02:55 -04:00
SalastilandClaude Sonnet 5.5 3a6c8da6ed Discord install headers; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 18:49:19 -04:00
SalastilandClaude Sonnet 5.5 ab8f40f53e Discord session resume, catch-up and throttles; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 18:19:27 -04:00
SalastilandClaude Sonnet 5.5 8f62f44517 Discord: the Connect anyway button; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 23:52:43 -04:00
SalastilandClaude Sonnet 5.5 59f978d03b Discord login limit; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 23:41:57 -04:00
SalastilandClaude Sonnet 5.5 de8674683e Telegram GIF picker; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:41:46 -04:00
SalastilandClaude Sonnet 5.5 7a2fda095e No GIF picker on IRC or Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:36:35 -04:00
SalastilandClaude Sonnet 5.5 40505af989 The GIF picker is offered only where a GIF link shows as a picture: not Steam, Twitch or Sneedchat
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:33:19 -04:00
Salastil b3a9077887 Merge development into master: Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	nobilis
2026-10-19 22:47:52 -04:00
SalastilandClaude Sonnet 5.5 b81810f5cc Merge development into master: 1.2.1, Steam identity
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 17:14:25 -04:00
SalastilandClaude Sonnet 5.5 44208364cd Merge development into master: 1.2.0, Twitch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:55:18 -04:00
SalastilandClaude Sonnet 5.5 335ac8b59d Merge development into master: 1.1.0, Steam
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:49:35 -04:00
23 changed files with 1093 additions and 73 deletions
+23
View File
@@ -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
+28
View File
@@ -17,6 +17,34 @@ Each section has two parts:
that made it, including dependency updates
(`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]
### Fixes, patches and changes
+1 -2
View File
@@ -1,8 +1,7 @@
# Maintainer: Salastil <[email protected]>
pkgname=moho-git
pkgver=r1.268751e
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')
url="https://github.com/Moho-Chat/moho"
license=('GPL3')
+1 -1
Submodule nobilis updated: ad597ea2d2...264ee7a1f5
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.2.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.2.1",
"version": "1.3.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "moho",
"version": "1.2.1",
"version": "1.3.0",
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
"author": "Salastil <[email protected]>",
"license": "GPL-3.0-or-later",
+171
View File
@@ -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)
+148
View File
@@ -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
View File
@@ -101,8 +101,18 @@ try {
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))
// The picker.
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where there is a Discord account')
// The picker. It is offered only where a GIF link shows as a picture - Discord, Matrix, Kick - and none of those can be
// 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 until(page, 'document.querySelector(".gif-picker")', 'the picker')
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.
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
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')
} catch (e) {
console.log('FAIL', e.message)
+2 -1
View File
@@ -356,7 +356,8 @@ function wireEditMenu(win: BrowserWindow): void {
delete: f.canDelete,
selectAll: f.canSelectAll
},
hasSelection: params.selectionText.length > 0
hasSelection: params.selectionText.length > 0,
selection: params.selectionText.slice(0, 1000)
} satisfies EditMenuRequest)
})
}
+5
View File
@@ -6264,6 +6264,11 @@ video.lightbox-media {
overflow: hidden;
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 ---------------------------------------------- */
+15 -1
View File
@@ -332,6 +332,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
// What the daemon last said about why. Only meaningful while something is
// wrong, which is exactly when the state line alone explains nothing.
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 => {
void window.moho
@@ -353,7 +354,20 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
<div className="small muted">
{serviceLabel(account.service)} · {account.state}
</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>
<button
type="button"
+12 -5
View File
@@ -1,6 +1,6 @@
import { FormatBar } from './FormatBar'
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 { useEffect, useMemo, useRef, useState } from 'react'
import { Icon, IconButton, MaskIcon } from './Icon'
@@ -51,6 +51,9 @@ interface StagedAttachment {
*/
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.
*
@@ -1178,6 +1181,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
aria-multiline="true"
aria-label={thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
onInput={(e) => {
restoreSelectionAfterHistory(e.currentTarget, (e.nativeEvent as InputEvent).inputType)
setText(composerText(e.currentTarget))
noteTyping()
noteMention()
@@ -1343,10 +1347,12 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
<Icon name="mood" size={18} />
</button>
{/* GIFs, wherever there is a Discord account to ask for them: the
picker borrows that account's way of looking, and what it finds can
be sent in any conversation - except Twitch's, whose chat takes no pictures. */}
{!thread && service !== 'steam' && service !== 'twitch' && accounts.some((a) => a.service === 'discord') && (
{/* GIFs, wherever there is a Discord account to ask for them: the picker borrows that account's way of looking.
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
(GIF_SERVICES): not IRC, Steam, Twitch or Sneedchat, which take none. Telegram has GIFs of its own - the account's
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
ref={gifButtonRef}
type="button"
@@ -1426,6 +1432,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
bufferId={buffer.id}
accountId={buffer.accountId}
discord={service === 'discord'}
telegram={service === 'telegram'}
onClose={() => setGifPicker(false)}
/>
)}
+16
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { ContextMenu, type MenuEntry } from './ContextMenu'
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
@@ -14,6 +16,8 @@ import type { EditAction, EditMenuRequest } from '../../../shared/ipc'
export function EditMenu(): JSX.Element | null {
const [menu, setMenu] = useState<EditMenuRequest | null>(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(
() =>
@@ -79,6 +83,18 @@ export function EditMenu(): JSX.Element | null {
{ separator: true },
{ 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)} />
}
+10 -2
View File
@@ -43,6 +43,7 @@ export function GifPicker({
bufferId,
accountId,
discord,
telegram,
onClose
}: {
anchor: HTMLElement | null
@@ -50,6 +51,8 @@ export function GifPicker({
accountId: string
/** Whether the conversation is on Discord, which decides what is sent. */
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
}): JSX.Element {
const store = useStore()
@@ -116,7 +119,7 @@ export function GifPicker({
const wait = setTimeout(
() => {
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) => {
if (!current) return
setGifs(answer.gifs)
@@ -135,10 +138,15 @@ export function GifPicker({
current = false
clearTimeout(wait)
}
}, [query])
}, [query, telegram, bufferId])
const pick = (gif: GifEntry): void => {
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.
// Everything else is sent the file, since a page address would be only
// that.
@@ -16,6 +16,7 @@ import { reactionLabel, reactorsSentence } from '../lib/reactions'
import { UploadMeter } from './UploadMeter'
import { RichText } from '../lib/richtext'
import { useChat, usePref, useStore } from '../state/hooks'
import { DEFAULT_SEARCH_ENGINE, engineName, searchUrl, shortened } from '../lib/searchengines'
import { useTick } from '../lib/clock'
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
import { useMediaUrl, useStrictRoute } from '../lib/route'
@@ -567,6 +568,12 @@ function MessageRowBody({
// have let go of the selection.
const selectedRef = useRef('')
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 openProfile = (): void => store.showProfile(bufferId, message.from, message.senderId)
// Back to what this answers, bringing it into view first if it is a long
@@ -618,6 +625,12 @@ function MessageRowBody({
...(selected
? ([{ 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
? ([{ label: 'Copy text', icon: 'content_copy', onClick: () => void window.moho.copyText(message.body) }] as MenuEntry[])
: []),
@@ -6,6 +6,7 @@ import {
DirectorySetting,
SavedMark,
SettingsSection,
DropdownSetting,
SelectionSetting,
StringSetting,
ToggleSetting,
@@ -17,6 +18,7 @@ import { acceleratorLabel, acceleratorOf } from '../../lib/accelerator'
import { AccountsPanel } from '../AccountsPanel'
import { DownloadsPanel } from '../DownloadsPanel'
import { humanBytes } from '../../lib/util'
import { DEFAULT_SEARCH_ENGINE, SEARCH_ENGINES, validTemplate } from '../../lib/searchengines'
import { TunnelAllSwitch, useNetSettings } from './Tunnel'
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 {
const [defaultDir, setDefaultDir] = useState('')
useEffect(() => {
@@ -891,6 +928,8 @@ function GeneralSettings(): JSX.Element {
/>
</SettingsSection>
<SearchSettings />
<SettingsSection title="Media">
<ToggleSetting
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
* (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)
})
})
+290 -55
View File
@@ -1,68 +1,303 @@
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. */
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
* the selection is exactly that format already. Returns whether it did
* anything.
*
* Goes through `insertHTML` rather than rearranging nodes, so the box's own
* undo still steps back over it.
* Takes a marker out of every run of `kind` around it, splitting each run in
* two at the marker. A start marker ends up in front of the second half and
* an end marker behind the first, so what lies between the two markers is
* never inside a run that reaches beyond it.
*/
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return false
const range = selection.getRangeAt(0)
if (range.collapsed || !root.contains(range.commonAncestorContainer)) return false
root.focus()
// The selection can be a whole formatted run (it is, right after one is
// applied, so another can be stacked on), which names the run's parent as
// its ancestor and the run itself only by position.
const whole =
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
function lift(root: Node, point: HTMLElement, kind: FormatKind, atStart: boolean): void {
for (let run = nearestRun(point, root, kind); run; run = nearestRun(point, root, kind)) {
const rest = document.createRange()
if (atStart) rest.setStartBefore(point)
else rest.setStartAfter(point)
rest.setEnd(run, run.childNodes.length)
const tail = run.cloneNode(false) as HTMLElement
tail.append(rest.extractContents())
run.after(tail)
if (atStart) tail.before(point)
else run.after(point)
}
if (kind === 'color' && !(colour && HEX.test(colour))) return false
}
const scratch = document.createElement('div')
scratch.appendChild(range.cloneContents())
const colourAttrs = kind === 'color' ? ` data-color="${colour}" style="color:${colour}"` : ''
document.execCommand(
'insertHTML',
false,
`<span data-fmt="${kind}"${colourAttrs} data-new="1">${scratch.innerHTML}</span>`
/** Every text node under `node`, in document order. */
export function textNodes(node: Node, into: Text[] = []): Text[] {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === 3) into.push(child as Text)
else textNodes(child, into)
}
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) {
made.removeAttribute('data-new')
// 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.
const around = document.createRange()
around.selectNode(made)
selection.removeAllRanges()
selection.addRange(around)
}
function charactersBefore(root: Node, point: Node): number {
return textNodes(root).reduce((sum, n) => sum + (point.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING ? n.nodeValue!.length : 0), 0)
}
/** 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. */
function place(root: Node, count: number, atStart: boolean): [Node, number] {
const text = textNodes(root)
let seen = 0
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
}
/** 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')
})
})
+73
View File
@@ -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
}
+2
View File
@@ -119,4 +119,6 @@ export interface EditMenuRequest {
suggestions: string[]
can: Record<EditAction, boolean>
hasSelection: boolean
/** What is selected, for searching the web for it. */
selection: string
}