8 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 3561a9d511 Merge development into master: 1.3.2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:36:07 -04:00
SalastilandClaude Sonnet 5.5 abc2c9adc2 1.3.2: Discord safeguards from Dissent's; reply quotes; CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:36:04 -04:00
SalastilandClaude Sonnet 5.5 74f2c4750f Discord safeguards (#338): typing off by default, acks only while the window is looked at, a notice of the risk when adding an account; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:23:17 -04:00
SalastilandClaude Sonnet 5.5 df818a13bb A reply's quote draws what it quotes: emotes, emoji and stickers as pictures, not their codes, in the line over a reply and in the bar over the box, on every service
Twitch's parent text has no emotes, so the original on screen is quoted where it is there.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 22:07:16 -04:00
SalastilandClaude Sonnet 5.5 75a49c4f75 Merge development into master: 1.3.1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 21:03:52 -04:00
SalastilandClaude Sonnet 5.5 10f0180aab 1.3.1: Discord first-word lock; CHANGELOG
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 21:03:45 -04:00
SalastilandClaude Sonnet 5.5 b04189ad6b Discord: first-word lock, nonce, upload limit and send pacing; window banner and check; nobilis pointer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 20:42:43 -04:00
SalastilandClaude Sonnet 5.5 5b438be371 The message box: bold, italic, underline and strikethrough are the browser's own, which toggle off from all of the box and keep its undo; the others are put back without the browser re-applying what it replaced
Select all then bold twice left the box bold: the browser keeps the formatting of the run it replaces when the selection is everything in it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 20:01:48 -04:00
18 changed files with 429 additions and 20 deletions
+16
View File
@@ -17,6 +17,22 @@ Each section has two parts:
that made it, including dependency updates
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
## [1.3.2]
### Fixes, patches and changes
- Discord is asked for less and more politely, after what Dissent does to avoid being flagged as a third-party client: reads wait out a rate limit and let an empty bucket refill instead of treating a refusal as an answer; no history is fetched at login (a conversation is read the first time it is opened, 30 messages); a member list is subscribed to once a connection and no longer again at every join and leave; our own membership of each server comes from login and is not asked for; a read mark is sent only while the window is in front, never for a conversation whose last word is yours, and not again after another device sent it; text Discord would refuse (over 2,000 characters, 4,000 with Nitro, or empty) is refused before it is sent; the login declares the capabilities of a user client; the first reconnect wait is four seconds ([moho `74f2c47`](https://github.com/Moho-Chat/moho/commit/74f2c47), [nobilis `7943767`](https://github.com/Moho-Chat/nobilis/commit/7943767))
- The typing notice to Discord is off unless you turn it on in Settings > Discord, and adding a Discord account says plainly that third-party clients are against Discord's terms and what moho does about that ([moho `74f2c47`](https://github.com/Moho-Chat/moho/commit/74f2c47))
- A reply's quote, in the line over a reply and in the bar over the box, draws what it quotes - emotes, emoji and stickers as pictures instead of their codes - on every service ([moho `df818a1`](https://github.com/Moho-Chat/moho/commit/df818a1))
## [1.3.1]
### Fixes, patches and changes
- Discord is not spoken to first from here: a server where your account has not said anything since it joined, and a conversation with nothing in it, are locked with a note to send the first message from Discord's own app or website, after Discord flagged accounts for exactly that. Whether you have spoken is looked for in what moho keeps and then in Discord's search (one request a server, remembered), the lock lifts by itself once there is a message of yours there, and a message sent from moho does not count ([moho `b04189a`](https://github.com/Moho-Chat/moho/commit/b04189a), [nobilis `b9a79e4`](https://github.com/Moho-Chat/nobilis/commit/b9a79e4))
- Discord messages carry a nonce and an explicit list of who may be mentioned, as Discord's own clients send them; a file is checked against what the account may upload before any of it is sent, and waits a few seconds after a message instead of following straight on ([moho `b04189a`](https://github.com/Moho-Chat/moho/commit/b04189a), [nobilis `b9a79e4`](https://github.com/Moho-Chat/nobilis/commit/b9a79e4))
- Taking bold, italic, underline or strikethrough off all of the message box (select all, then the same button or key) works, and undo and redo step over formatting and leave the selection where it was ([moho `5b438be`](https://github.com/Moho-Chat/moho/commit/5b438be))
## [1.3.0]
### Major features
+1 -1
Submodule nobilis updated: 264ee7a1f5...ec34657d92
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "moho",
"version": "1.3.0",
"version": "1.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "moho",
"version": "1.3.0",
"version": "1.3.2",
"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.3.0",
"version": "1.3.2",
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
"author": "Salastil <[email protected]>",
"license": "GPL-3.0-or-later",
+62 -8
View File
@@ -4,10 +4,11 @@
* 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
* node scripts/check-composer-format.mjs (SERVICE=sneedchat to try another service's box)
*/
import { launchApp, sleep } from './lib/harness.mjs'
const SERVICE = process.env.SERVICE ?? 'discord'
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
const results = []
@@ -21,7 +22,13 @@ const html = () => ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML.
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('')
const native = { B: 'bold', STRONG: 'bold', I: 'italic', EM: 'italic', U: 'underline', S: 'strike', STRIKE: 'strike' }
const walk = (n) => {
if (n.nodeType === 3) return n.nodeValue
const kind = n instanceof HTMLElement ? (n.dataset.fmt ?? native[n.nodeName]) : null
const inner = [...n.childNodes].map(walk).join('')
return kind ? (mark[kind] ?? '') + inner + (kind === 'quote' ? '' : mark[kind] ?? '') : inner
}
return walk(document.querySelector(${JSON.stringify(BOX)}))
})()`)
const type = async (text) => {
@@ -62,10 +69,10 @@ try {
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')
const acct = { id: '${SERVICE}:me', service: '${SERVICE}', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const buf = { id: '${SERVICE}:me|chat', accountId: '${SERVICE}:me', kind: 'channel', name: 'chat', lastActivityTs: 5, category: 'Channels', groupId: 'account:${SERVICE}:me' }
s.set({ accounts: [acct], groups: [{ id: 'account:${SERVICE}:me', accountId: '${SERVICE}:me', service: '${SERVICE}', kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { '${SERVICE}:me|chat': [] }, activeBufferId: '${SERVICE}:me|chat' })
s.selectGroup('account:${SERVICE}:me')
})()`)
await sleep(1200)
@@ -90,6 +97,53 @@ try {
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('All of the box')
await type('one two three')
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); getSelection().selectAllChildren(b) })()`)
await bold()
check('everything goes bold (select all, then the key)', (await marked()) === '**one two three**', await marked())
await bold()
check('and the key again takes it all off', (await marked()) === 'one two three', await marked())
await bold()
await undo()
check('and undo puts the box back as it was', (await marked()) === 'one two three', await marked())
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); getSelection().selectAllChildren(b) })()`)
await sleep(250)
await strip('Bold')
await sleep(250)
check('everything goes bold from the strip', (await marked()) === '**one two three**', await marked())
await strip('Bold')
check('and the strip again takes it all off', (await marked()) === 'one two three', await marked())
for (const [title, m] of [['Code', '`'], ['Spoiler', '||'], ['Italic', '_']]) {
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); getSelection().selectAllChildren(b) })()`)
await sleep(250)
await strip(title)
const on = await marked()
await sleep(250)
await strip(title)
check(`${title}: all of the box goes on and comes off again`, on === `${m}one two three${m}` && (await marked()) === 'one two three', `${on} -> ${await marked()}`)
}
console.log('Formats the window makes itself (code, spoiler) undo as well')
for (const [title, m] of [['Code', '`'], ['Spoiler', '||']]) {
await type('one two three four')
await select('two')
await sleep(250)
await strip(title)
const on = await marked()
await undo()
const off = await marked()
const kept = await ev(`String(getSelection())`)
await redo()
check(`${title}: on, undo takes it off with the word still selected, redo puts it on`, on === `one ${m}two${m} three four` && off === 'one two three four' && kept === 'two' && (await marked()) === on, `${on} | ${off} | ${kept} | ${await marked()}`)
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); getSelection().selectAllChildren(b) })()`)
await sleep(250)
await strip(title)
await strip(title)
await undo()
check(`${title}: over all of the box, taken off and undone`, (await marked()).replace(/[\`|]/g, '') === 'one two three four' && (await marked()).includes(m), await marked())
}
console.log('Undo leaves a selection that is safe to type over')
await type('one two three four')
await select('two')
@@ -130,9 +184,9 @@ try {
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)
check('a selection over two lines is bold, and the break is still there', /<b>|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())
check('and comes off whole', !/data-fmt|<b>/.test(await html()), await html())
console.log('Part of a run')
await type('one two three four')
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* The message box in a Discord server (or conversation) the account has not spoken in: replaced by the reason, in a
* scratch moho with injected state (nothing is contacted). The daemon decides the lock; this checks what the window
* makes of the `readOnly` it is given.
*
* node scripts/check-first-word-lock.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 REASON = "Send your first message in this server from Discord's own app or website. Discord flags accounts that start talking in a server from another client."
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 mk = (name, readOnly) => ({ id: 'discord:me|' + name, accountId: 'discord:me', kind: 'channel', name, lastActivityTs: 5, category: 'Channels', groupId: 'account:discord:me', ...(readOnly ? { readOnly } : {}) })
s.set({ accounts: [acct], groups: [{ id: 'account:discord:me', accountId: 'discord:me', service: 'discord', kind: 'account', name: 'me', position: 0 }], buffers: [mk('chasm/#general', ${JSON.stringify(REASON)}), mk('chasm/#open')], messagesByBuffer: { 'discord:me|chasm/#general': [], 'discord:me|chasm/#open': [] }, activeBufferId: 'discord:me|chasm/#general' })
s.selectGroup('account:discord:me')
})()`)
await sleep(1000)
check('a locked server shows the reason where the box would be', (await ev(`document.querySelector('.composer-readonly')?.innerText ?? ''`)).includes('first message in this server'))
check('with a line saying moho opens it by itself', (await ev(`document.querySelector('.composer-readonly')?.innerText ?? ''`)).includes('opens this up by itself'))
check('and no message box to type in', !(await ev(`!!document.querySelector('.composer-input')`)))
if (out) {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'first-word-lock.png'), Buffer.from(data, 'base64'))
}
await ev(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.map((b) => ({ ...b, readOnly: undefined })) }) })()`)
await sleep(500)
check('once the lock is lifted the box is back', await ev(`!!document.querySelector('.composer-input')`))
} catch (e) {
console.log(' FAIL', e.message)
results.push(false)
} finally {
await stop()
}
process.exit(results.every(Boolean) ? 0 : 1)
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* The line above a reply, and the bar over the box while one is written, in a scratch moho with injected state
* (nothing is contacted): what the reply quotes is drawn as the original is - an emote or emoji as its picture, not
* its code - on every service that has such codes, and text that has none is untouched.
*
* node scripts/check-reply-emotes.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)
}
// What each service's stored text carries for a picture, and what must not be left on screen.
const CASES = [
{ service: 'kick', body: '[emote:5756632:SUSSY][emote:5756632:SUSSY] lol', code: '[emote:', pictures: 2 },
{ service: 'discord', body: 'nice <:pog:123456789012345678> yes', code: '<:pog', pictures: 1 },
{ service: 'twitch', body: '[twitch:emote:25:Kappa] hello', code: '[twitch:', pictures: 1 },
// Twitch quotes the parent as plain text, with no word of which are emotes: the original on screen has them.
{ service: 'twitch', body: '[twitch:emote:25:Kappa] hello', quotedAs: 'Kappa hello', code: '[twitch:', pictures: 1 },
{ service: 'steam', body: '[steam:emote:steamhappy] hi', code: '[steam:', pictures: 1 },
{ service: 'sneedchat', body: 'hello :smile: there', code: null, pictures: 0 },
{ service: 'matrix', body: 'plain 😀 words', code: null, pictures: 0 },
{ service: 'irc', body: 'plain 😀 words', code: null, pictures: 0 },
{ service: 'telegram', body: 'plain 😀 words', code: null, pictures: 0 }
]
try {
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
for (const c of CASES) {
console.log(c.service)
await ev(`(() => {
const s = window.__mohoShots.store
const id = ${JSON.stringify(c.service + ':me')}
const acct = { id, service: ${JSON.stringify(c.service)}, name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
const buf = { id: id + '|room', accountId: id, kind: 'channel', name: 'room', lastActivityTs: 5, category: 'Channels', groupId: 'account:' + id }
const t = Math.floor(Date.now() / 1000) - 600
const base = { bufferId: buf.id, ts: t, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false }
const messages = [
{ ...base, id: 'o1', from: 'TAYL31GH', senderId: 't', body: ${JSON.stringify(c.body)} },
{ ...base, id: 'r1', from: 'RildWandy', senderId: 'r', ts: t + 30, body: 'I shit you not lol', replyTo: { id: 'o1', from: 'TAYL31GH', body: ${JSON.stringify(c.quotedAs ?? c.body)}${c.service === 'telegram' ? ", chat: ''" : ''} } }
]
s.set({ accounts: [acct], groups: [{ id: 'account:' + id, accountId: id, service: ${JSON.stringify(c.service)}, kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { [buf.id]: messages }, activeBufferId: buf.id })
s.selectGroup('account:' + id)
})()`)
await sleep(900)
const preview = await ev(`(() => {
const el = document.querySelector('.reply-preview, .tg-quote-body')
return el ? { text: el.innerText, pictures: el.querySelectorAll('img.custom-emoji, img.smilie, .custom-emoji-gone').length, html: el.innerHTML.slice(0, 200) } : null
})()`)
check(`${c.service}: the reply shows what it answers`, !!preview)
if (!preview) continue
if (c.code) check(`${c.service}: and not the emote's code`, !preview.text.includes(c.code) && !preview.html.includes(c.code), JSON.stringify(preview))
if (c.pictures) check(`${c.service}: as ${c.pictures} picture(s)`, preview.pictures === c.pictures, JSON.stringify(preview))
else check(`${c.service}: its words are kept`, /plain|hello/.test(preview.text), JSON.stringify(preview))
if (c.service === 'kick') {
console.log('The bar over the box')
await ev(`window.__mohoShots.store.startReply('o1', 'TAYL31GH', '[emote:5756632:SUSSY] lol')`)
await sleep(600)
const bar = await ev(`(() => { const el = document.querySelector('.composer-reply'); return el ? { text: el.innerText, pictures: el.querySelectorAll('img.custom-emoji, .custom-emoji-gone').length } : null })()`)
check('the bar over the box draws the emote too', !!bar && bar.pictures === 1 && !bar.text.includes('[emote:'), JSON.stringify(bar))
await ev(`window.__mohoShots.store.set({ replyingTo: null })`)
}
if (c.service === 'kick' && out) {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, 'reply-emotes-kick.png'), Buffer.from(data, 'base64'))
}
}
} catch (e) {
console.log(' FAIL', e.message)
results.push(false)
} finally {
await stop()
}
process.exit(results.every(Boolean) ? 0 : 1)
+3 -2
View File
@@ -27,14 +27,15 @@ const shot = async (name) => {
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 setField = (selector, value, how) => ev(`(async () => {
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()
// Time for the window to take the edit before the field is left, as it has when a person types.
if (${JSON.stringify(how)} === 'input') { await new Promise((r) => setTimeout(r, 200)); el.blur() }
return true
})()`)
const openGeneral = async () => {
@@ -995,6 +995,16 @@ function DiscordForm({ accountId }: { accountId?: string }): JSX.Element {
return (
<div className="add-form">
{/* Said before anything is entered, the way Dissent's README says it: this is the risk, and what moho does and does
not do about it. */}
{!accountId && (
<p className="small muted discord-risk" role="note">
Discord does not allow third-party clients, and an account using one can be flagged and logged out, or banned.
moho is careful - it says what it is, makes few requests, and will not send the first message to a server or a
person - but nothing it does can make that risk nothing. Use an account you can afford to lose, and see the
first message in each new place from Discord&apos;s own app.
</p>
)}
{!accountId && <TorSwitch service="discord" on={useTor} onChange={setUseTor} />}
<div className="setting-segmented">
<button
+16 -1
View File
@@ -10,6 +10,7 @@ import { PollComposer } from './PollComposer'
import { Avatar } from './Avatar'
import { COMMON_EMOJI, emojiToken, EmojiPicker, type StickerEntry } from './EmojiPicker'
import { GifPicker } from './GifPicker'
import { ReplyBody } from './ReplyBody'
import { PlaceField } from './PlaceField'
import { VoiceRecorder } from './VoiceRecorder'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
@@ -139,6 +140,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
const [stickers, setStickers] = useState<StickerEntry[]>([])
const [stickerPicker, setStickerPicker] = useState(false)
const [gifPicker, setGifPicker] = useState(false)
const [discordTyping] = usePref<boolean>('discord.sendTyping', false)
/**
* What to do with files dropped on the window: they land in the tray, to be
* looked over and sent with the next message rather than going out on the
@@ -448,6 +450,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
* it on services with no such notion, so there is nothing to check here.
*/
const noteTyping = (): void => {
// Not on Discord unless asked for: its own clients send it, but a third-party client that does is one more thing
// Discord can tell it apart by, and Dissent does without it altogether. A setting in Settings > Discord.
if (service === 'discord' && !discordTyping) return
const now = Date.now()
if (now - typingSentAt.current < TYPING_REPEAT_MS) return
typingSentAt.current = now
@@ -877,6 +882,8 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
// A channel's comments are written to its discussion group, which the channel's own lock says nothing about.
if (buffer.readOnly && !(thread && service === 'telegram')) {
const official = buffer.readOnly.includes('official Discord')
// Discord's first-word lock: the daemon opens it by itself once the account has spoken there.
const firstWord = buffer.readOnly.includes("Discord's own app")
return (
<div className="composer">
<div className="divider-h" />
@@ -887,6 +894,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
{official && (
<span className="small">Discord will never ask you for your password or account token.</span>
)}
{firstWord && <span className="small">moho notices when you have, and opens this up by itself.</span>}
</div>
</div>
</div>
@@ -1039,7 +1047,14 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
{repliedTo?.avatarUrl && <img className="reply-avatar" src={media(repliedTo.avatarUrl)} alt="" />}
<span className="ellipsis">
Replying to <strong>{replyingTo.from}</strong>
{replyingTo.body ? <span className="muted">{`: ${replyingTo.body}`}</span> : ''}
{replyingTo.body ? (
<span className="muted">
{': '}
<ReplyBody text={replyingTo.body} service={service} />
</span>
) : (
''
)}
</span>
{/* Whether the one answered is told, as Discord's own bar has it.
Only there: nothing else here can say it. */}
+17 -3
View File
@@ -15,6 +15,7 @@ import { COMMON_EMOJI, EmojiPicker, readRecent, type StickerEntry } from './Emoj
import { reactionLabel, reactorsSentence } from '../lib/reactions'
import { UploadMeter } from './UploadMeter'
import { RichText } from '../lib/richtext'
import { ReplyBody } from './ReplyBody'
import { useChat, usePref, useStore } from '../state/hooks'
import { DEFAULT_SEARCH_ENGINE, engineName, searchUrl, shortened } from '../lib/searchengines'
import { useTick } from '../lib/clock'
@@ -382,6 +383,9 @@ function MessageRowBody({
const twitchModerator = useChat((s) => s.twitchRights[bufferId] === true)
const twitchBlocker = useChat((s) => s.accounts.find((a) => bufferId.startsWith(`${a.id}|`))?.moderation === true)
const original = useChat((s) => (replyId ? s.messagesByBuffer[bufferId]?.find((m) => m.id === replyId) : undefined))
// What a reply quotes. Twitch sends the parent's words as plain text, with no word of which are emotes; where the
// original is on screen it has them, as it was drawn, so that is what is quoted.
const quoted = service === 'twitch' && original?.body ? original.body : message.replyTo?.body ?? ''
// Which message is open for editing is the window's, not each row's, so
// Up arrow in the message box can open the last one.
const editing = useChat((s) => s.editingId === message.id)
@@ -1099,7 +1103,11 @@ function MessageRowBody({
>
<Icon name="forum" size={13} />
<span>In thread</span>
{message.replyTo.body && <span className="ellipsis">{message.replyTo.body}</span>}
{message.replyTo.body && (
<span className="ellipsis">
<ReplyBody text={message.replyTo.body} service={service} channels={channels} />
</span>
)}
</button>
) : service === 'telegram' && message.replyTo.forwarded ? (
// Where a forwarded message came from, as Telegram says it: "Forwarded from" and the source, which is
@@ -1161,7 +1169,11 @@ function MessageRowBody({
{message.replyTo.chat && <Icon name="campaign" size={13} />}
{message.replyTo.from || 'Message'}
</div>
{message.replyTo.body && <div className="tg-quote-body">{message.replyTo.body}</div>}
{message.replyTo.body && (
<div className="tg-quote-body">
<ReplyBody text={message.replyTo.body} service={service} channels={channels} />
</div>
)}
<Icon name="format_quote" size={16} className="tg-quote-mark" />
</div>
) : (
@@ -1200,7 +1212,9 @@ function MessageRowBody({
>
{message.replyTo.from}
</span>
<span className="ellipsis">{message.replyTo.body}</span>
<span className="ellipsis">
<ReplyBody text={quoted} service={service} channels={channels} />
</span>
{/* Kick groups a chain of replies under the message it began
with; a reply is a way into that conversation. */}
{!inThread && message.replyTo.threadRoot && (
+18
View File
@@ -0,0 +1,18 @@
import { useMemo } from 'react'
import { RichText } from '../lib/richtext'
import { replyPreviewHtml } from '../lib/replypreview'
import { useChat, usePref } from '../state/hooks'
import type { ChannelIndex } from '../lib/format'
/**
* What a reply is answering, as words: the original's text drawn the way its own line is - its emotes, emoji and
* stickers as pictures, not as the codes they travel as.
*/
export function ReplyBody({ text, service, channels }: { text: string; service?: string; channels?: ChannelIndex }): JSX.Element {
const smilies = useChat((s) => s.smilieIndex)
const [ircColours] = usePref<boolean>('irc.renderColours', true)
const html = useMemo(() => replyPreviewHtml(text, { service, smilies, channels, ircColours }), [text, service, smilies, channels, ircColours])
// Ours: the markup is what the formatter built from the text, so the pictures it names are public emote and emoji
// addresses. A link is not followed from here, and a spoiler is not opened.
return <RichText html={html} ownMarkup onRevealSpoiler={() => {}} onOpenChannel={() => {}} onOpenLink={() => {}} />
}
@@ -1532,6 +1532,18 @@ function DiscordSettings(): JSX.Element {
}
return (
<>
<SettingsSection
title="Typing"
description="Discord's own apps tell others when you are typing. A client that is not Discord's own is told apart from them by exactly this kind of traffic, and accounts using one have been flagged, so moho does not send it unless you turn it on. You still see when other people are typing."
>
<ToggleSetting
settingKey="discord.sendTyping"
label="Tell others when I am typing"
description="Sends Discord's typing notice, at most once every eight seconds in a conversation."
defaultValue={false}
/>
</SettingsSection>
<SettingsSection
title="Scrollback"
description="Re-reads the last 50 messages of every Discord conversation on every account and stores any that are missing here. Nothing already stored is changed. A conversation takes about a third of a second, so a large account takes a few minutes."
@@ -1544,6 +1556,7 @@ function DiscordSettings(): JSX.Element {
</div>
{result && <p className="small muted">{result}</p>}
</SettingsSection>
</>
)
}
@@ -69,6 +69,21 @@ describe('serialising the box', () => {
})
})
describe('what the browser makes in the box', () => {
it('reads its own bold, italic, underline and strikethrough as ours', () => {
const made = box(el('B', [text('b')]), text(' '), el('I', [text('i')]), text(' '), el('U', [text('u')]), text(' '), el('STRIKE', [text('s')]))
expect(serialize(made, 'discord')).toBe('**b** *i* __u__ ~~s~~')
expect(serialize(made, 'sneedchat')).toBe('[b]b[/b] [i]i[/i] [u]u[/u] [s]s[/s]')
})
it('sees through the plain wrapper put round content when a format is taken off', () => {
const wrapped = box(el('SPAN', [text('one '), fmt('bold', [text('two')]), text(' three')], { plain: '' }))
expect(serialize(wrapped, 'discord')).toBe('one **two** three')
})
it('does not say bold twice for bold inside bold', () => {
expect(serialize(box(el('B', [fmt('bold', [text('x')])])), 'discord')).toBe('**x**')
})
})
describe('markup details', () => {
it('fences code with more backticks than it holds', () => {
expect(wrap('discord', 'code', 'a`b')).toBe('``a`b``')
+26 -2
View File
@@ -190,6 +190,9 @@ export function transformFormat(root: HTMLElement, range: Range, kind: FormatKin
return true
}
/** The formats the browser can make and unmake itself, and its name for each. */
const NATIVE: Partial<Record<FormatKind, string>> = { bold: 'bold', italic: 'italic', underline: 'underline', strike: 'strikeThrough' }
/** 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]
@@ -215,6 +218,16 @@ function resolve(root: Node, path: number[]): [Node, number] {
* replacement lands in cannot put back a format that was just taken off.
*/
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
const native = NATIVE[kind]
if (native) {
// The browser's own bold, italic, underline and strikethrough: it knows how to take them off again from all of a
// selection, part of one or the whole box - the last of which the replacement below cannot do, because the
// browser keeps the formatting of what it replaces when that is everything in the box - and its undo steps over
// them and puts the selection back by itself. Serialising reads the elements it makes as it does ours.
root.focus()
document.execCommand('styleWithCSS', false, 'false')
return document.execCommand(native)
}
const before = root.innerHTML
const beforeSelection = currentSelection(root)
const selection = window.getSelection()
@@ -224,9 +237,20 @@ export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string
root.focus()
const copy = root.cloneNode(true) as HTMLElement
// Content that begins with plain text is put in a wrapper of its own. The browser keeps the formatting of whatever
// it replaces when the replacement is text and the selection is everything inside one formatted run - so taking a
// format off all of the box put it straight back - and does not when the replacement starts with an element.
const wrapped = copy.firstChild?.nodeType === 3
const shift = (path: number[]): number[] => (wrapped ? [0, ...path] : path)
if (wrapped) {
const wrapper = document.createElement('span')
wrapper.dataset.plain = ''
wrapper.append(...Array.from(copy.childNodes))
copy.append(wrapper)
}
const range = document.createRange()
range.setStart(...resolve(copy, pathOf(root, picked.startContainer, picked.startOffset)))
range.setEnd(...resolve(copy, pathOf(root, picked.endContainer, picked.endOffset)))
range.setStart(...resolve(copy, shift(pathOf(root, picked.startContainer, picked.startOffset))))
range.setEnd(...resolve(copy, shift(pathOf(root, picked.endContainer, picked.endOffset))))
if (!transformFormat(copy, range, kind, colour)) return false
const start = pathOf(copy, range.startContainer, range.startOffset)
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { replyPreviewHtml } from './replypreview'
describe('what a reply quotes, as words', () => {
it('draws a Kick emote as its picture, not its code', () => {
const html = replyPreviewHtml('[emote:5756632:SUSSY][emote:5756632:SUSSY]', { service: 'kick' })
expect(html).not.toContain('[emote:')
expect(html.match(/<img /g)).toHaveLength(2)
expect(html).toContain('alt=":SUSSY:"')
})
it('draws a Discord custom emoji', () => {
const html = replyPreviewHtml('nice <:pog:123456789012345678> and <a:spin:223456789012345678>', { service: 'discord' })
expect(html).not.toContain('&lt;:pog')
expect(html.match(/<img /g)).toHaveLength(2)
})
it('draws Twitch and Steam emotes and stickers', () => {
expect(replyPreviewHtml('[twitch:emote:25:Kappa] hi', { service: 'twitch' })).toMatch(/<img [^>]*alt="Kappa"/)
expect(replyPreviewHtml('[twitch:aemote:300:LUL]', { service: 'twitch' })).toContain('<img ')
expect(replyPreviewHtml('[steam:emote:steamhappy]', { service: 'steam' })).toContain('<img ')
})
it('draws a Sneedchat smilie when it is known', () => {
const smilies = { regex: /:smile:/, byAlias: { ':smile:': { url: 'x.png' } } } as never
expect(replyPreviewHtml(':smile: ok', { service: 'sneedchat', smilies })).toContain('<img src="x.png"')
})
it('leaves plain words plain, and escapes what is not markup', () => {
expect(replyPreviewHtml('just words', {})).toBe('just words')
expect(replyPreviewHtml('<img src=x onerror=alert(1)>', {})).not.toContain('<img')
})
it('shows a link as its words and keeps a spoiler hidden', () => {
const link = replyPreviewHtml('see https://example.org/page now', {})
expect(link).not.toContain('<a ')
expect(link).toContain('example.org/page')
const spoiler = replyPreviewHtml('||secret||', {})
expect(spoiler).toContain('spoiler')
expect(spoiler).not.toContain('revealed')
})
it('has nothing for nothing', () => {
expect(replyPreviewHtml('', {})).toBe('')
})
})
+27
View File
@@ -0,0 +1,27 @@
import { formatMessage, normalizeBBCode, type ChannelIndex, type SmilieIndex } from './format'
/**
* The words of a message as they read in the line that quotes it - the one above a reply, or in the bar over the
* box while one is being written.
*
* Those lines showed the original's text as it was stored, so an emote was its code (`[emote:5756632:SUSSY]`), a
* custom emoji its token, a Steam sticker its name in brackets. Run through the formatter every message is, they
* draw as what they are. What is left out is what a quote has no room or use for: a link is its words and not
* something to follow (the line is the way to the original), and a hidden spoiler stays hidden.
*/
export function replyPreviewHtml(
text: string,
options: { service?: string; smilies?: SmilieIndex | null; channels?: ChannelIndex | null; ircColours?: boolean } = {}
): string {
if (!text) return ''
const sneedchat = options.service === 'sneedchat'
const html = formatMessage(sneedchat ? normalizeBBCode(text) : text, {
isSneedchat: sneedchat,
smilies: options.smilies,
channels: options.channels,
markdownEscapes: options.service === 'discord',
ircFormatting: options.service === 'irc' && options.ircColours ? 'render' : 'strip'
})
// Anchors that are not spoilers: their words, with no link.
return html.replace(/<a href="(?!spoiler:)[^"]*"[^>]*>([\s\S]*?)<\/a>/g, '$1')
}
+21
View File
@@ -1349,6 +1349,12 @@ export class ChatStore {
this.set({ activeBufferId: initialBufferId, activeGroupId: initialGroupId })
void this.refreshNetSettings()
const looked = (): void => {
const id = this.ackPending
if (id && id === this.state.activeBufferId && this.windowIsActive()) bestEffort(this.ackRead(id), 'ack read')
}
window.addEventListener('focus', looked)
document.addEventListener('visibilitychange', looked)
void window.moho.popout.list().then((popouts) => this.set({ popouts }))
window.moho.popout.onChange((popouts) => this.set({ popouts }))
@@ -2605,6 +2611,13 @@ export class ChatStore {
// --- push events ----------------------------------------------------
/** The conversation to be told is read as soon as the window is looked at. */
private ackPending: string | null = null
private windowIsActive(): boolean {
return document.visibilityState === 'visible' && document.hasFocus()
}
/**
* Tells the service a conversation has been read.
*
@@ -2618,6 +2631,14 @@ export class ChatStore {
* leave a room read here unread on your phone forever.
*/
private async ackRead(bufferId: string): Promise<void> {
// Only what is being read. A window that is hidden, or is not the one in front, is not reading anything: a
// conversation opened at startup behind other windows, or selected from a notification, is not read by anybody.
// Said when the window is next looked at (`ackPending`), and only if that is still the conversation on screen.
if (!this.windowIsActive()) {
this.ackPending = bufferId
return
}
this.ackPending = null
const prefs = await window.moho.prefs.getAll()
await window.moho.rpc('markBufferRead', {
bufferId,