Compare commits
No files matched your search
@@ -17,6 +17,33 @@ Each section has two parts:
|
||||
that made it, including dependency updates
|
||||
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
|
||||
|
||||
## [1.3.3]
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Telegram sign-in by QR code: scan it with Telegram on a phone that is already signed in (Settings, Devices, Link Desktop Device), with no phone number or code; the code is renewed as Telegram says, an account kept in another datacenter is followed there, and a cloud password is asked for after the scan ([moho `b481fb8`](https://github.com/Moho-Chat/moho/commit/b481fb8), [nobilis `430e89f`](https://github.com/Moho-Chat/nobilis/commit/430e89f))
|
||||
- Telegram chat folders are the categories of the account's page, kept in step both ways: a folder made, renamed, deleted or put in another order on Telegram turns up as the category, and the same done to a category here is done on Telegram, including filing a conversation by dragging it (a category becomes a folder when its first conversation is filed in, since Telegram keeps no empty folder) ([moho `b481fb8`](https://github.com/Moho-Chat/moho/commit/b481fb8), [nobilis `430e89f`](https://github.com/Moho-Chat/nobilis/commit/430e89f), [nobilis `c9c98de`](https://github.com/Moho-Chat/nobilis/commit/c9c98de))
|
||||
- Telegram stickers can be sent, from the Stickers tab (recent, favourites and the installed packs); a group's bots offer their commands in the slash menu, written as `/command@bot` where two bots share a name; and a topic's own unread and mention counts start its badge ([moho `b481fb8`](https://github.com/Moho-Chat/moho/commit/b481fb8), [nobilis `430e89f`](https://github.com/Moho-Chat/nobilis/commit/430e89f))
|
||||
- Telegram links open in moho: moho takes over `tg://` links, which is what the "View in Telegram" button on a post's web page opens, and a t.me link in a message goes to the chat and the post, joining the channel if the account is not in it ([moho `b6b308d`](https://github.com/Moho-Chat/moho/commit/b6b308d), [nobilis `64a13fa`](https://github.com/Moho-Chat/nobilis/commit/64a13fa))
|
||||
- Discord is held back further from what gets an account flagged: no request after a 401 or three refusals on a route, a captcha closes its place instead of being answered, the server's own rules (verification level, onboarding, timeout, slowmode) are checked before a send, a direct message needs five of the account's own messages from Discord's own apps first and an empty channel is not written into first, history is asked once, uploads go in two steps as the official client does, one message at a time is sent to a channel, and the login is the official shape with the gateway stream compressed ([moho `5776ca8`](https://github.com/Moho-Chat/moho/commit/5776ca8), [nobilis `3557335`](https://github.com/Moho-Chat/nobilis/commit/3557335), [nobilis `2638938`](https://github.com/Moho-Chat/nobilis/commit/2638938))
|
||||
- The message box of a Discord server is closed while the green new-member badge shows by your name there (taken to be the first week after joining), since what is sent from another client in that time is treated as spam; it opens again by itself ([moho `5f10ae6`](https://github.com/Moho-Chat/moho/commit/5f10ae6), [nobilis `b1862bb`](https://github.com/Moho-Chat/nobilis/commit/b1862bb))
|
||||
|
||||
## [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...9382bc04ea.
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "moho",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.3",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.3",
|
||||
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
|
||||
"author": "Salastil <[email protected]>",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* A Telegram account's page with its conversations under the account's chat folders, and the changes the window asks
|
||||
* Telegram for: filing a conversation by dragging it to another folder, putting the folders in another order, making a
|
||||
* category (which is a folder once it has a conversation in it), renaming and deleting one - and that what Telegram
|
||||
* says next is what is drawn. The daemon's answers are stood in for here, in a scratch home on a fake IRC server.
|
||||
*
|
||||
* node scripts/check-telegram-folders-ui.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const irc = await fakeIrc({ people: ['alice'], onJoin: (c, _n, send) => send(`:alice!u@h PRIVMSG ${c} :hello there`) })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const results = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, detail)
|
||||
}
|
||||
const headings = () => ev(`[...document.querySelectorAll('.bufferlist .category')].map((c) => ({ head: (c.querySelector('.category-head')?.textContent || '').replace('expand_more', '').replace('chevron_right', '').trim(), rows: [...c.querySelectorAll('[draggable=true]')].filter((r) => !r.classList.contains('category-head')).map((r) => r.textContent.trim()) }))`)
|
||||
/** Holds a conversation (or a heading) over a heading and lets go. */
|
||||
const drag = (from, to) =>
|
||||
ev(`(async () => {
|
||||
const row = [...document.querySelectorAll('.bufferlist [draggable=true]')].find((e) => e.textContent.replace(/[a-z_]+(?=[A-Z])/, '').trim().endsWith(${JSON.stringify(from)}) && e.textContent.trim().length < 40)
|
||||
const head = [...document.querySelectorAll('.bufferlist .category-head')].find((e) => e.textContent.includes(${JSON.stringify(to)}))
|
||||
if (!row || !head) return { row: !!row, head: !!head }
|
||||
const dt = new DataTransfer()
|
||||
const wait = () => new Promise((r) => setTimeout(r, 120))
|
||||
row.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
head.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
head.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
head.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
row.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
return { dragged: true }
|
||||
})()`)
|
||||
try {
|
||||
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'hello there' })
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
// A window that is not focused takes a field's focus away again as soon as it is given.
|
||||
await page.call('Emulation.setFocusEmulationEnabled', { enabled: true })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots
|
||||
const st = s.state()
|
||||
const irc = st.accounts[0]
|
||||
const ircGroup = st.groups.find((g) => g.kind === 'account')
|
||||
const channel = st.buffers.find((b) => b.id.endsWith('|#ui'))
|
||||
const account = { ...irc, id: 'telegram:7', service: 'telegram', displayName: 'me', state: 'connected' }
|
||||
const group = { ...ircGroup, id: 'account:telegram:7', accountId: 'telegram:7', service: 'telegram', name: 'Telegram - me' }
|
||||
const chat = (name, id, category) => ({ ...channel, id: 'telegram:7|' + name, accountId: 'telegram:7', name, kind: 'dm', remoteId: id, groupId: group.id, category, position: 0, unread: 0, highlight: false, mentions: 0 })
|
||||
const buffers = [
|
||||
chat('Alice', '101', 'Direct Messages'), chat('Bob', '102', 'Direct Messages'), chat('Carol', '103', 'Direct Messages'),
|
||||
chat('HelperBot', '201', 'Bots'), chat('News', '-1001', 'Channels')
|
||||
]
|
||||
window.__calls = []
|
||||
const store = s.store
|
||||
for (const name of ['fileTelegramChat', 'renameTelegramFolder', 'deleteTelegramFolder', 'reorderTelegramFolders']) {
|
||||
store[name] = (...a) => { window.__calls.push([name, ...a]) }
|
||||
}
|
||||
store.createTelegramFolder = async (...a) => { window.__calls.push(['createTelegramFolder', ...a]); return true }
|
||||
s.patch({
|
||||
accounts: [...st.accounts, account],
|
||||
groups: [...st.groups, group],
|
||||
buffers: [...st.buffers, ...buffers],
|
||||
telegramFolders: { 'telegram:7': [
|
||||
{ id: 3, name: 'Work', members: ['101', '102'] },
|
||||
{ id: 2, name: 'Bots', members: ['201'] }
|
||||
] },
|
||||
activeGroupId: group.id, activeBufferId: buffers[0].id
|
||||
})
|
||||
})()`)
|
||||
await sleep(1200)
|
||||
await ev(`window.__mohoShots.patch({ telegramFolders: { 'telegram:7': [{ id: 3, name: 'Work', members: ['101', '102'] }, { id: 2, name: 'Bots', members: ['201'] }] } })`)
|
||||
await sleep(500)
|
||||
let h = await headings()
|
||||
console.log(JSON.stringify(h))
|
||||
check('the account page has a category for each folder, in Telegram’s order', h.findIndex((x) => x.head === 'Work') >= 0 && h.findIndex((x) => x.head === 'Work') < h.findIndex((x) => x.head === 'Bots'))
|
||||
check('conversations sit under the folder that holds them', h.find((x) => x.head === 'Work')?.rows.length === 2 && h.find((x) => x.head === 'Bots')?.rows.length === 1)
|
||||
await shot('telegram-folders.png')
|
||||
|
||||
await drag('Alice', 'Bots')
|
||||
const filed = await ev(`window.__calls.filter((c) => c[0] === 'fileTelegramChat')`)
|
||||
check('dragging a conversation to another folder asks Telegram to move it there', JSON.stringify(filed) === JSON.stringify([['fileTelegramChat', 'telegram:7|Alice', 2]]), JSON.stringify(filed))
|
||||
h = await headings()
|
||||
check('and nothing moves here until Telegram says so', h.find((x) => x.head === 'Work')?.rows.length === 2)
|
||||
|
||||
// Telegram says it did.
|
||||
await ev(`window.__mohoShots.patch({ telegramFolders: { 'telegram:7': [{ id: 3, name: 'Work', members: ['102'] }, { id: 2, name: 'Bots', members: ['201', '101'] }] } })`)
|
||||
await sleep(400)
|
||||
h = await headings()
|
||||
check('what Telegram says is what is drawn', h.find((x) => x.head === 'Work')?.rows.length === 1 && h.find((x) => x.head === 'Bots')?.rows.length === 2)
|
||||
|
||||
// A folder added on another of the account's apps turns up.
|
||||
await ev(`window.__mohoShots.patch({ telegramFolders: { 'telegram:7': [{ id: 3, name: 'Work', members: ['102'] }, { id: 2, name: 'Bots', members: ['201', '101'] }, { id: 4, name: 'Family', members: ['103'] }] } })`)
|
||||
await sleep(400)
|
||||
h = await headings()
|
||||
check('a folder made on another device turns up as a category', h.some((x) => x.head === 'Family' && x.rows.length === 1))
|
||||
|
||||
// The order, by dragging a heading.
|
||||
await ev(`(async () => {
|
||||
const heads = [...document.querySelectorAll('.bufferlist .category-head')]
|
||||
const from = heads.find((e) => e.textContent.includes('Family'))
|
||||
const to = heads.find((e) => e.textContent.includes('Work'))
|
||||
const dt = new DataTransfer()
|
||||
const wait = () => new Promise((r) => setTimeout(r, 120))
|
||||
from.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
to.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
to.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
await wait()
|
||||
to.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
from.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: dt }))
|
||||
})()`)
|
||||
await sleep(400)
|
||||
const reordered = await ev(`window.__calls.filter((c) => c[0] === 'reorderTelegramFolders')`)
|
||||
check('dragging a heading puts the folders in that order on Telegram', reordered.length >= 1 && JSON.stringify(reordered.at(-1)) === JSON.stringify(['reorderTelegramFolders', 'telegram:7', [4, 3, 2]]), JSON.stringify(reordered))
|
||||
|
||||
// A new category is only drawn until a conversation is put in it. (A click with a mouse takes focus off the message
|
||||
// box; these do not, and the menu would give it back to the box as it closed, taking it from the name field.)
|
||||
await ev(`document.activeElement?.blur()`)
|
||||
await ev(`(() => { const t = [...document.querySelectorAll('.bufferlist button')].find((b) => b.textContent.includes('Telegram - me')); t && t.click() })()`)
|
||||
await sleep(300)
|
||||
await ev(`(() => { const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes('New category')); i && i.click() })()`)
|
||||
await sleep(100)
|
||||
h = await headings()
|
||||
const naming = await ev(`!!document.querySelector('.category-name-field')`)
|
||||
console.log('naming field right after:', naming, JSON.stringify(h.map((x) => x.head)))
|
||||
check('a new category is drawn where it was asked for, empty', naming || h.some((x) => x.head === 'New category' && x.rows.length === 0))
|
||||
check('and nothing has been asked of Telegram', !(await ev(`window.__calls.some((c) => c[0] === 'createTelegramFolder')`)))
|
||||
// Named by typing over the name it opened with.
|
||||
if (naming) {
|
||||
await ev(`(() => { const inp = document.querySelector('.category-name-field'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(inp, 'Raiders'); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) })()`)
|
||||
await sleep(300)
|
||||
}
|
||||
h = await headings()
|
||||
console.log(JSON.stringify(h.map((x) => x.head)))
|
||||
await shot('telegram-folders-new.png')
|
||||
const pendingName = h.find((x) => x.head === 'Raiders') ? 'Raiders' : 'New category'
|
||||
await drag('Carol', pendingName)
|
||||
const made = await ev(`window.__calls.filter((c) => c[0] === 'createTelegramFolder')`)
|
||||
check('the first conversation put in it makes it a folder', made.length === 1 && made[0][1] === 'telegram:7|Carol' && made[0][2] === pendingName, JSON.stringify(made))
|
||||
|
||||
// Rename and delete, from the heading's menu.
|
||||
const menu = async (head, label) => {
|
||||
await ev(`(() => { const e = [...document.querySelectorAll('.bufferlist .category-head')].find((x) => x.textContent.includes(${JSON.stringify(head)})); e.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 100 })) })()`)
|
||||
await sleep(300)
|
||||
await ev(`(() => { const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((e) => e.textContent.includes(${JSON.stringify(label)})); i && i.click() })()`)
|
||||
await sleep(300)
|
||||
}
|
||||
await menu('Bots', 'Remove')
|
||||
check('removing a category deletes the folder on Telegram', JSON.stringify(await ev(`window.__calls.filter((c) => c[0] === 'deleteTelegramFolder')`)) === JSON.stringify([['deleteTelegramFolder', 'telegram:7', 2]]))
|
||||
await ev(`document.activeElement?.blur()`)
|
||||
// Renamed: typed over as soon as the field is there (a window that is not focused takes it away again).
|
||||
await ev(`(async () => {
|
||||
const e = [...document.querySelectorAll('.bufferlist .category-head')].find((x) => x.textContent.includes('Work'))
|
||||
e.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 100 }))
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
const i = [...document.querySelectorAll('.context-menu button, .context-menu [role=menuitem]')].find((x) => x.textContent.includes('Rename'))
|
||||
i.click()
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
const inp = document.querySelector('.category-name-field')
|
||||
if (!inp) return
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
|
||||
set.call(inp, 'Job')
|
||||
inp.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
})()`)
|
||||
await sleep(300)
|
||||
const renamed = await ev(`window.__calls.filter((c) => c[0] === 'renameTelegramFolder')`)
|
||||
check('renaming one renames the folder on Telegram', renamed.some((c) => c[1] === 'telegram:7' && c[2] === 3 && c[3] === 'Job'), JSON.stringify(renamed))
|
||||
} finally {
|
||||
await stop()
|
||||
irc.close()
|
||||
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
|
||||
process.exitCode = results.every(Boolean) ? 0 : 1
|
||||
}
|
||||
@@ -36,6 +36,9 @@ try {
|
||||
const form = await ev(`(() => { const f = document.querySelector('.add-form'); return f ? f.innerText : '' })()`)
|
||||
check('the form says where the api_id comes from', /my\.telegram\.org\/apps/.test(form), form.slice(0, 120))
|
||||
check('it asks for the api_id, the api_hash and the phone number', /api_id/.test(form) && /api_hash/.test(form) && /Phone number/.test(form))
|
||||
const qrDisabled = () => ev(`[...document.querySelectorAll('.add-form button')].find((b) => /QR code/.test(b.textContent))?.disabled`)
|
||||
check('a QR code is offered as the other way in', (await qrDisabled()) !== undefined)
|
||||
check('and waits for the api_id and the api_hash, which it needs, but not for a phone number', (await qrDisabled()) === true)
|
||||
const disabled = () => ev(`[...document.querySelectorAll('.add-form button.primary')].find((b) => /Send the code/.test(b.textContent))?.disabled`)
|
||||
check('nothing can be sent while the form is empty', (await disabled()) === true)
|
||||
const fill = (placeholderOrLabel, value) => ev(`(() => {
|
||||
@@ -48,6 +51,7 @@ try {
|
||||
await fill('api_id', '12345')
|
||||
await fill('api_hash', 'abcdef')
|
||||
check('two of three are not enough', (await disabled()) === true)
|
||||
check('the QR code needs only those two', (await qrDisabled()) === false)
|
||||
await fill('Phone number', '+1 555 123 4567')
|
||||
await sleep(200)
|
||||
check('all three turn the button on', (await disabled()) === false)
|
||||
|
||||
@@ -71,9 +71,9 @@ Exec=$BIN_DIR/$APP_ID.AppImage %U
|
||||
Icon=$APP_ID
|
||||
Terminal=false
|
||||
Categories=Network;InstantMessaging;Chat;
|
||||
# What makes an irc:// link in a browser offer moho at all. Exec already takes
|
||||
# What makes an irc:// or tg:// (Telegram) link in a browser offer moho at all. Exec already takes
|
||||
# %U, so the desktop has somewhere to put the URL once it decides to.
|
||||
MimeType=x-scheme-handler/irc;x-scheme-handler/ircs;
|
||||
MimeType=x-scheme-handler/irc;x-scheme-handler/ircs;x-scheme-handler/tg;
|
||||
Keywords=chat;irc;discord;matrix;sneedchat;messaging;
|
||||
StartupWMClass=moho
|
||||
StartupNotify=true
|
||||
|
||||
@@ -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's own app.
|
||||
</p>
|
||||
)}
|
||||
{!accountId && <TorSwitch service="discord" on={useTor} onChange={setUseTor} />}
|
||||
<div className="setting-segmented">
|
||||
<button
|
||||
@@ -1510,6 +1520,8 @@ function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
const [detail, setDetail] = useState('')
|
||||
const [needs, setNeeds] = useState<{ step: 'code' | 'password'; hint?: string; error?: string } | null>(null)
|
||||
const [typed, setTyped] = useState('')
|
||||
/** A QR code to scan, and when it was drawn (Telegram renews it about every half minute). */
|
||||
const [qr, setQr] = useState<{ path: string; at: number } | null>(null)
|
||||
|
||||
// Held in a ref as well: the first event can be handled before the render that follows the answer that started the
|
||||
// sign-in. Nothing else is signing in from this form, so an event with no id known yet is this one's.
|
||||
@@ -1526,12 +1538,18 @@ function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
if (current.current && d.loginId !== current.current) return
|
||||
if (e.event === 'telegramLoginStatus') {
|
||||
setDetail(d.text || '')
|
||||
} else if (e.event === 'telegramLoginQr') {
|
||||
const q = e.data as { path?: string }
|
||||
if (q.path) setQr({ path: q.path, at: Date.now() })
|
||||
setDetail('')
|
||||
} else if (e.event === 'telegramLoginNeeds') {
|
||||
setQr(null)
|
||||
setNeeds({ step: d.step ?? 'code', hint: d.hint ?? undefined, error: d.error ?? undefined })
|
||||
setTyped('')
|
||||
setDetail('')
|
||||
} else if (e.event === 'telegramLoginResult') {
|
||||
setNeeds(null)
|
||||
setQr(null)
|
||||
setId('')
|
||||
setBusy(false)
|
||||
setDetail('')
|
||||
@@ -1569,6 +1587,21 @@ function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
})
|
||||
}
|
||||
|
||||
const beginQr = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
setNeeds(null)
|
||||
setQr(null)
|
||||
current.current = ''
|
||||
void window.moho
|
||||
.rpc<{ loginId: string }>('addTelegramAccountQr', { apiId: apiId.trim(), apiHash: apiHash.trim(), useTor })
|
||||
.then((r) => setId(r.loginId))
|
||||
.catch((e: Error) => {
|
||||
setBusy(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const send = (): void => {
|
||||
const value = needs?.step === 'password' ? typed : typed.trim()
|
||||
if (!value || !loginId || !needs) return
|
||||
@@ -1582,10 +1615,31 @@ function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
if (current.current) void window.moho.rpc('cancelTelegramLogin', { loginId: current.current }).catch(() => {})
|
||||
setId('')
|
||||
setNeeds(null)
|
||||
setQr(null)
|
||||
setDetail('')
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
if (qr && !needs) {
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>Scan this with Telegram on your phone.</p>
|
||||
<p className="small muted">
|
||||
In the app, open Settings, then Devices, then Link Desktop Device, and point the phone at the code. The code
|
||||
changes about every half minute, and the picture here changes with it. If the account has a cloud password,
|
||||
it is asked for next.
|
||||
</p>
|
||||
<img className="qr-image" src={resolveMediaUrl(`file://${qr.path}#${qr.at}`)} alt="Telegram sign-in QR code" />
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
<div className="field-row">
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (needs) {
|
||||
const password = needs.step === 'password'
|
||||
return (
|
||||
@@ -1670,7 +1724,7 @@ function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="small muted">Phone number, with its country code</span>
|
||||
<span className="small muted">Phone number, with its country code (not needed for a QR code)</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoComplete="off"
|
||||
@@ -1687,6 +1741,9 @@ function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
<button type="button" className="button primary" disabled={busy || !ready} onClick={begin}>
|
||||
{busy ? 'Signing in…' : 'Send the code'}
|
||||
</button>
|
||||
<button type="button" className="button" disabled={busy || apiId.trim() === '' || apiHash.trim() === ''} onClick={beginQr}>
|
||||
Sign in with a QR code
|
||||
</button>
|
||||
{busy && (
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel
|
||||
|
||||
@@ -12,6 +12,14 @@ import { presenceClass, presenceLabel } from '../lib/presence'
|
||||
import { activityLabel, dmActivity } from '../lib/activity'
|
||||
import { ActivityLine } from './ActivityLine'
|
||||
import { steamCategoryId, parseSteamCategory } from '../lib/steamgroups'
|
||||
import {
|
||||
folderAssignment,
|
||||
folderCategories,
|
||||
folderOrder,
|
||||
isPendingCategory,
|
||||
parseTelegramCategory,
|
||||
pendingCategoryId
|
||||
} from '../lib/telegramfolders'
|
||||
import { NamePrompt } from './NamePrompt'
|
||||
import { useChat, useComfy, useIdSetPref, useInvitations, useMapPref, usePref, useStore } from '../state/hooks'
|
||||
import {
|
||||
@@ -170,6 +178,9 @@ export function BufferList(): JSX.Element {
|
||||
const isDmPage = activeGroup?.id === DM_GROUP_ID
|
||||
// A Steam account's own page: its friends sit under the folders Steam keeps for the account.
|
||||
const steamPage = activeGroup?.kind === 'account' && accounts.find((a) => a.id === activeGroup.accountId)?.service === 'steam'
|
||||
// A Telegram account's own page: its conversations sit under the chat folders the account keeps, which are kept in step
|
||||
// with the categories both ways.
|
||||
const telegramPage = activeGroup?.kind === 'account' && accounts.find((a) => a.id === activeGroup.accountId)?.service === 'telegram'
|
||||
/**
|
||||
* Whose identity the plaque shows.
|
||||
*
|
||||
@@ -264,8 +275,23 @@ export function BufferList(): JSX.Element {
|
||||
: [],
|
||||
[steamPage, activeGroup, steamGroups]
|
||||
)
|
||||
const myCategories = steamPage ? steamFolders : activeGroup ? (customCats[activeGroup.id] ?? []) : []
|
||||
const telegramFolders = useChat((s) => s.telegramFolders)
|
||||
/** Categories made on this page that are not Telegram folders yet: Telegram keeps no empty one. */
|
||||
const [pendingFolders, setPendingFolders] = useState<CustomCategory[]>([])
|
||||
const tgFolders = useMemo(() => (telegramPage && activeGroup ? (telegramFolders[activeGroup.accountId] ?? []) : []), [telegramPage, activeGroup, telegramFolders])
|
||||
const myCategories = useMemo(
|
||||
() =>
|
||||
steamPage
|
||||
? steamFolders
|
||||
: telegramPage && activeGroup
|
||||
? [...folderCategories(activeGroup.accountId, tgFolders), ...pendingFolders.filter((p) => p.id.startsWith(`tfnew:${activeGroup.accountId}:`))]
|
||||
: activeGroup
|
||||
? (customCats[activeGroup.id] ?? [])
|
||||
: [],
|
||||
[steamPage, steamFolders, telegramPage, activeGroup, tgFolders, pendingFolders, customCats]
|
||||
)
|
||||
const viewAssignment = useMemo(() => {
|
||||
if (telegramPage && activeGroup) return folderAssignment(activeGroup.accountId, tgFolders, groupBuffers)
|
||||
if (!steamPage || !activeGroup) return assignment
|
||||
const folders = [...(steamGroups[activeGroup.accountId] ?? [])].sort((a, b) => a.id - b.id)
|
||||
const next = { ...assignment }
|
||||
@@ -276,16 +302,30 @@ export function BufferList(): JSX.Element {
|
||||
else delete next[b.id]
|
||||
}
|
||||
return next
|
||||
}, [steamPage, activeGroup, steamGroups, assignment, groupBuffers])
|
||||
/** Files a conversation under a heading: on Steam for a Steam page, in the preferences for the rest. */
|
||||
}, [steamPage, telegramPage, tgFolders, activeGroup, steamGroups, assignment, groupBuffers])
|
||||
/** Files a conversation under a heading: on Steam for a Steam page, on Telegram for a Telegram one, in the preferences for the rest. */
|
||||
const fileBuffer = (bufferId: string, categoryId: string): void => {
|
||||
if (steamPage) store.moveSteamFriend(bufferId, parseSteamCategory(categoryId)?.groupId ?? null)
|
||||
else setAssignment(bufferId, categoryId)
|
||||
if (steamPage) {
|
||||
store.moveSteamFriend(bufferId, parseSteamCategory(categoryId)?.groupId ?? null)
|
||||
} else if (telegramPage) {
|
||||
const pending = pendingFolders.find((p) => p.id === categoryId)
|
||||
if (pending) {
|
||||
// The first conversation in it is what makes it a folder on Telegram.
|
||||
void store.createTelegramFolder(bufferId, pending.name).then((made) => {
|
||||
if (made) setPendingFolders((list) => list.filter((p) => p.id !== categoryId))
|
||||
})
|
||||
} else {
|
||||
store.fileTelegramChat(bufferId, parseTelegramCategory(categoryId)?.folderId ?? null)
|
||||
}
|
||||
} else {
|
||||
setAssignment(bufferId, categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!activeGroup) return null
|
||||
const list = sections(groupBuffers, myCategories, viewAssignment, categoryOrder[activeGroup.id] ?? [])
|
||||
// Telegram's own order stands on its page: a heading dragged here is moved there, and comes back in that order.
|
||||
const list = sections(groupBuffers, myCategories, viewAssignment, telegramPage ? [] : (categoryOrder[activeGroup.id] ?? []))
|
||||
if (!isPinnedPage && !isDmPage) {
|
||||
for (const s of list) {
|
||||
s.buffers.sort((a, b) => (a.position || 0) - (b.position || 0))
|
||||
@@ -295,7 +335,7 @@ export function BufferList(): JSX.Element {
|
||||
// they just made it, and a heading that vanishes until something is put
|
||||
// in it cannot have anything put in it.
|
||||
return list.filter((s) => s.custom || s.buffers.length > 0)
|
||||
}, [activeGroup, isPinnedPage, isDmPage, steamPage, groupBuffers, myCategories, viewAssignment, categoryOrder])
|
||||
}, [activeGroup, isPinnedPage, isDmPage, steamPage, telegramPage, groupBuffers, myCategories, viewAssignment, categoryOrder])
|
||||
|
||||
// The menu still toggles a buffer's *own* flag independently of a muted
|
||||
// rail entry above it, the same way muting a channel inside an
|
||||
@@ -416,7 +456,7 @@ export function BufferList(): JSX.Element {
|
||||
lifted={dragging?.kind === 'buffer' && dragging.id === b.id}
|
||||
onDragStart={() => beginDrag('buffer', b.id)}
|
||||
onDragEnd={endDrag}
|
||||
filed={!!assignment[b.id] && myCategories.some((c) => c.id === assignment[b.id])}
|
||||
filed={telegramPage ? !!viewAssignment[b.id] : !!assignment[b.id] && myCategories.some((c) => c.id === assignment[b.id])}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -463,7 +503,9 @@ export function BufferList(): JSX.Element {
|
||||
if (!held || !grouped || !activeGroup) return
|
||||
if (held.kind === 'category') {
|
||||
if (held.id === section.key) return
|
||||
setCategoryOrder(activeGroup.id, reorderCategories(grouped, held.id, section.key))
|
||||
const order = reorderCategories(grouped, held.id, section.key)
|
||||
if (telegramPage) store.reorderTelegramFolders(activeGroup.accountId, folderOrder(order))
|
||||
else setCategoryOrder(activeGroup.id, order)
|
||||
return
|
||||
}
|
||||
if (!section.custom) return
|
||||
@@ -619,6 +661,14 @@ export function BufferList(): JSX.Element {
|
||||
if (name && steamPage && activeGroup) {
|
||||
const group = parseSteamCategory(naming.id)?.groupId
|
||||
if (group !== undefined) store.renameSteamFolder(activeGroup.accountId, group, name)
|
||||
} else if (name && telegramPage && activeGroup) {
|
||||
// A category not yet a folder is only named; a folder is renamed on Telegram.
|
||||
if (isPendingCategory(naming.id)) setPendingFolders((list) => list.map((p) => (p.id === naming.id ? { ...p, name } : p)))
|
||||
else {
|
||||
const folder = parseTelegramCategory(naming.id)?.folderId
|
||||
// The same name is not a change: nothing is asked of Telegram.
|
||||
if (folder !== undefined && name !== section.name) store.renameTelegramFolder(activeGroup.accountId, folder, name)
|
||||
}
|
||||
} else if (name) {
|
||||
writeCategories(
|
||||
myCategories.map((c) => (c.id === naming.id ? { ...c, name } : c))
|
||||
@@ -750,6 +800,13 @@ export function BufferList(): JSX.Element {
|
||||
setMakingFolder(true)
|
||||
return
|
||||
}
|
||||
// On Telegram it is made there with its first conversation, so until then it is only drawn.
|
||||
if (telegramPage) {
|
||||
const id = pendingCategoryId(activeGroup.accountId)
|
||||
setPendingFolders((list) => [...list, { id, name: 'New category' }])
|
||||
setNaming({ id, name: 'New category' })
|
||||
return
|
||||
}
|
||||
const id = `cat-${Date.now().toString(36)}`
|
||||
// At the top, not the end. The command was given from the
|
||||
// group's own title at the top of this list, and a heading
|
||||
@@ -800,7 +857,10 @@ export function BufferList(): JSX.Element {
|
||||
// to wherever the service filed them.
|
||||
onClick: () => {
|
||||
const group = parseSteamCategory(catMenu.section.key)
|
||||
const folder = parseTelegramCategory(catMenu.section.key)
|
||||
if (steamPage && activeGroup && group) store.deleteSteamFolder(activeGroup.accountId, group.groupId)
|
||||
else if (telegramPage && activeGroup && isPendingCategory(catMenu.section.key)) setPendingFolders((list) => list.filter((p) => p.id !== catMenu.section.key))
|
||||
else if (telegramPage && activeGroup && folder) store.deleteTelegramFolder(activeGroup.accountId, folder.folderId)
|
||||
else writeCategories(myCategories.filter((c) => c.id !== catMenu.section.key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -245,7 +247,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const route = useChat((s) => s.route)
|
||||
const routed = tunnelAll || !!account?.useTor
|
||||
const media = useMediaUrl(account?.id)
|
||||
const hasStickers = service === 'matrix' || service === 'discord' || service === 'steam'
|
||||
const hasStickers = service === 'matrix' || service === 'discord' || service === 'steam' || service === 'telegram'
|
||||
const formats = formatsFor(service)
|
||||
/**
|
||||
* The account's stickers, asked for when the picker opens - from either
|
||||
@@ -255,7 +257,16 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const loadStickers = (): void => {
|
||||
if (!hasStickers || !buffer) return
|
||||
void window.moho
|
||||
.rpc<StickerEntry[]>(service === 'discord' ? 'listDiscordStickers' : service === 'steam' ? 'listSteamStickers' : 'listMatrixStickers', { bufferId: buffer.id })
|
||||
.rpc<StickerEntry[]>(
|
||||
service === 'discord'
|
||||
? 'listDiscordStickers'
|
||||
: service === 'steam'
|
||||
? 'listSteamStickers'
|
||||
: service === 'telegram'
|
||||
? 'listTelegramStickers'
|
||||
: 'listMatrixStickers',
|
||||
{ bufferId: buffer.id }
|
||||
)
|
||||
.then(setStickers)
|
||||
.catch(() => setStickers([]))
|
||||
}
|
||||
@@ -448,6 +459,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 +891,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 +903,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 +1056,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. */}
|
||||
@@ -1372,7 +1396,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
gesture: an emoji goes into the line being written and a sticker
|
||||
is the message. Only where the service has them: Matrix, where a
|
||||
pack supplies them, and Discord, where each guild has its own. */}
|
||||
{!thread && (service === 'matrix' || service === 'discord' || service === 'steam') && (
|
||||
{!thread && hasStickers && (
|
||||
<button
|
||||
ref={stickerButtonRef}
|
||||
type="button"
|
||||
@@ -1457,7 +1481,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const sent =
|
||||
service === 'steam'
|
||||
? window.moho.rpc('sendSteamSticker', { bufferId: buffer.id, name: sticker.name })
|
||||
: sticker.id
|
||||
: service === 'telegram'
|
||||
? window.moho.rpc('sendTelegramSticker', { bufferId: buffer.id, id: sticker.id })
|
||||
: sticker.id
|
||||
? window.moho.rpc('sendDiscordSticker', { bufferId: buffer.id, stickerId: sticker.id })
|
||||
: window.moho.rpc('sendMatrixSticker', { bufferId: buffer.id, mxc: sticker.mxc, body: sticker.body })
|
||||
void sent.catch((e: Error) => store.toast('error', e.message))
|
||||
|
||||
@@ -321,6 +321,15 @@ export function MessageList(): JSX.Element {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [anchored, setAnchored] = useState(true)
|
||||
// Read as far as the newest line, while it is on screen at the bottom of a window that is looked at: a moment after
|
||||
// it arrives, so one that is scrolled past at once is not reported as read.
|
||||
const newestId = messages[messages.length - 1]?.id
|
||||
useEffect(() => {
|
||||
if (!anchored || !newestId) return
|
||||
const timer = setTimeout(() => store.ackIfReading(bufferId), 1500)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [anchored, newestId, bufferId])
|
||||
const [missedCount, setMissedCount] = useState(0)
|
||||
/** How many of the newest messages are built as rows. */
|
||||
const [shown, setShown] = useState(RENDER_WINDOW)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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``')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('<: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('')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
folderAssignment,
|
||||
folderCategories,
|
||||
folderOrder,
|
||||
isPendingCategory,
|
||||
parseTelegramCategory,
|
||||
pendingCategoryId,
|
||||
telegramCategoryId
|
||||
} from './telegramfolders'
|
||||
|
||||
const folders = [
|
||||
{ id: 3, name: 'Work', members: ['100', '101'] },
|
||||
{ id: 2, name: 'Bots', members: ['101', '200'] }
|
||||
]
|
||||
|
||||
describe('a Telegram folder as a category', () => {
|
||||
it('is named by its account and folder, and read back', () => {
|
||||
const id = telegramCategoryId('telegram:12345', 3)
|
||||
expect(id).toBe('tf:telegram:12345:3')
|
||||
expect(parseTelegramCategory(id)).toEqual({ accountId: 'telegram:12345', folderId: 3 })
|
||||
expect(parseTelegramCategory('sg:steam:1:2')).toBeNull()
|
||||
expect(parseTelegramCategory('tf:nonsense')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps Telegram’s order and names', () => {
|
||||
expect(folderCategories('telegram:1', folders)).toEqual([
|
||||
{ id: 'tf:telegram:1:3', name: 'Work' },
|
||||
{ id: 'tf:telegram:1:2', name: 'Bots' }
|
||||
])
|
||||
})
|
||||
|
||||
it('draws a conversation under the first folder that holds it', () => {
|
||||
const got = folderAssignment('telegram:1', folders, [
|
||||
{ id: 'a', remoteId: '100' },
|
||||
{ id: 'b', remoteId: '101' },
|
||||
{ id: 'c', remoteId: '200' },
|
||||
{ id: 'd', remoteId: '999' },
|
||||
{ id: 'e', remoteId: null }
|
||||
])
|
||||
expect(got).toEqual({ a: 'tf:telegram:1:3', b: 'tf:telegram:1:3', c: 'tf:telegram:1:2' })
|
||||
})
|
||||
|
||||
it('turns the categories’ order into the folders’ numbers, leaving out what is not a folder', () => {
|
||||
expect(folderOrder(['tf:telegram:1:2', 'svc:Groups', 'tfnew:telegram:1:x', 'tf:telegram:1:3'])).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('knows a category that is not a folder yet', () => {
|
||||
const id = pendingCategoryId('telegram:1')
|
||||
expect(isPendingCategory(id)).toBe(true)
|
||||
expect(parseTelegramCategory(id)).toBeNull()
|
||||
expect(isPendingCategory('tf:telegram:1:2')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CustomCategory } from './categories'
|
||||
|
||||
/**
|
||||
* Telegram's chat folders, as the categories of the account's page.
|
||||
*
|
||||
* A folder is a tab of chats on Telegram, and it is drawn here as a category with the conversations on the account's
|
||||
* page that it holds. Both sides change the other: a folder made, renamed, deleted or reordered on Telegram turns up
|
||||
* here, and the same done to a category here is done on Telegram. The daemon works out what each folder holds (a folder
|
||||
* can take chats by kind - every bot, every group - as well as by name) and says it as the list of conversations; this
|
||||
* only draws that, so what is shown is what Telegram says.
|
||||
*
|
||||
* A conversation is under one category here and can be in several folders there. It is drawn under the first folder,
|
||||
* in Telegram's order, that holds it.
|
||||
*/
|
||||
|
||||
export interface TelegramFolder {
|
||||
id: number
|
||||
name: string
|
||||
emoticon?: string | null
|
||||
/** The conversations it holds, by their Telegram ids (a conversation's `remoteId`). */
|
||||
members: string[]
|
||||
/** A folder shared by link: it has no rules and no exceptions. */
|
||||
shared?: boolean
|
||||
}
|
||||
|
||||
const PREFIX = 'tf:'
|
||||
const PENDING = 'tfnew:'
|
||||
|
||||
/** The category that stands for one of an account's folders. Stable across renames. */
|
||||
export function telegramCategoryId(accountId: string, folderId: number): string {
|
||||
return `${PREFIX}${accountId}:${folderId}`
|
||||
}
|
||||
|
||||
/** Which account and folder a category stands for, if it stands for one. */
|
||||
export function parseTelegramCategory(id: string): { accountId: string; folderId: number } | null {
|
||||
if (!id.startsWith(PREFIX)) return null
|
||||
const rest = id.slice(PREFIX.length)
|
||||
const colon = rest.lastIndexOf(':')
|
||||
const folderId = Number(rest.slice(colon + 1))
|
||||
return colon > 0 && Number.isInteger(folderId) ? { accountId: rest.slice(0, colon), folderId } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A category made here that is not a folder yet. Telegram keeps no empty folder, so it is made on Telegram when the
|
||||
* first conversation is filed into it; until then it is only drawn.
|
||||
*/
|
||||
export function pendingCategoryId(accountId: string): string {
|
||||
return `${PENDING}${accountId}:${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
export function isPendingCategory(id: string): boolean {
|
||||
return id.startsWith(PENDING)
|
||||
}
|
||||
|
||||
/** The categories for an account's folders, in Telegram's order. */
|
||||
export function folderCategories(accountId: string, folders: TelegramFolder[]): CustomCategory[] {
|
||||
return folders.map((f) => ({ id: telegramCategoryId(accountId, f.id), name: f.name }))
|
||||
}
|
||||
|
||||
/** Which category each conversation is drawn under: the first folder, in order, that holds it. */
|
||||
export function folderAssignment(
|
||||
accountId: string,
|
||||
folders: TelegramFolder[],
|
||||
buffers: { id: string; remoteId?: string | null }[]
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const b of buffers) {
|
||||
if (!b.remoteId) continue
|
||||
const home = folders.find((f) => f.members.includes(b.remoteId!))
|
||||
if (home) out[b.id] = telegramCategoryId(accountId, home.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The folders' numbers in the order the categories are now drawn in. */
|
||||
export function folderOrder(keys: string[]): number[] {
|
||||
return keys.map((k) => parseTelegramCategory(k)?.folderId).filter((n): n is number => n !== undefined)
|
||||
}
|
||||
@@ -56,3 +56,35 @@ describe('scrolling to the top of a conversation', () => {
|
||||
expect(rpc).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a conversation whose service keeps its own unread count', () => {
|
||||
it('starts the count from it when it first appears', () => {
|
||||
// A conversation new to the window is subscribed to, which asks the daemon.
|
||||
vi.stubGlobal('window', { moho: { rpc: vi.fn().mockResolvedValue(undefined) } })
|
||||
const store = new ChatStore() as unknown as { state: { buffers: Record<string, unknown>[] }; handleBufferListChange: (data: unknown) => void }
|
||||
store.handleBufferListChange({ id: 't|g:5', accountId: 't', kind: 'channel', name: 'Group/#Topic', lastActivityTs: 0, unreadSeed: 7, mentionsSeed: 2 })
|
||||
const b = store.state.buffers.find((x) => x.id === 't|g:5')!
|
||||
expect([b.unread, b.mentions, b.highlight]).toEqual([7, 2, true])
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('sets the count when the service moves it, and leaves it alone when it has not', () => {
|
||||
const seeded = { unreadSeed: 7, mentionsSeed: 2, unread: 9, mentions: 3, highlight: true }
|
||||
// Two more were heard since the service last said seven: not a change, so the window keeps counting.
|
||||
let b = after({ unreadSeed: 7, mentionsSeed: 2 }, seeded)
|
||||
expect([b.unread, b.mentions]).toEqual([9, 3])
|
||||
// The service says it was all read elsewhere: the seed goes away and so does the count.
|
||||
b = after({}, seeded)
|
||||
expect([b.unread, b.mentions, b.highlight, b.unreadSeed]).toEqual([0, 0, false, 0])
|
||||
// The service says there are now ten.
|
||||
b = after({ unreadSeed: 10 }, seeded)
|
||||
expect([b.unread, b.mentions, b.highlight]).toEqual([10, 0, false])
|
||||
})
|
||||
|
||||
it('does not put a count on the conversation being read', () => {
|
||||
const store = new ChatStore() as unknown as { state: { buffers: Record<string, unknown>[]; activeBufferId: string | null }; handleBufferListChange: (data: unknown) => void }
|
||||
store.state = { ...store.state, activeBufferId: 'm|!r', buffers: [{ id: 'm|!r', accountId: 'm', kind: 'channel', name: '!r', lastActivityTs: 0, unread: 0, highlight: false, mentions: 0 }] }
|
||||
store.handleBufferListChange({ id: 'm|!r', accountId: 'm', kind: 'channel', name: '!r', lastActivityTs: 0, unreadSeed: 4 })
|
||||
expect(store.state.buffers[0].unread).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
import { addToast, type ToastAction, type ToastItem } from '../lib/toasts'
|
||||
import { ALL_STATUSES, statusesFor, supportsStatus } from '../lib/status'
|
||||
import type { SteamGroup, GroupCall } from '../lib/steamgroups'
|
||||
import type { TelegramFolder } from '../lib/telegramfolders'
|
||||
import { buildSmilieIndex, sneedSameText, steamSameText, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { runExport } from '../lib/exporter'
|
||||
@@ -738,6 +739,8 @@ export interface ChatState {
|
||||
profile: Profile | null
|
||||
/** Each Steam account's friend groups as Steam last said them; absent until it has. */
|
||||
steamGroups: Record<string, SteamGroup[]>
|
||||
/** Each Telegram account's chat folders as Telegram last said them, in its order; absent until it has. */
|
||||
telegramFolders: Record<string, TelegramFolder[]>
|
||||
replyingTo: { id: string; from: string; body: string } | null
|
||||
/** Whether answering pings the author (Discord's "@ ON"): on for every new reply. */
|
||||
replyPing: boolean
|
||||
@@ -862,6 +865,7 @@ const INITIAL: ChatState = {
|
||||
steamDialog: null,
|
||||
profile: null,
|
||||
steamGroups: {},
|
||||
telegramFolders: {},
|
||||
replyingTo: null,
|
||||
replyPing: true,
|
||||
editingId: '',
|
||||
@@ -1047,6 +1051,24 @@ function canFetchAround(bufferId: string): boolean {
|
||||
return bufferId.startsWith('discord:') || bufferId.startsWith('matrix:') || bufferId.startsWith('telegram:')
|
||||
}
|
||||
|
||||
/**
|
||||
* What a re-broadcast of a conversation does to its unread count: when the service's own count changed, the count is
|
||||
* set to it, and otherwise it is left to follow the messages heard. A conversation being read is not given a count.
|
||||
* The seeds are taken as sent, absent meaning none, since the daemon leaves a zero out.
|
||||
*/
|
||||
function seededCount(
|
||||
before: BufferEntry,
|
||||
data: WireBuffer,
|
||||
active: string | null
|
||||
): Pick<BufferEntry, 'unreadSeed' | 'mentionsSeed'> & Partial<Pick<BufferEntry, 'unread' | 'mentions' | 'highlight'>> {
|
||||
const unreadSeed = data.unreadSeed ?? 0
|
||||
const mentionsSeed = data.mentionsSeed ?? 0
|
||||
const changed = unreadSeed !== (before.unreadSeed ?? 0) || mentionsSeed !== (before.mentionsSeed ?? 0)
|
||||
if (!changed) return { unreadSeed, mentionsSeed }
|
||||
if (data.id === active) return { unreadSeed, mentionsSeed }
|
||||
return { unreadSeed, mentionsSeed, unread: unreadSeed, mentions: mentionsSeed, highlight: mentionsSeed > 0 }
|
||||
}
|
||||
|
||||
function bestEffort(work: Promise<unknown>, what: string): void {
|
||||
void work.catch((e: Error) => console.debug(`[moho] ${what}:`, e.message))
|
||||
}
|
||||
@@ -1349,6 +1371,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 }))
|
||||
|
||||
@@ -2220,6 +2248,12 @@ export class ChatStore {
|
||||
return
|
||||
}
|
||||
|
||||
// A Telegram link goes to wherever it points: the chat, joined if the account is not in it, and the post.
|
||||
if (link.service === 'telegram') {
|
||||
void this.openTelegramLink(link.url)
|
||||
return
|
||||
}
|
||||
|
||||
// A kick.com link names a streamer, and clicking one means both things:
|
||||
// the stream goes to the browser, which is where video is watched, and
|
||||
// the chat opens here, which is what this client is for. Doing only the
|
||||
@@ -2277,6 +2311,36 @@ export class ChatStore {
|
||||
this.awaitBuffer(existing.id, link.channels[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a link to a Telegram chat or post, from a message or from the browser's "View in Telegram" button: the
|
||||
* daemon finds (or joins) the chat, and this opens its conversation and goes to the post.
|
||||
*
|
||||
* With no Telegram account to open it in, a web address goes on to the browser as it would have, and an app link
|
||||
* says what is missing.
|
||||
*/
|
||||
async openTelegramLink(url: string): Promise<void> {
|
||||
const accounts = this.state.accounts.filter((a) => a.service === 'telegram')
|
||||
if (accounts.length === 0) {
|
||||
if (/^https?:/i.test(url)) void window.moho.openExternal(url)
|
||||
else this.toast('info', 'Add a Telegram account to open that link here')
|
||||
return
|
||||
}
|
||||
// Brought to the front by the click that came from the browser; a link followed in a message is already here.
|
||||
this.set({ activePanel: '' })
|
||||
try {
|
||||
const where = await window.moho.rpc<{ accountId: string; bufferId: string; messageId: string | null }>('openTelegramLink', {
|
||||
url,
|
||||
// The account in front, when it is a Telegram one; otherwise the daemon takes the first that is connected.
|
||||
accountId: accounts.find((a) => this.state.activeBufferId?.startsWith(`${a.id}|`))?.id
|
||||
})
|
||||
await this.refreshBuffers()
|
||||
await this.selectBuffer(where.bufferId, true)
|
||||
if (where.messageId && (await this.jumpToMessage(where.bufferId, where.messageId))) this.setJumpTarget(where.messageId)
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't open that Telegram link: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a private message to one person on Sneedchat.
|
||||
*
|
||||
@@ -2580,9 +2644,9 @@ export class ChatStore {
|
||||
const wire = await window.moho.rpc<WireBuffer[]>('listBuffers')
|
||||
const existing = new Map(this.state.buffers.map((b) => [b.id, b]))
|
||||
const buffers = wire.map((b) => ({
|
||||
unread: existing.get(b.id)?.unread ?? 0,
|
||||
highlight: existing.get(b.id)?.highlight ?? false,
|
||||
mentions: existing.get(b.id)?.mentions ?? 0,
|
||||
unread: existing.get(b.id)?.unread ?? b.unreadSeed ?? 0,
|
||||
highlight: existing.get(b.id)?.highlight ?? (b.mentionsSeed ?? 0) > 0,
|
||||
mentions: existing.get(b.id)?.mentions ?? b.mentionsSeed ?? 0,
|
||||
...b
|
||||
}))
|
||||
this.set({ buffers })
|
||||
@@ -2605,6 +2669,29 @@ 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
|
||||
|
||||
/** When each conversation was last told it is read, so a busy one is told every few seconds and not every line. */
|
||||
private ackedAt = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* What is on screen has been read to its end: the window is in front, the conversation is the open one, and the
|
||||
* list is at its bottom (the caller says so). Told to the service, but not oftener than every few seconds - the
|
||||
* newer of a run of messages covers the older - and never for a conversation that is not being looked at.
|
||||
*/
|
||||
ackIfReading(bufferId: string): void {
|
||||
if (bufferId !== this.state.activeBufferId || !this.windowIsActive()) return
|
||||
const now = Date.now()
|
||||
if (now - (this.ackedAt.get(bufferId) ?? 0) < 5000) return
|
||||
this.ackedAt.set(bufferId, now)
|
||||
bestEffort(this.ackRead(bufferId), 'ack read')
|
||||
}
|
||||
|
||||
private windowIsActive(): boolean {
|
||||
return document.visibilityState === 'visible' && document.hasFocus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the service a conversation has been read.
|
||||
*
|
||||
@@ -2618,6 +2705,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,
|
||||
@@ -2757,6 +2852,57 @@ export class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks what folders a Telegram account has, for when it has just connected. */
|
||||
loadTelegramFolders(accountId: string): void {
|
||||
void window.moho
|
||||
.rpc<TelegramFolder[]>('telegramFolders', { accountId })
|
||||
.then((folders) => this.set({ telegramFolders: { ...this.state.telegramFolders, [accountId]: folders } }))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Files a Telegram conversation under a folder, or under none: a change to the account on Telegram, out of the folder it
|
||||
* is drawn under and into the one chosen. Nothing moves here until Telegram has agreed and said so.
|
||||
*/
|
||||
fileTelegramChat(bufferId: string, folderId: number | null): void {
|
||||
void window.moho
|
||||
.rpc('fileTelegramChat', { bufferId, folderId })
|
||||
.catch((e: Error) => this.toast('error', `Telegram did not move the chat: ${e.message}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a Telegram folder with one conversation in it, which is how a category made here comes to exist there: a
|
||||
* folder needs a chat in it. Resolves when it is made.
|
||||
*/
|
||||
async createTelegramFolder(bufferId: string, name: string): Promise<boolean> {
|
||||
try {
|
||||
await window.moho.rpc('createTelegramFolder', { bufferId, name })
|
||||
return true
|
||||
} catch (e) {
|
||||
this.toast('error', `Telegram did not make the folder: ${(e as Error).message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
renameTelegramFolder(accountId: string, folderId: number, name: string): void {
|
||||
void window.moho
|
||||
.rpc('renameTelegramFolder', { accountId, folderId, name })
|
||||
.catch((e: Error) => this.toast('error', `Telegram did not rename the folder: ${e.message}`))
|
||||
}
|
||||
|
||||
/** Deletes a folder: the chats in it stay where they are, in whatever other folders they are in. */
|
||||
deleteTelegramFolder(accountId: string, folderId: number): void {
|
||||
void window.moho
|
||||
.rpc('deleteTelegramFolder', { accountId, folderId })
|
||||
.catch((e: Error) => this.toast('error', `Telegram did not delete the folder: ${e.message}`))
|
||||
}
|
||||
|
||||
reorderTelegramFolders(accountId: string, order: number[]): void {
|
||||
void window.moho
|
||||
.rpc('reorderTelegramFolders', { accountId, order })
|
||||
.catch((e: Error) => this.toast('error', `Telegram did not reorder the folders: ${e.message}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an IRC account rejoins this channel when it connects.
|
||||
*
|
||||
@@ -3916,6 +4062,12 @@ export class ChatStore {
|
||||
break
|
||||
}
|
||||
|
||||
// Telegram says what folders an account has, or that they changed.
|
||||
case 'telegramFolders': {
|
||||
this.set({ telegramFolders: { ...this.state.telegramFolders, [data.accountId as string]: (data.folders ?? []) as TelegramFolder[] } })
|
||||
break
|
||||
}
|
||||
|
||||
case 'profile': {
|
||||
const answer = data as unknown as Profile
|
||||
const open = this.state.profile
|
||||
@@ -4287,7 +4439,8 @@ export class ChatStore {
|
||||
favourite: data.favourite ?? false,
|
||||
lowPriority: data.lowPriority ?? false,
|
||||
readOnly: data.readOnly,
|
||||
forum: data.forum ?? false
|
||||
forum: data.forum ?? false,
|
||||
...seededCount(b, data, this.state.activeBufferId)
|
||||
}
|
||||
: b
|
||||
)
|
||||
@@ -4295,7 +4448,12 @@ export class ChatStore {
|
||||
this.followPeekJoin(data)
|
||||
return
|
||||
}
|
||||
this.set({ buffers: [...buffers, { unread: 0, highlight: false, mentions: 0, ...data }] })
|
||||
this.set({
|
||||
buffers: [
|
||||
...buffers,
|
||||
{ unread: data.unreadSeed ?? 0, highlight: (data.mentionsSeed ?? 0) > 0, mentions: data.mentionsSeed ?? 0, ...data }
|
||||
]
|
||||
})
|
||||
// Filed under a rail entry this window has never been told of. An
|
||||
// account's own entry is not announced when the account is made - the
|
||||
// daemon lists one for every account when asked - so the first
|
||||
|
||||
@@ -38,6 +38,12 @@ export function useDmCategories(): void {
|
||||
for (const id of connectedSteam.split(',').filter(Boolean)) store.loadSteamGroups(id)
|
||||
}, [connectedSteam, store])
|
||||
|
||||
// The same for a Telegram account's folders, which the account's page draws as its categories.
|
||||
const connectedTelegram = accounts.filter((a) => a.service === 'telegram' && a.state === 'connected').map((a) => a.id).join(',')
|
||||
useEffect(() => {
|
||||
for (const id of connectedTelegram.split(',').filter(Boolean)) store.loadTelegramFolders(id)
|
||||
}, [connectedTelegram, store])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
// The account headings first, then the Steam groups on top of what they
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isDeepLink, isTelegramLink, parseDeepLink } from './deeplink'
|
||||
|
||||
describe('a Telegram link', () => {
|
||||
it('is what the View in Telegram button of a post opens, and what a message says', () => {
|
||||
for (const url of [
|
||||
'tg://resolve?domain=disclosetv&post=21953',
|
||||
'https://t.me/disclosetv/21953',
|
||||
'https://t.me/disclosetv',
|
||||
'https://telegram.me/disclosetv/5',
|
||||
'https://t.me/+AbC_d-123',
|
||||
'https://t.me/joinchat/AbC_d-123',
|
||||
'tg://join?invite=AbC_d-123',
|
||||
'https://t.me/c/1234567/89',
|
||||
'tg://privatepost?channel=1234567&post=89'
|
||||
]) {
|
||||
expect(isTelegramLink(url), url).toBe(true)
|
||||
expect(isDeepLink(url), url).toBe(true)
|
||||
expect(parseDeepLink(url)).toEqual({ service: 'telegram', url })
|
||||
}
|
||||
})
|
||||
|
||||
it('is not a page about something else, another host, or another tg: link', () => {
|
||||
for (const url of [
|
||||
'https://t.me/addstickers/Pack',
|
||||
'https://t.me/s/disclosetv',
|
||||
'https://t.me/proxy?server=a&port=1',
|
||||
'https://t.me/',
|
||||
'https://example.com/disclosetv/1',
|
||||
'https://t.me.example.com/disclosetv/1',
|
||||
'tg://login?token=abc',
|
||||
'tg://resolve?domain=telegrampassport',
|
||||
'tg://addstickers?set=x'
|
||||
]) {
|
||||
expect(isTelegramLink(url), url).toBe(false)
|
||||
expect(isDeepLink(url), url).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not turn an irc link into one, nor the other way round', () => {
|
||||
expect(parseDeepLink('irc://irc.libera.chat/moho')?.service).toBe('irc')
|
||||
expect(isDeepLink('irc://irc.libera.chat/moho')).toBe(true)
|
||||
expect(parseDeepLink('tg://login?token=abc')).toBeNull()
|
||||
expect(parseDeepLink('https://kick.com/xqc')?.service).toBe('kick')
|
||||
})
|
||||
})
|
||||
+56
-4
@@ -27,7 +27,16 @@ export interface KickLink {
|
||||
handle: string
|
||||
}
|
||||
|
||||
export type DeepLink = IrcLink | KickLink
|
||||
/**
|
||||
* A link to a Telegram chat or a post in one, as a `tg://` link (what the "View in Telegram" button on a web preview
|
||||
* opens) or a t.me address in a message. The daemon reads it: this only recognises one, and passes it on whole.
|
||||
*/
|
||||
export interface TelegramLink {
|
||||
service: 'telegram'
|
||||
url: string
|
||||
}
|
||||
|
||||
export type DeepLink = IrcLink | KickLink | TelegramLink
|
||||
|
||||
/**
|
||||
* Schemes moho asks the desktop to send it.
|
||||
@@ -36,12 +45,54 @@ export type DeepLink = IrcLink | KickLink
|
||||
* link is an ordinary web address, and registering for `https` would mean
|
||||
* volunteering to be the system browser.
|
||||
*/
|
||||
export const DEEP_LINK_SCHEMES = ['irc', 'ircs']
|
||||
export const DEEP_LINK_SCHEMES = ['irc', 'ircs', 'tg']
|
||||
|
||||
/** Whether a string is a link this client would act on. */
|
||||
export function isDeepLink(value: string): boolean {
|
||||
const lower = value.toLowerCase()
|
||||
return DEEP_LINK_SCHEMES.some((s) => lower.startsWith(`${s}:`)) || !!kickHandle(value)
|
||||
return DEEP_LINK_SCHEMES.some((s) => lower.startsWith(`${s}:`)) ? isTelegramOrIrc(value) : !!kickHandle(value) || isTelegramLink(value)
|
||||
}
|
||||
|
||||
/** A `tg:` link is only one of ours if it is a kind that opens a chat; the others (login, proxy, themes) are not. */
|
||||
function isTelegramOrIrc(value: string): boolean {
|
||||
return value.toLowerCase().startsWith('tg:') ? isTelegramLink(value) : true
|
||||
}
|
||||
|
||||
/** Pages on t.me that are about something other than a chat. */
|
||||
const TELEGRAM_PAGES = ['s', 'addstickers', 'addemoji', 'setlanguage', 'proxy', 'socks', 'share', 'login', 'iv', 'addtheme', 'bg', 'boost', 'm', 'addlist', 'invoice', 'giftcode', 'contact']
|
||||
|
||||
/**
|
||||
* Whether a string is a link to a Telegram chat, a post, or a private invite: `tg://resolve`, `tg://privatepost`,
|
||||
* `tg://join`, and t.me, telegram.me and telegram.dog addresses that name one. The same reading the daemon makes
|
||||
* (nobilis backend/telegram/links.rs), which has the last word.
|
||||
*/
|
||||
export function isTelegramLink(value: string): boolean {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value.trim())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.protocol === 'tg:') {
|
||||
const params = url.searchParams
|
||||
if (url.hostname === 'resolve') {
|
||||
const domain = params.get('domain') ?? ''
|
||||
return /^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(domain) && domain !== 'telegrampassport'
|
||||
}
|
||||
if (url.hostname === 'privatepost') return /^\d+$/.test(params.get('channel') ?? '')
|
||||
if (url.hostname === 'join') return /^[A-Za-z0-9_-]+$/.test(params.get('invite') ?? '')
|
||||
return false
|
||||
}
|
||||
if (!/^https?:$/.test(url.protocol)) return false
|
||||
if (!['t.me', 'telegram.me', 'telegram.dog'].includes(url.hostname.replace(/^www\./, ''))) return false
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
const first = parts[0]
|
||||
if (!first) return false
|
||||
if (first.startsWith('+')) return /^[A-Za-z0-9_-]+$/.test(first.slice(1))
|
||||
if (first === 'joinchat') return /^[A-Za-z0-9_-]+$/.test(parts[1] ?? '')
|
||||
if (first === 'c') return /^\d+$/.test(parts[1] ?? '')
|
||||
if (TELEGRAM_PAGES.includes(first)) return false
|
||||
return /^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(first)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +142,7 @@ export function kickHandle(value: string): string | null {
|
||||
export function parseDeepLink(value: string): DeepLink | null {
|
||||
const kick = kickHandle(value)
|
||||
if (kick) return { service: 'kick', handle: kick }
|
||||
if (isTelegramLink(value)) return { service: 'telegram', url: value.trim() }
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
@@ -100,7 +152,7 @@ export function parseDeepLink(value: string): DeepLink | null {
|
||||
}
|
||||
|
||||
const scheme = url.protocol.replace(/:$/, '').toLowerCase()
|
||||
if (!DEEP_LINK_SCHEMES.includes(scheme)) return null
|
||||
if (scheme !== 'irc' && scheme !== 'ircs') return null
|
||||
const host = url.hostname
|
||||
if (!host) return null
|
||||
|
||||
|
||||
@@ -118,6 +118,13 @@ export interface Buffer {
|
||||
readOnly?: string
|
||||
/** A Discord forum: a list of posts, each a thread, not a conversation. */
|
||||
forum?: boolean
|
||||
/**
|
||||
* What the service counts as unread here, where it keeps a count this window could not work out (a Telegram topic
|
||||
* never opened here): a starting point for the count, which then follows live messages.
|
||||
*/
|
||||
unreadSeed?: number
|
||||
/** How many of those mention this account. */
|
||||
mentionsSeed?: number
|
||||
/** Matrix only - absent, not false, for protocols with no encryption concept. */
|
||||
encrypted?: boolean
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user