Compare commits
@@ -228,6 +228,17 @@ called `subscribe` for that buffer.
|
||||
the stream plays in the window as the person (their Turbo or subscriptions count) with Twitch's ad
|
||||
breaks said and never skipped.
|
||||
|
||||
- **Telegram** - MTProto through grammers (a pure-Rust client, pinned), with each account carrying its own
|
||||
`api_id` and `api_hash` from my.telegram.org (moho embeds none, so no install shares a key) and one saved
|
||||
session per account in a private file that is never re-created on a hiccup. Sign-in by phone, code and cloud
|
||||
password. Chats are read from the dialog list and become buffers under Direct Messages, Groups and Channels
|
||||
(a channel the account cannot write in shows the lock message instead of the message box); history, live
|
||||
messages, edits and deletions, formatting both ways, replies and forwards, service messages as cards, media
|
||||
fetched on demand into a cache, reactions, pins, polls, search in a chat or everywhere, members and profiles,
|
||||
threads and a channel's comments, join by @name or link, administrator actions, and a mute that follows the
|
||||
account. Not supported: secret chats, calls, stories, payments; sign-in by QR code, folders, forum topics,
|
||||
drafts and scheduled messages are not done yet. The code is `nobilis/src/backend/telegram/`.
|
||||
|
||||
### Notifications are cards
|
||||
|
||||
Anything a service *says* rather than somebody *saying* - a join or part, a topic or mode change, a kick,
|
||||
|
||||
@@ -17,6 +17,56 @@ Each section has two parts:
|
||||
that made it, including dependency updates
|
||||
(`node scripts/changelog-deps.mjs <previous tag>` lists those).
|
||||
|
||||
## [1.3.2]
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Discord is asked for less and more politely, after what Dissent does to avoid being flagged as a third-party client: reads wait out a rate limit and let an empty bucket refill instead of treating a refusal as an answer; no history is fetched at login (a conversation is read the first time it is opened, 30 messages); a member list is subscribed to once a connection and no longer again at every join and leave; our own membership of each server comes from login and is not asked for; a read mark is sent only while the window is in front, never for a conversation whose last word is yours, and not again after another device sent it; text Discord would refuse (over 2,000 characters, 4,000 with Nitro, or empty) is refused before it is sent; the login declares the capabilities of a user client; the first reconnect wait is four seconds ([moho `74f2c47`](https://github.com/Moho-Chat/moho/commit/74f2c47), [nobilis `7943767`](https://github.com/Moho-Chat/nobilis/commit/7943767))
|
||||
- The typing notice to Discord is off unless you turn it on in Settings > Discord, and adding a Discord account says plainly that third-party clients are against Discord's terms and what moho does about that ([moho `74f2c47`](https://github.com/Moho-Chat/moho/commit/74f2c47))
|
||||
- A reply's quote, in the line over a reply and in the bar over the box, draws what it quotes - emotes, emoji and stickers as pictures instead of their codes - on every service ([moho `df818a1`](https://github.com/Moho-Chat/moho/commit/df818a1))
|
||||
|
||||
## [1.3.1]
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Discord is not spoken to first from here: a server where your account has not said anything since it joined, and a conversation with nothing in it, are locked with a note to send the first message from Discord's own app or website, after Discord flagged accounts for exactly that. Whether you have spoken is looked for in what moho keeps and then in Discord's search (one request a server, remembered), the lock lifts by itself once there is a message of yours there, and a message sent from moho does not count ([moho `b04189a`](https://github.com/Moho-Chat/moho/commit/b04189a), [nobilis `b9a79e4`](https://github.com/Moho-Chat/nobilis/commit/b9a79e4))
|
||||
- Discord messages carry a nonce and an explicit list of who may be mentioned, as Discord's own clients send them; a file is checked against what the account may upload before any of it is sent, and waits a few seconds after a message instead of following straight on ([moho `b04189a`](https://github.com/Moho-Chat/moho/commit/b04189a), [nobilis `b9a79e4`](https://github.com/Moho-Chat/nobilis/commit/b9a79e4))
|
||||
- Taking bold, italic, underline or strikethrough off all of the message box (select all, then the same button or key) works, and undo and redo step over formatting and leave the selection where it was ([moho `5b438be`](https://github.com/Moho-Chat/moho/commit/5b438be))
|
||||
|
||||
## [1.3.0]
|
||||
|
||||
### Major features
|
||||
|
||||
- **Telegram, ported:** your chats, groups, channels and bots in the same window as everything else. Sign in with your phone number, the code Telegram sends and your two-step password if you have one, using your own API id and hash from my.telegram.org (one is enough for every client you use); the session is kept, so a restart does not sign in again.
|
||||
- **Telegram conversations:** private chats and bots as direct messages, groups and supergroups on the server rail like Discord's servers, channels with their posts, and a group's forum topics as conversations of their own. A horn marks a broadcast channel and a robot a bot. History loads as you scroll, new messages, edits and deletions arrive live, and messages you send, edit and delete go out the same way.
|
||||
- **Telegram messages:** formatting, replies (with the quote shown above and a click to the original, even in another channel), forwards with a link back to where they came from, pictures, videos, files and voice messages drawn as they arrive and fetched when played, link cards, and Instant View articles opened in a window of their own.
|
||||
- **Telegram on top of the chat:** reactions, pinned messages, polls (voting and making them), search, comments and thread counts on channel posts, a member list with moderation where you have the rights, muting, joining by link, and the GIF picker with your saved GIFs and Telegram's own search.
|
||||
- **Telegram bots:** a bot's inline buttons are drawn under its message and pressed from the window, and the Menu button opens the bot's own command list.
|
||||
- **Discord stays signed in:** a restart within a couple of minutes resumes the session Discord is already holding instead of logging in again, only the conversations that have something new are asked about, and a limit on fresh logins (3 an hour, 8 a day) holds the connection and says why, with a Connect anyway button when you mean it.
|
||||
- **One video player on every service:** play, seek, a volume slider that opens on hover, elapsed or remaining time, speed, full screen, save, and edit on your own messages.
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Select text in a message or in the message box, right-click it and search the web for it, in your default browser, with the engine chosen in Settings > General: DuckDuckGo, Google, Bing, Brave Search, Startpage, Ecosia, Qwant, Yandex, Kagi, or an address of your own ([moho `4233439`](https://github.com/Moho-Chat/moho/commit/4233439))
|
||||
- Formatting in the message box can be taken off again with the same button or key, over a whole run, part of one or a mixed selection, and undo and redo step over each change and leave the selection where it was ([moho `496d18f`](https://github.com/Moho-Chat/moho/commit/496d18f))
|
||||
- Discord sends this install's id, language and timezone with its requests, as its own clients do; it still says it is moho ([moho `3a6c8da`](https://github.com/Moho-Chat/moho/commit/3a6c8da), [nobilis `3bf68e7`](https://github.com/Moho-Chat/nobilis/commit/3bf68e7))
|
||||
- Discord resumes its session after a restart, asks only about conversations that moved on, posts typing at most every eight seconds and does not repeat a read mark ([moho `ab8f40f`](https://github.com/Moho-Chat/moho/commit/ab8f40f), [nobilis `e8b25d9`](https://github.com/Moho-Chat/nobilis/commit/e8b25d9))
|
||||
- Discord limits fresh logins per account, holds the connection when it is over, and lets you through with Connect anyway ([moho `59f978d`](https://github.com/Moho-Chat/moho/commit/59f978d), [nobilis `8a7d733`](https://github.com/Moho-Chat/nobilis/commit/8a7d733), [moho `8f62f44`](https://github.com/Moho-Chat/moho/commit/8f62f44), [nobilis `58bf5ac`](https://github.com/Moho-Chat/nobilis/commit/58bf5ac))
|
||||
- Discord's membership-screening dialog is drawn as Discord draws it, with the rules, the questions and the answers sent ([moho `23afcb6`](https://github.com/Moho-Chat/moho/commit/23afcb6), [nobilis `63093e1`](https://github.com/Moho-Chat/nobilis/commit/63093e1))
|
||||
- Discord messages made of layout components (containers, sections, text, pictures) are drawn as cards with their buttons ([moho `c64812c`](https://github.com/Moho-Chat/moho/commit/c64812c), [nobilis `41c2780`](https://github.com/Moho-Chat/nobilis/commit/41c2780))
|
||||
- Discord's APP mark on bots and applications, a check on verified ones, and OFFICIAL on the platform's own account and its messages; badges sit between the name and the time ([moho `2f4c204`](https://github.com/Moho-Chat/moho/commit/2f4c204), [nobilis `dba7731`](https://github.com/Moho-Chat/nobilis/commit/dba7731), [moho `510627f`](https://github.com/Moho-Chat/moho/commit/510627f), [nobilis `5501675`](https://github.com/Moho-Chat/nobilis/commit/5501675), [moho `5d2d0fe`](https://github.com/Moho-Chat/moho/commit/5d2d0fe))
|
||||
- Discord: an emoji picked from another server's section goes in as its token and not the number that names it, and the hover toolbar and recent list no longer show bare numbers ([moho `9a86141`](https://github.com/Moho-Chat/moho/commit/9a86141), [moho `f1bf63a`](https://github.com/Moho-Chat/moho/commit/f1bf63a))
|
||||
- Discord reads a backslash before punctuation as an escape in messages, link previews and embed field values ([moho `eb549f7`](https://github.com/Moho-Chat/moho/commit/eb549f7), [moho `a2a8949`](https://github.com/Moho-Chat/moho/commit/a2a8949))
|
||||
- Videos go full screen by themselves on every service, filling the screen, with no second expand button and no viewer ([moho `bf5c33d`](https://github.com/Moho-Chat/moho/commit/bf5c33d), [moho `1387907`](https://github.com/Moho-Chat/moho/commit/1387907), [moho `aab10c7`](https://github.com/Moho-Chat/moho/commit/aab10c7), [moho `72d5b83`](https://github.com/Moho-Chat/moho/commit/72d5b83))
|
||||
- A left click on the tray icon puts the window away when it is showing and brings it back when it is not ([moho `4ebabb2`](https://github.com/Moho-Chat/moho/commit/4ebabb2))
|
||||
- The GIF picker is offered only where a GIF link shows as a picture: not on Steam, Twitch, Sneedchat or IRC ([moho `40505af`](https://github.com/Moho-Chat/moho/commit/40505af), [moho `7a2fda0`](https://github.com/Moho-Chat/moho/commit/7a2fda0))
|
||||
|
||||
## [1.2.1]
|
||||
|
||||
### Fixes, patches and changes
|
||||
|
||||
- Steam signs in as a desktop client of its own, with a machine id and a logon id that belong to the install, instead of as the Steam mobile app (a "phone" reporting a Linux desktop, with no machine id, which Steam raised an account alert about), and waits longer before reconnecting after Steam closes a connection. Sign in to Steam again from Accounts to take on the new identity; the old one stays in Steam's list of devices until you remove it there ([moho `fceec5d`](https://github.com/Moho-Chat/moho/commit/fceec5d), [nobilis `f3de870`](https://github.com/Moho-Chat/nobilis/commit/f3de870))
|
||||
|
||||
## [1.2.0]
|
||||
|
||||
### Major features
|
||||
|
||||
+1
-1
Submodule nobilis updated: 2355fcb6ac...ec34657d92
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "moho",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.2",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moho",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.2",
|
||||
"description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix",
|
||||
"author": "Salastil <[email protected]>",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Formatting in the message box, in a scratch moho with injected state (nothing is contacted): a format applied
|
||||
* to a selection can be taken off again - by the keyboard shortcut or the strip's button, over the whole run,
|
||||
* over part of it, or over a selection reaching past it - and each step can be undone with the box's own undo.
|
||||
*
|
||||
* node scripts/check-composer-format.mjs (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 = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
|
||||
}
|
||||
const BOX = '.composer-input'
|
||||
const html = () => ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML.replace(/ data-[a-z-]+="[^"]*"/g, (m) => m.startsWith(' data-fmt') ? m : '')`)
|
||||
/** The text the box would send, with the markers of what is bold, italic, and so on in it. */
|
||||
const marked = () =>
|
||||
ev(`(() => {
|
||||
const mark = { bold: '**', italic: '_', underline: '__', strike: '~~', code: '\`', spoiler: '||', quote: '> ' }
|
||||
const 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) => {
|
||||
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); b.innerHTML = '' })()`)
|
||||
await page.call('Input.insertText', { text })
|
||||
}
|
||||
const select = (needle, nth = 0) =>
|
||||
ev(`(() => {
|
||||
const box = document.querySelector(${JSON.stringify(BOX)})
|
||||
const text = []
|
||||
const w = document.createTreeWalker(box, NodeFilter.SHOW_TEXT)
|
||||
for (let n; (n = w.nextNode()); ) text.push(n)
|
||||
const all = text.map((t) => t.nodeValue).join('')
|
||||
let at = -1
|
||||
for (let i = 0; i <= ${nth}; i++) at = all.indexOf(${JSON.stringify(needle)}, at + 1)
|
||||
if (at < 0) return false
|
||||
const locate = (offset) => { let seen = 0; for (const t of text) { if (offset <= seen + t.nodeValue.length) return [t, offset - seen]; seen += t.nodeValue.length } }
|
||||
const [a, ao] = locate(at)
|
||||
const [b, bo] = locate(at + ${JSON.stringify(needle)}.length)
|
||||
const r = document.createRange()
|
||||
r.setStart(a, ao); r.setEnd(b, bo)
|
||||
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
|
||||
box.focus()
|
||||
return true
|
||||
})()`)
|
||||
const key = (k, { ctrl = false, commands } = {}) =>
|
||||
page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: 'Key' + k.toUpperCase(), windowsVirtualKeyCode: k.toUpperCase().charCodeAt(0), modifiers: ctrl ? 2 : 0, commands })
|
||||
const bold = () => key('b', { ctrl: true })
|
||||
const undo = () => key('z', { ctrl: true, commands: ['undo'] })
|
||||
const redo = () => key('z', { ctrl: true, commands: ['redo'] })
|
||||
const strip = async (title) => {
|
||||
await sleep(150)
|
||||
return ev(`(() => { const b = [...document.querySelectorAll('.format-bar button')].find((x) => x.title === ${JSON.stringify(title)}); if (!b) return false; b.click(); return true })()`)
|
||||
}
|
||||
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
const acct = { id: '${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)
|
||||
|
||||
console.log('Applying and taking off')
|
||||
await type('one two three four')
|
||||
await select('two')
|
||||
await bold()
|
||||
check('bold goes on', (await marked()) === 'one **two** three four', await marked())
|
||||
await bold()
|
||||
check('the same key again takes it off', (await marked()) === 'one two three four', await marked())
|
||||
await bold()
|
||||
check('and again puts it back', (await marked()) === 'one **two** three four', await marked())
|
||||
|
||||
console.log('Undo')
|
||||
await undo()
|
||||
check('undo takes the bold back off', (await marked()) === 'one two three four', await marked())
|
||||
await redo()
|
||||
check('redo puts it on again', (await marked()) === 'one **two** three four', await marked())
|
||||
check('redo leaves the word selected, not the whole message', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||
await bold()
|
||||
await undo()
|
||||
check('undo puts it back after it was taken off', (await marked()) === 'one **two** three four', await marked())
|
||||
check('and leaves the word selected', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||
|
||||
console.log('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')
|
||||
await bold()
|
||||
await undo()
|
||||
check('after undo the word is selected, not the whole message', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||
|
||||
console.log('The ends of the box')
|
||||
await type('one two')
|
||||
await select('one')
|
||||
await bold()
|
||||
check('the first word goes bold', (await marked()) === '**one** two', await marked())
|
||||
await bold()
|
||||
check('and comes off', (await marked()) === 'one two', await marked())
|
||||
await select('two')
|
||||
await bold()
|
||||
check('the last word goes bold', (await marked()) === 'one **two**', await marked())
|
||||
await bold()
|
||||
check('and comes off', (await marked()) === 'one two', await marked())
|
||||
|
||||
console.log('Other formats')
|
||||
await type('one two three')
|
||||
await select('two')
|
||||
await key('i', { ctrl: true })
|
||||
check('italic goes on', (await marked()) === 'one _two_ three', await marked())
|
||||
await select('one two thr')
|
||||
await bold()
|
||||
check('bold over italic keeps both', (await marked()) === '**one _two_ thr**ee', await marked())
|
||||
await key('i', { ctrl: true })
|
||||
check('italic over text that is only partly italic makes all of it italic', (await marked()) === '**_one two thr_**ee', await marked())
|
||||
await key('i', { ctrl: true })
|
||||
check('and again takes italic off all of it, leaving the bold', (await marked()) === '**one two thr**ee', await marked())
|
||||
|
||||
console.log('Lines')
|
||||
await type('first line')
|
||||
await page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, modifiers: 8 })
|
||||
await page.call('Input.insertText', { text: 'second line' })
|
||||
if (!(await select('line\nsecond'))) await select('linesecond')
|
||||
await bold()
|
||||
const lines = await ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML`)
|
||||
check('a selection over two lines is bold, and the break is still there', /<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|<b>/.test(await html()), await html())
|
||||
|
||||
console.log('Part of a run')
|
||||
await type('one two three four')
|
||||
await select('two three')
|
||||
await bold()
|
||||
await select('wo th')
|
||||
await bold()
|
||||
check('part of a run is taken off, the rest stays', (await marked()) === 'one **t**wo th**ree** four', await marked())
|
||||
|
||||
console.log('Reaching past a run')
|
||||
await type('one two three four')
|
||||
await select('two')
|
||||
await bold()
|
||||
await select('one two thr')
|
||||
await bold()
|
||||
check('a selection partly bold is made all bold, not doubled', (await marked()) === '**one two thr**ee four', await marked())
|
||||
await bold()
|
||||
check('and the next press takes it all off', (await marked()) === 'one two three four', await marked())
|
||||
|
||||
console.log('The strip')
|
||||
await type('one two three four')
|
||||
await select('three')
|
||||
await sleep(300)
|
||||
check('the strip offers bold', await strip('Bold'))
|
||||
check('and it goes on', (await marked()) === 'one two **three** four', await marked())
|
||||
await sleep(300)
|
||||
check('the strip button again takes it off', (await strip('Bold')) && (await marked()) === 'one two three four', await marked())
|
||||
await undo()
|
||||
check('and undo puts it back', (await marked()) === 'one two **three** four', await marked())
|
||||
} catch (e) {
|
||||
console.log(' FAIL', e.message)
|
||||
results.push(false)
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
process.exit(results.every(Boolean) ? 0 : 1)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Searching the web for selected text, in a scratch moho with injected state (nothing is contacted): the message
|
||||
* menu offers it for a selection only, names the engine chosen in Settings > General, follows a change of engine,
|
||||
* and Settings shows the drop-down (and the address field for an engine of one's own).
|
||||
*
|
||||
* node scripts/check-search-menu.mjs [output directory]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const out = process.argv[2]
|
||||
if (out) fs.mkdirSync(out, { recursive: true })
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const results = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
|
||||
}
|
||||
const shot = async (name) => {
|
||||
if (!out) return
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
const mouse = (type, x, y, button = 'left') => page.call('Input.dispatchMouseEvent', { type, x, y, button, buttons: type === 'mouseReleased' ? 0 : button === 'right' ? 2 : 1, clickCount: 1 })
|
||||
const menuLabels = () => ev(`[...document.querySelectorAll('.context-menu .context-menu-item')].map((e) => e.innerText.trim().split('\\n').pop())`)
|
||||
/** Sets a field the way a person's edit does, so the window's own handlers run. */
|
||||
const setField = (selector, value, how) => ev(`(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 }))
|
||||
// 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 () => {
|
||||
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
|
||||
await sleep(500)
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('button, [role=tab], a')].find((e) => e.innerText.trim() === 'General'); b?.click() })()`)
|
||||
await sleep(500)
|
||||
}
|
||||
const closeMenu = async () => {
|
||||
await page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
|
||||
await sleep(200)
|
||||
}
|
||||
/** Selects `needle` in the first message and right-clicks on it. */
|
||||
const rightClickOn = async (needle) => {
|
||||
const at = await ev(`(() => {
|
||||
const body = document.querySelector('.message-body') ?? document.querySelector('[data-msg-id]')
|
||||
const w = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
|
||||
for (let n; (n = w.nextNode()); ) {
|
||||
const i = n.nodeValue.indexOf(${JSON.stringify(needle)})
|
||||
if (i < 0) continue
|
||||
const r = document.createRange()
|
||||
r.setStart(n, i); r.setEnd(n, i + ${JSON.stringify(needle)}.length)
|
||||
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
|
||||
const b = r.getBoundingClientRect()
|
||||
return { x: b.left + b.width / 2, y: b.top + b.height / 2 }
|
||||
}
|
||||
return null
|
||||
})()`)
|
||||
if (!at) return false
|
||||
await mouse('mouseMoved', at.x, at.y)
|
||||
await mouse('mousePressed', at.x, at.y, 'right')
|
||||
await mouse('mouseReleased', at.x, at.y, 'right')
|
||||
await sleep(400)
|
||||
return true
|
||||
}
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
const acct = { id: 'discord:me', service: 'discord', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
|
||||
const buf = { id: 'discord:me|chat', accountId: 'discord:me', kind: 'channel', name: 'chat', lastActivityTs: 5, category: 'Channels', groupId: 'account:discord:me' }
|
||||
const t = Math.floor(Date.now() / 1000) - 600
|
||||
const messages = [{ id: 'm1', bufferId: 'discord:me|chat', from: 'ada', body: 'the Antikythera mechanism is older than anyone expected', ts: t, kind: 'chat', isAction: false, isHighlight: false, isOwn: false, edited: false }]
|
||||
s.set({ accounts: [acct], groups: [{ id: 'account:discord:me', accountId: 'discord:me', service: 'discord', kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { 'discord:me|chat': messages }, activeBufferId: 'discord:me|chat' })
|
||||
s.selectGroup('account:discord:me')
|
||||
})()`)
|
||||
await sleep(1200)
|
||||
|
||||
console.log('Message menu')
|
||||
check('a message with a word selected', await rightClickOn('Antikythera mechanism'))
|
||||
let labels = await menuLabels()
|
||||
check('offers a search for it, by the default engine', labels.some((l) => l === 'Search DuckDuckGo for “Antikythera mechanism”'), JSON.stringify(labels))
|
||||
await shot('search-menu.png')
|
||||
await closeMenu()
|
||||
|
||||
await openGeneral()
|
||||
check('Settings > General has the engine drop-down', await setField('select[aria-label="Search engine"]', 'google', 'change'))
|
||||
await sleep(300)
|
||||
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||
await sleep(500)
|
||||
await rightClickOn('Antikythera mechanism is older')
|
||||
labels = await menuLabels()
|
||||
check('names the engine chosen in Settings, and shortens a long selection', labels.some((l) => /^Search Google for “Antikythera mechanism is ol…”$/.test(l)), JSON.stringify(labels))
|
||||
await closeMenu()
|
||||
|
||||
await openGeneral()
|
||||
await setField('select[aria-label="Search engine"]', 'custom', 'change')
|
||||
await sleep(300)
|
||||
check('an own engine asks for an address', await setField('input[placeholder="https://example.org/search?q=%s"]', 'https://www.example.org/find?q=%s', 'input'))
|
||||
await shot('search-settings.png')
|
||||
await sleep(300)
|
||||
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||
await sleep(500)
|
||||
await rightClickOn('Antikythera')
|
||||
labels = await menuLabels()
|
||||
check('an engine of one’s own is named by its host', labels.some((l) => l === 'Search example.org for “Antikythera”'), JSON.stringify(labels))
|
||||
await closeMenu()
|
||||
|
||||
await openGeneral()
|
||||
await setField('input[placeholder="https://example.org/search?q=%s"]', 'nonsense', 'input')
|
||||
await sleep(300)
|
||||
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||
await sleep(500)
|
||||
await rightClickOn('Antikythera')
|
||||
labels = await menuLabels()
|
||||
check('an address that cannot be searched is not offered', !labels.some((l) => l.startsWith('Search ')), JSON.stringify(labels))
|
||||
await closeMenu()
|
||||
|
||||
await openGeneral()
|
||||
await setField('select[aria-label="Search engine"]', 'duckduckgo', 'change')
|
||||
await sleep(300)
|
||||
await ev(`window.__mohoShots.store.setActivePanel('')`)
|
||||
await sleep(500)
|
||||
await ev(`getSelection().removeAllRanges()`)
|
||||
const row = await ev(`(() => { const r = document.querySelector('[data-msg-id]').getBoundingClientRect(); return { x: r.right - 20, y: r.top + r.height / 2 } })()`)
|
||||
await mouse('mouseMoved', row.x, row.y)
|
||||
await mouse('mousePressed', row.x, row.y, 'right')
|
||||
await mouse('mouseReleased', row.x, row.y, 'right')
|
||||
await sleep(400)
|
||||
labels = await menuLabels()
|
||||
check('without a selection there is no search entry', labels.length > 0 && !labels.some((l) => l.startsWith('Search ')), JSON.stringify(labels))
|
||||
await closeMenu()
|
||||
|
||||
} catch (e) {
|
||||
console.log(' FAIL', e.message)
|
||||
results.push(false)
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
process.exit(results.every(Boolean) ? 0 : 1)
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The Telegram parts of the window, in a scratch moho (nothing is typed into a form that would be sent, so nothing
|
||||
* reaches Telegram): the account picker offers Telegram with its mark, its form asks for the api_id, the api_hash and
|
||||
* the phone number, explains where the first two come from, keeps the button off until all three are given, and
|
||||
* warns about Tor.
|
||||
*
|
||||
* node scripts/check-telegram-ui.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] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
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)
|
||||
}
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1100, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
|
||||
await sleep(1200)
|
||||
const chip = await ev(`(() => { const c = [...document.querySelectorAll('.service-chip')].find((x) => x.textContent.includes('Telegram')); return c ? { text: c.textContent.trim(), mark: !!c.querySelector('.mask-icon, img, span[style*="mask"]') } : null })()`)
|
||||
check('the picker offers Telegram', !!chip, JSON.stringify(chip))
|
||||
await ev(`[...document.querySelectorAll('.service-chip')].find((x) => x.textContent.includes('Telegram')).click()`)
|
||||
await sleep(500)
|
||||
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 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(`(() => {
|
||||
const label = [...document.querySelectorAll('.add-form label.field')].find((l) => l.textContent.includes(${JSON.stringify(placeholderOrLabel)}))
|
||||
const input = label.querySelector('input')
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
|
||||
set.call(input, ${JSON.stringify(value)})
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})()`)
|
||||
await fill('api_id', '12345')
|
||||
await fill('api_hash', 'abcdef')
|
||||
check('two of three are not enough', (await disabled()) === true)
|
||||
await fill('Phone number', '+1 555 123 4567')
|
||||
await sleep(200)
|
||||
check('all three turn the button on', (await disabled()) === false)
|
||||
await ev(`document.querySelector('.add-form .switch')?.click()`)
|
||||
await sleep(300)
|
||||
const warning = await ev(`document.querySelector('.add-form .warning-note')?.innerText || ''`)
|
||||
check('routing it says Telegram may challenge a Tor sign-in', /Telegram/.test(warning), warning.slice(0, 80))
|
||||
await shot('telegram-form.png')
|
||||
} finally {
|
||||
await stop()
|
||||
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
|
||||
process.exitCode = results.every(Boolean) ? 0 : 1
|
||||
}
|
||||
+13
-3
@@ -101,8 +101,18 @@ try {
|
||||
const klipy = rows.find((r) => r.text.includes('klipy.com'))
|
||||
check(!!klipy && klipy.videos.length === 0, 'a Klipy page nothing could read stays a link', JSON.stringify(klipy))
|
||||
|
||||
// The picker.
|
||||
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where there is a Discord account')
|
||||
// The picker. It is offered only where a GIF link shows as a picture - Discord, Matrix, Kick - and none of those can be
|
||||
// stood up here, so the IRC account is shown to the window as a Matrix one: the picker appears, and what it sends still
|
||||
// goes through the daemon to the fake IRC server.
|
||||
check(!(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')')), 'no GIF button on an IRC conversation, which would show a GIF link as a link')
|
||||
await page.evaluate(`(() => {
|
||||
const shots = window.__mohoShots
|
||||
const s = shots.state()
|
||||
const account = s.accounts.find((a) => a.id === s.buffers.find((b) => b.id === s.activeBufferId)?.accountId)
|
||||
shots.patch({ accounts: s.accounts.map((a) => (a === account ? { ...a, service: 'matrix' } : a)) })
|
||||
})()`)
|
||||
await sleep(500)
|
||||
check(await page.evaluate('!!document.querySelector(\'button[title="GIFs"]\')'), 'a GIF button is offered where a GIF link shows as a picture')
|
||||
await page.evaluate(click('button[title="GIFs"]'))
|
||||
await until(page, 'document.querySelector(".gif-picker")', 'the picker')
|
||||
check((await page.evaluate('document.querySelectorAll(".gif-cell").length')) === 3, 'it lists what it was given')
|
||||
@@ -117,7 +127,7 @@ try {
|
||||
// Choosing one sends it - to IRC, the file, which anything can show as a link.
|
||||
await page.evaluate(`document.querySelector('.gif-cell[title="cat dancing"]').click()`)
|
||||
await sleep(1200)
|
||||
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord', JSON.stringify(sent))
|
||||
check(sent.some((l) => /PRIVMSG #lobby :?http:\/\/127\.0\.0\.1:9\/a\.gif/.test(l)), 'choosing one sends the file to a service that is not Discord (here IRC\'s wire, dressed as Matrix)', JSON.stringify(sent))
|
||||
check(!(await page.evaluate('!!document.querySelector(".gif-picker")')), 'and the picker closes')
|
||||
} catch (e) {
|
||||
console.log('FAIL', e.message)
|
||||
|
||||
+61
-4
@@ -356,7 +356,8 @@ function wireEditMenu(win: BrowserWindow): void {
|
||||
delete: f.canDelete,
|
||||
selectAll: f.canSelectAll
|
||||
},
|
||||
hasSelection: params.selectionText.length > 0
|
||||
hasSelection: params.selectionText.length > 0,
|
||||
selection: params.selectionText.slice(0, 1000)
|
||||
} satisfies EditMenuRequest)
|
||||
})
|
||||
}
|
||||
@@ -619,6 +620,28 @@ function toggleWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a left click on the tray icon does: put the window away if it is showing, bring it back if it is not.
|
||||
*
|
||||
* By whether it is showing, and not by whether it has focus. Clicking the tray takes the focus off the window first
|
||||
* on most desktops, so asking whether it is focused always said no, and a click could only ever show the window -
|
||||
* never put it away. The hotkey keeps the focus test (see `toggleWindow`): pressed while typing somewhere else it
|
||||
* means "bring moho here", not "hide it".
|
||||
*/
|
||||
function toggleFromTray(): void {
|
||||
if (!mainWindow) {
|
||||
createWindow()
|
||||
return
|
||||
}
|
||||
if (mainWindow.isVisible() && !mainWindow.isMinimized()) {
|
||||
mainWindow.hide()
|
||||
return
|
||||
}
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function trayIcon(unread: number): Electron.NativeImage {
|
||||
lastTrayIcon = trayIconFile(nativeTheme.shouldUseDarkColors, unread)
|
||||
const img = nativeImage.createFromPath(resourcePath('icons', lastTrayIcon))
|
||||
@@ -700,7 +723,7 @@ function createTray(): void {
|
||||
tray = new Tray(trayIcon(0))
|
||||
tray.setToolTip('moho')
|
||||
refreshTrayMenu()
|
||||
tray.on('click', toggleWindow)
|
||||
tray.on('click', toggleFromTray)
|
||||
// The icon is drawn for the panel it sits on, so it is drawn again when the
|
||||
// system changes colour scheme.
|
||||
nativeTheme.on('updated', () => updateTray(lastTray))
|
||||
@@ -893,6 +916,38 @@ function wireIpc(): void {
|
||||
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url)
|
||||
})
|
||||
|
||||
// An article, drawn from a document the daemon built (see nobilis's backend/telegram/instant.rs). It gets a window
|
||||
// of its own that can do nothing but show it: no script, no preload, no way to navigate away - a link opens in the
|
||||
// browser - and the only pictures it may draw are the daemon's cached ones, through moho-media.
|
||||
ipcMain.handle(IPC.openArticle, (_e, title: unknown, html: unknown) => {
|
||||
if (typeof html !== 'string' || html.length > 8_000_000) return
|
||||
const win = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 800,
|
||||
minWidth: 360,
|
||||
minHeight: 300,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#0e1621',
|
||||
icon: resourcePath('icons', 'moho.png'),
|
||||
title: typeof title === 'string' && title ? title : 'Article',
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, javascript: false }
|
||||
})
|
||||
const external = (url: string): void => {
|
||||
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url)
|
||||
}
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
external(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
// A place in the same page is fine; anything else is somewhere else.
|
||||
if (url.startsWith('data:') && url.includes('#')) return
|
||||
event.preventDefault()
|
||||
external(url)
|
||||
})
|
||||
void win.loadURL(`data:text/html;charset=utf-8;base64,${Buffer.from(html, 'utf8').toString('base64')}`)
|
||||
})
|
||||
|
||||
// Where to write something the app is about to produce - a key export, so
|
||||
// far. Separate from pickFile because the two dialogs ask opposite
|
||||
// questions, and a save dialog that cannot name a default file is a save
|
||||
@@ -1152,7 +1207,7 @@ app.whenReady().then(() => {
|
||||
applyPolicy(contents)
|
||||
contents.on('console-message', (event) => {
|
||||
const message = (event as unknown as { message?: string }).message
|
||||
if (typeof message === 'string' && message.startsWith('[call] ')) log.info(message)
|
||||
if (typeof message === 'string' && (message.startsWith('[call] ') || message.startsWith('[restore] '))) log.info(message)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1188,7 +1243,9 @@ app.whenReady().then(() => {
|
||||
// Granted only to this application's own windows, on the default session.
|
||||
// A sign-in window loads somebody else's page and runs in a partition of
|
||||
// its own, which this handler never sees.
|
||||
const allowed = new Set(['media', 'display-capture', 'audioCapture', 'videoCapture'])
|
||||
// And full screen, which a page asks for with requestFullscreen: a handler that does not name it refuses it, and the
|
||||
// video player's button then did nothing.
|
||||
const allowed = new Set(['media', 'display-capture', 'audioCapture', 'videoCapture', 'fullscreen'])
|
||||
session.defaultSession.setPermissionRequestHandler((_contents, permission, callback) => {
|
||||
callback(allowed.has(permission))
|
||||
})
|
||||
|
||||
@@ -122,6 +122,8 @@ const DAEMON_MEDIA_DIRS = [
|
||||
'kick-emotes',
|
||||
'discord-stickers',
|
||||
'discord-sounds',
|
||||
'telegram-media',
|
||||
'telegram-avatars',
|
||||
'transient'
|
||||
]
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ const api = {
|
||||
|
||||
openExternal: (url: string): Promise<void> => ipcRenderer.invoke(IPC.openExternal, url),
|
||||
|
||||
/** A finished document (a Telegram Instant View) in a window of its own. */
|
||||
openArticle: (title: string, html: string): Promise<void> => ipcRenderer.invoke(IPC.openArticle, title, html),
|
||||
|
||||
/**
|
||||
* Which picture a page is showing, read from its own OpenGraph tag.
|
||||
*
|
||||
|
||||
+421
-2
@@ -2982,8 +2982,8 @@ img.custom-emoji.steam-sticker {
|
||||
}
|
||||
|
||||
.membership-rules {
|
||||
max-width: 480px;
|
||||
max-height: 70vh;
|
||||
width: min(860px, 94vw);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-l);
|
||||
border-radius: var(--corner-radius);
|
||||
@@ -2992,6 +2992,69 @@ img.custom-emoji.steam-sticker {
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
/* The server on the left and its form on the right, as Discord draws it; one above the other when narrow. */
|
||||
.membership-body {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: var(--spacing-xl, 24px);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.membership-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.membership-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--spacing-m);
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container);
|
||||
border: 1px solid var(--outline-medium);
|
||||
}
|
||||
.membership-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.membership-card-name {
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.membership-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.membership-field {
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
.membership-field-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.membership-rules-list {
|
||||
margin: 0 0 var(--spacing-s);
|
||||
padding: var(--spacing-s) var(--spacing-m) var(--spacing-s) 2em;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--surface-container);
|
||||
}
|
||||
.membership-rules-list li {
|
||||
padding: 6px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.membership-rules-list li + li {
|
||||
border-top: 1px solid var(--outline-medium);
|
||||
}
|
||||
.membership-agree {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.membership-rules-head {
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--weight-semibold);
|
||||
@@ -3508,6 +3571,155 @@ img.smilie {
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
/* A link's preview as Telegram draws it: an accent bar, the site in the accent, the title in bold, the picture, and
|
||||
Instant View as a bar along the bottom. */
|
||||
.link-card {
|
||||
margin: var(--spacing-xxs) 0;
|
||||
padding: 2px 0 0 var(--spacing-m);
|
||||
border-left: 3px solid var(--primary);
|
||||
max-width: 420px;
|
||||
}
|
||||
.link-card-site {
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.link-card-title {
|
||||
display: block;
|
||||
font-weight: var(--weight-semibold);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.link-card-desc {
|
||||
color: var(--surface-variant-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.link-card-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
margin-top: var(--spacing-xs);
|
||||
border-radius: var(--corner-radius-small);
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-card-instant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-xs);
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--primary-hover);
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-card-instant:hover {
|
||||
background: var(--primary-selected);
|
||||
}
|
||||
|
||||
/* The glyph for what kind of conversation it is (a horn for a broadcast channel, a robot for a bot), ahead of its picture. */
|
||||
.buffer-kind-icon {
|
||||
flex: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* What a Telegram message answers: an accent bar, who said it, what they said, and the quote mark. */
|
||||
.tg-quote {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 2px 0 4px;
|
||||
padding: 4px 28px 4px var(--spacing-m);
|
||||
border-left: 3px solid var(--primary);
|
||||
border-radius: 0 var(--corner-radius-small) var(--corner-radius-small) 0;
|
||||
background: var(--primary-hover-light);
|
||||
max-width: 520px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tg-quote:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
.tg-quote-from {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.tg-quote-body {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
color: var(--surface-variant-text);
|
||||
}
|
||||
.tg-quote-mark {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
opacity: 0.7;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Where a forwarded Telegram message came from. */
|
||||
.tg-forward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
margin: 2px 0 2px;
|
||||
padding-left: var(--spacing-m);
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
.tg-forward-source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 0;
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Discord's own conversation in the list: the mark beside its name and what it is underneath. */
|
||||
.buffer-official-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: #5865f2;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* A Telegram bot's Menu button, ahead of the message box. */
|
||||
.composer-menu-button {
|
||||
flex: none;
|
||||
margin-right: var(--spacing-xs);
|
||||
padding: 4px 14px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: var(--primary-container);
|
||||
color: #fff;
|
||||
font-weight: var(--weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.composer-menu-button:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* --- media embeds ------------------------------------------------------ */
|
||||
|
||||
.media-row {
|
||||
@@ -5648,6 +5860,195 @@ video.lightbox-media {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* The player: the controls are drawn over the picture and rest out of sight while it plays. */
|
||||
.vp {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: #000;
|
||||
}
|
||||
.vp.full,
|
||||
.vp:fullscreen {
|
||||
/* Full screen is the whole screen, not the shape the video had in the log (which its inline aspect ratio and the
|
||||
embed's size limits would otherwise keep). */
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
aspect-ratio: auto !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
.vp.full .vp-video,
|
||||
.vp:fullscreen .vp-video {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
aspect-ratio: auto !important;
|
||||
object-fit: contain;
|
||||
}
|
||||
.vp-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vp-top {
|
||||
position: absolute;
|
||||
top: var(--spacing-xs);
|
||||
right: var(--spacing-xs);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.vp-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 18px 10px 6px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
opacity: 1;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
/* Out of the way while it plays, back when the pointer is on it or it stops. */
|
||||
.vp.playing .vp-bar {
|
||||
opacity: 0;
|
||||
}
|
||||
.vp:hover .vp-top,
|
||||
.vp:hover .vp-bar,
|
||||
.vp:focus-within .vp-top,
|
||||
.vp:focus-within .vp-bar,
|
||||
.vp-bar:has(.vp-speeds) {
|
||||
opacity: 1;
|
||||
}
|
||||
.vp-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.vp-fill {
|
||||
flex: 1;
|
||||
}
|
||||
.vp-button {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vp-button:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.vp-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.vp-time {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vp-rate {
|
||||
margin-left: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* The volume: the icon, and the slider that opens out beside it when the pointer is on either. */
|
||||
.vp-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.vp-level {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
transition: width 0.15s, opacity 0.15s, margin 0.15s;
|
||||
}
|
||||
.vp-volume:hover .vp-level,
|
||||
.vp-volume:focus-within .vp-level {
|
||||
width: 70px;
|
||||
margin: 0 6px 0 2px;
|
||||
opacity: 1;
|
||||
}
|
||||
.vp-seek,
|
||||
.vp-level {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to right, #fff var(--vp-progress, 0%), rgba(255, 255, 255, 0.35) var(--vp-progress, 0%));
|
||||
cursor: pointer;
|
||||
}
|
||||
.vp-seek {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.vp-seek::-webkit-slider-thumb,
|
||||
.vp-level::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
.vp-speed {
|
||||
position: relative;
|
||||
}
|
||||
.vp-speeds {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 96px;
|
||||
padding: 4px;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: rgba(20, 20, 24, 0.96);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
.vp-speed-option {
|
||||
padding: 5px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: none;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vp-speed-option:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.vp-speed-option.on {
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.media-embed-wrap.video:hover .video-expand {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -5863,6 +6264,11 @@ video.lightbox-media {
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
/* A held login is explained in full: the time it will connect is at the end. */
|
||||
.account-detail.held {
|
||||
-webkit-line-clamp: unset;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* --- presence on avatars ---------------------------------------------- */
|
||||
|
||||
@@ -6180,6 +6586,19 @@ video.lightbox-media {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Discord's own pill for a bot or application, in its blurple, whoever's name it sits beside. */
|
||||
.sender-badge.app,
|
||||
.sender-badge.verified-app,
|
||||
.sender-badge.official {
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: #5865f2;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* The one badge that is a warning rather than a title: this message came
|
||||
from a device nobody has vouched for. Coloured like an error and left
|
||||
uncoloured nowhere else, because a doubt about who sent something is the
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- Telegram's paper plane as a plain silhouette (Simple Icons, CC0). -->
|
||||
<path fill="#fff" d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 825 B |
@@ -25,7 +25,7 @@ import type { Account } from '../../../shared/wire'
|
||||
* that gains a backend appears here as soon as it gains a form, and one the
|
||||
* daemon stops offering disappears without this file being touched.
|
||||
*/
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam', 'twitch'] as const
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam', 'twitch', 'telegram'] as const
|
||||
type AddableService = (typeof FORMS)[number]
|
||||
|
||||
/** What `listProtocols` answers with. */
|
||||
@@ -138,6 +138,7 @@ export function AccountsPanel(): JSX.Element {
|
||||
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'steam' && <SteamForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'twitch' && <TwitchForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'telegram' && <TelegramForm onDone={() => setAdding(null)} />}
|
||||
{adding && (
|
||||
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
|
||||
Cancel
|
||||
@@ -331,6 +332,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
// What the daemon last said about why. Only meaningful while something is
|
||||
// wrong, which is exactly when the state line alone explains nothing.
|
||||
const detail = useConnectionDetail(account.id)
|
||||
const held = account.service === 'discord' && !!detail?.startsWith('Holding the Discord connection')
|
||||
|
||||
const call = (method: string, params: Record<string, unknown>): void => {
|
||||
void window.moho
|
||||
@@ -352,7 +354,20 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
<div className="small muted">
|
||||
{serviceLabel(account.service)} · {account.state}
|
||||
</div>
|
||||
{detail && <div className="small muted ellipsis account-detail" title={detail}>{detail}</div>}
|
||||
{detail && <div className={classes('small muted account-detail', held && 'held')} title={detail}>{detail}</div>}
|
||||
{/* Held for having logged in too often (see nobilis's identify_budget): said in full, with the way past it. */}
|
||||
{held && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
title="Many fresh logins can get an account flagged as spam by Discord. This one is counted."
|
||||
onClick={() => call('connectDiscordAnyway', { accountId: account.id })}
|
||||
>
|
||||
Connect anyway
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -980,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
|
||||
@@ -1415,9 +1440,10 @@ function SteamForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
<div className="add-form">
|
||||
<p className="small muted">
|
||||
Sign in with your Steam account name — the one you type at the Steam login, not the
|
||||
display name your friends see. moho signs in as the Steam mobile app's chat, so it runs
|
||||
beside the Steam launcher without signing it out. Your password goes to Steam and is not
|
||||
kept; moho keeps only the token Steam hands back.
|
||||
display name your friends see. moho signs in as a desktop client of its own, the way other
|
||||
third-party Steam clients do, and shows up in your Steam devices as "moho on" your
|
||||
computer's name. Your password goes to Steam and is not kept; moho keeps only the token
|
||||
Steam hands back.
|
||||
</p>
|
||||
<TorSwitch service="steam" on={useTor} onChange={setUseTor} />
|
||||
<div className="field-row">
|
||||
@@ -1475,6 +1501,213 @@ function SteamForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding a Telegram account: an api_id and api_hash, a phone number, then the code Telegram sends and, if the account
|
||||
* has one, its cloud password.
|
||||
*
|
||||
* Telegram gives one api_id and api_hash to a phone number's owner, and every client sends them. moho has none of its
|
||||
* own, so the ones typed here are kept with the account, as private as the session is. Nothing typed here is sent
|
||||
* anywhere but Telegram.
|
||||
*/
|
||||
function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [apiId, setApiId] = useState('')
|
||||
const [apiHash, setApiHash] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [useTor, setUseTor] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loginId, setLoginId] = useState('')
|
||||
const [detail, setDetail] = useState('')
|
||||
const [needs, setNeeds] = useState<{ step: 'code' | 'password'; hint?: string; error?: string } | null>(null)
|
||||
const [typed, setTyped] = useState('')
|
||||
|
||||
// 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.
|
||||
const current = useRef('')
|
||||
const setId = (id: string): void => {
|
||||
current.current = id
|
||||
setLoginId(id)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.moho.onEvent((e) => {
|
||||
const d = e.data as { loginId?: string; text?: string; step?: 'code' | 'password'; hint?: string; error?: string; success?: boolean }
|
||||
if (!e.event.startsWith('telegramLogin')) return
|
||||
if (current.current && d.loginId !== current.current) return
|
||||
if (e.event === 'telegramLoginStatus') {
|
||||
setDetail(d.text || '')
|
||||
} else if (e.event === 'telegramLoginNeeds') {
|
||||
setNeeds({ step: d.step ?? 'code', hint: d.hint ?? undefined, error: d.error ?? undefined })
|
||||
setTyped('')
|
||||
setDetail('')
|
||||
} else if (e.event === 'telegramLoginResult') {
|
||||
setNeeds(null)
|
||||
setId('')
|
||||
setBusy(false)
|
||||
setDetail('')
|
||||
if (d.success) {
|
||||
setApiHash('')
|
||||
void store.refreshAccounts()
|
||||
onDone()
|
||||
} else {
|
||||
store.toast('error', d.error || 'Telegram sign-in failed')
|
||||
}
|
||||
}
|
||||
})
|
||||
return off
|
||||
}, [onDone, store])
|
||||
|
||||
// Closing the form on a sign-in that is still waiting tells the daemon nobody is.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) void window.moho.rpc('cancelTelegramLogin', { loginId: current.current }).catch(() => {})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const begin = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
setNeeds(null)
|
||||
current.current = ''
|
||||
void window.moho
|
||||
.rpc<{ loginId: string }>('addTelegramAccount', { apiId: apiId.trim(), apiHash: apiHash.trim(), phone, 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
|
||||
void window.moho
|
||||
.rpc(needs.step === 'password' ? 'submitTelegramPassword' : 'submitTelegramCode', { loginId, value })
|
||||
.then(() => setDetail('checking'))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
if (current.current) void window.moho.rpc('cancelTelegramLogin', { loginId: current.current }).catch(() => {})
|
||||
setId('')
|
||||
setNeeds(null)
|
||||
setDetail('')
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
if (needs) {
|
||||
const password = needs.step === 'password'
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>
|
||||
{password
|
||||
? 'This account has a cloud password.'
|
||||
: 'Telegram sent a code to your other Telegram app, or by SMS.'}
|
||||
</p>
|
||||
<label className="field">
|
||||
<span className="small muted">{password ? `Password${needs.hint ? ` (hint: ${needs.hint})` : ''}` : 'Code'}</span>
|
||||
<input
|
||||
className="text-field"
|
||||
type={password ? 'password' : 'text'}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
inputMode={password ? undefined : 'numeric'}
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') send()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{needs.error && (
|
||||
<div className="warning-note">
|
||||
<Icon name="warning" size={16} />
|
||||
<span>{needs.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail && <p className="small muted">{detail}…</p>}
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" disabled={!typed.trim()} onClick={send}>
|
||||
{password ? 'Submit password' : 'Submit code'}
|
||||
</button>
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ready = apiId.trim() !== '' && apiHash.trim() !== '' && phone.trim() !== ''
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p className="small muted">
|
||||
Telegram gives one <strong>api_id</strong> and <strong>api_hash</strong> to the owner of a phone number, at{' '}
|
||||
<a href="https://my.telegram.org/apps" target="_blank" rel="noreferrer">
|
||||
my.telegram.org/apps
|
||||
</a>
|
||||
, and every Telegram app a person uses can share them. moho has none of its own: the ones you type here are kept
|
||||
with the account, as private as the session. Telegram then sends a code, and asks for the account's cloud
|
||||
password if it has one.
|
||||
</p>
|
||||
<TorSwitch service="telegram" on={useTor} onChange={setUseTor} />
|
||||
<div className="field-row">
|
||||
<label className="field">
|
||||
<span className="small muted">api_id</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
inputMode="numeric"
|
||||
value={apiId}
|
||||
disabled={busy}
|
||||
onChange={(e) => setApiId(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="small muted">api_hash</span>
|
||||
<input
|
||||
className="text-field"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={apiHash}
|
||||
disabled={busy}
|
||||
onChange={(e) => setApiHash(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="small muted">Phone number, with its country code</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoComplete="off"
|
||||
placeholder="+1 555 123 4567"
|
||||
value={phone}
|
||||
disabled={busy}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && ready && !busy) begin()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" disabled={busy || !ready} onClick={begin}>
|
||||
{busy ? 'Signing in…' : 'Send the code'}
|
||||
</button>
|
||||
{busy && (
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{busy && detail && <p className="small muted">{detail}…</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding a Twitch account: sign in, or only read.
|
||||
*
|
||||
@@ -1713,6 +1946,9 @@ function TorWarning({ service }: { service: Account['service'] }): JSX.Element |
|
||||
} else if (service === 'kick') {
|
||||
text =
|
||||
"Kick blocks Tor's exit points, so a connection through moho's own Tor will most likely be refused. A SOCKS5 proxy you host yourself may work."
|
||||
} else if (service === 'telegram') {
|
||||
text =
|
||||
"Telegram often challenges or limits a sign-in that comes from Tor or a VPN, and signing in from a new address can put the account's other sessions on alert. Sign in once from your usual connection, then route it if you need to."
|
||||
} else if (service === 'irc' || service === 'matrix') {
|
||||
text = `Each ${service === 'irc' ? 'network' : 'homeserver'} has its own policy on Tor, and many block its exit points. A SOCKS5 proxy you host yourself may be accepted where Tor is not.`
|
||||
} else {
|
||||
|
||||
@@ -303,7 +303,7 @@ export function BufferList(): JSX.Element {
|
||||
/** The call that sets a mute on the account itself, for a service that keeps one. */
|
||||
const accountMute = (accountId: string): string | null => {
|
||||
const service = accounts.find((a) => a.id === accountId)?.service
|
||||
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : service === 'steam' ? 'steamSetFriendMuted' : null
|
||||
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : service === 'steam' ? 'steamSetFriendMuted' : service === 'telegram' ? 'setTelegramChatMuted' : null
|
||||
}
|
||||
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
|
||||
isMutedBuffer(buffer, muted, mutedGroups)
|
||||
@@ -1045,6 +1045,8 @@ function BufferRow({
|
||||
// redraws when it changes and not on every presence update in the window.
|
||||
const activity = useChat((s) => dmActivity(buffer, s.presenceByBuffer))
|
||||
const comfy = useComfy()
|
||||
// The one conversation Discord itself writes to, which the daemon marks read-only for that reason.
|
||||
const isOfficial = account?.service === 'discord' && !!buffer.readOnly?.includes('official Discord')
|
||||
const doing = activity ? activityLabel(activity) : undefined
|
||||
|
||||
// Under an account header the row's own kind is what's worth showing (a
|
||||
@@ -1156,8 +1158,23 @@ function BufferRow({
|
||||
onDragEnd={onDragEnd}
|
||||
title={link ? `${buffer.name} — ${link.detail}` : draggable ? `${buffer.name} — drag onto a heading to file it` : doing ? `${buffer.name} — ${doing}` : buffer.name}
|
||||
>
|
||||
{/* What kind of conversation it is, ahead of its picture: a horn for a broadcast channel, a robot for a bot. */}
|
||||
{buffer.nameIcon && <Icon name={buffer.nameIcon} size={15} className="buffer-kind-icon" />}
|
||||
{leading}
|
||||
{comfy && activity ? (
|
||||
{isOfficial ? (
|
||||
// Discord's own conversation: marked so a notice that arrives here can be trusted as Discord's, and a message
|
||||
// that merely claims to be Discord's cannot borrow it - the mark belongs to this conversation alone.
|
||||
<span className="buffer-text">
|
||||
<span className="ellipsis buffer-name">
|
||||
{rowName}
|
||||
<span className="buffer-official-mark">
|
||||
<Icon name="check" size={11} />
|
||||
OFFICIAL
|
||||
</span>
|
||||
</span>
|
||||
<span className="small ellipsis">Official Discord Message</span>
|
||||
</span>
|
||||
) : comfy && activity ? (
|
||||
<span className="buffer-text">
|
||||
<span className="ellipsis buffer-name">{rowName}</span>
|
||||
<ActivityLine activity={activity} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormatBar } from './FormatBar'
|
||||
import { formatsFor, serialize, type FormatKind } from '../lib/composeFormat'
|
||||
import { applyFormat } from '../lib/composeFormatDom'
|
||||
import { applyFormat, restoreSelectionAfterHistory } from '../lib/composeFormatDom'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Icon, IconButton, MaskIcon } from './Icon'
|
||||
@@ -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'
|
||||
@@ -51,6 +52,9 @@ interface StagedAttachment {
|
||||
*/
|
||||
const TYPING_REPEAT_MS = 4000
|
||||
|
||||
/** The services whose conversations can take a GIF: the picker sends a link, and these show it as the picture. */
|
||||
const GIF_SERVICES = new Set(['discord', 'matrix', 'kick'])
|
||||
|
||||
/**
|
||||
* What the box holds, as the text that will be sent.
|
||||
*
|
||||
@@ -104,7 +108,7 @@ interface TypedCommand {
|
||||
description?: string
|
||||
/** "Built-in", or the bot that answers it. */
|
||||
source?: string
|
||||
kind: 'builtin' | 'application'
|
||||
kind: 'builtin' | 'application' | 'bot'
|
||||
/** Discord's own description of the command, handed back to run it. */
|
||||
command?: { options?: { name: string; type?: number; required?: boolean }[] }
|
||||
}
|
||||
@@ -136,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
|
||||
@@ -208,6 +213,8 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
*/
|
||||
const [commands, setCommands] = useState<TypedCommand[]>([])
|
||||
const [commandIndex, setCommandIndex] = useState(0)
|
||||
/** A Telegram bot's Menu, open over the box: the bot's commands, asked for without anything being typed. */
|
||||
const [botMenu, setBotMenu] = useState(false)
|
||||
/**
|
||||
* The application command whose name has been completed into the box.
|
||||
*
|
||||
@@ -260,7 +267,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
// here rather than left as the one service where the button is dead.
|
||||
// Steam takes a picture or a short video, and says which and how big.
|
||||
const supportsAttachments =
|
||||
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc' || service === 'steam'
|
||||
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc' || service === 'steam' || service === 'telegram'
|
||||
|
||||
// Starting a reply puts the cursor in the box: the next thing anybody does
|
||||
// is write the answer.
|
||||
@@ -394,7 +401,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const typedCommand = /^\/\S*$/.test(text) ? text.slice(1) : null
|
||||
|
||||
useEffect(() => {
|
||||
if (typedCommand === null || !buffer) {
|
||||
// The bot's Menu is open: its whole list, whatever is (or is not) in the box.
|
||||
const asking = botMenu ? '' : typedCommand
|
||||
if (asking === null || !buffer) {
|
||||
setCommands([])
|
||||
return
|
||||
}
|
||||
@@ -402,7 +411,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
void window.moho
|
||||
.rpc<{ commands: TypedCommand[] }>('listCommands', {
|
||||
bufferId: buffer.id,
|
||||
query: typedCommand
|
||||
query: asking
|
||||
})
|
||||
.then((answer) => {
|
||||
setCommands(answer.commands || [])
|
||||
@@ -414,10 +423,21 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
// complaint about typing a slash.
|
||||
setCommands([])
|
||||
})
|
||||
}, 200)
|
||||
}, botMenu ? 0 : 200)
|
||||
return () => clearTimeout(timer)
|
||||
}, [typedCommand, buffer?.id])
|
||||
}, [typedCommand, buffer?.id, botMenu])
|
||||
|
||||
// The Menu belongs to the conversation it was opened in, and goes when anything else is clicked.
|
||||
useEffect(() => setBotMenu(false), [buffer?.id])
|
||||
useEffect(() => {
|
||||
if (!botMenu) return
|
||||
const away = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target?.closest('.command-picker, .composer-menu-button')) setBotMenu(false)
|
||||
}
|
||||
document.addEventListener('mousedown', away)
|
||||
return () => document.removeEventListener('mousedown', away)
|
||||
}, [botMenu])
|
||||
|
||||
if (!buffer) return null
|
||||
|
||||
@@ -430,6 +450,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
* it on services with no such notion, so there is nothing to check here.
|
||||
*/
|
||||
const noteTyping = (): void => {
|
||||
// Not on Discord unless asked for: its own clients send it, but a third-party client that does is one more thing
|
||||
// Discord can tell it apart by, and Dissent does without it altogether. A setting in Settings > Discord.
|
||||
if (service === 'discord' && !discordTyping) return
|
||||
const now = Date.now()
|
||||
if (now - typingSentAt.current < TYPING_REPEAT_MS) return
|
||||
typingSentAt.current = now
|
||||
@@ -487,6 +510,9 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
disabled: !supportsAttachments,
|
||||
onClick: pickAndStage
|
||||
},
|
||||
...(service === 'telegram' && !thread
|
||||
? ([{ label: 'Start a poll', icon: 'ballot', onClick: () => setPolling(true) }] as MenuEntry[])
|
||||
: []),
|
||||
...(service === 'matrix' && !thread
|
||||
? ([
|
||||
{
|
||||
@@ -643,6 +669,15 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const takeCommand = (command: TypedCommand): void => {
|
||||
const wants = (command.usage ?? '').trim().length > 0
|
||||
setCommands([])
|
||||
// A Telegram bot's command is sent the moment it is chosen, as Telegram's own apps do: it is a message to the bot,
|
||||
// and the menu is the bot's own list of what it understands.
|
||||
if (command.kind === 'bot' && buffer) {
|
||||
setBotMenu(false)
|
||||
setText('')
|
||||
if (inputRef.current) inputRef.current.textContent = ''
|
||||
void store.sendMessage(buffer.id, `/${command.name}`)
|
||||
return
|
||||
}
|
||||
if (!wants && command.kind === 'application') {
|
||||
setPendingCommand(command)
|
||||
runCommand(command, `/${command.name}`)
|
||||
@@ -844,8 +879,11 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
// Somewhere that can be read and not written in: a channel this account has
|
||||
// no permission to send to, or Discord's own notices. A box that every send
|
||||
// is refused from is a worse answer than saying so where it would be.
|
||||
if (buffer.readOnly) {
|
||||
// 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" />
|
||||
@@ -856,6 +894,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
{official && (
|
||||
<span className="small">Discord will never ask you for your password or account token.</span>
|
||||
)}
|
||||
{firstWord && <span className="small">moho notices when you have, and opens this up by itself.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -897,7 +936,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
{commands.length > 0 && (
|
||||
<div className="command-picker" role="listbox" aria-label="Run a command">
|
||||
<div className="picker-head small muted">
|
||||
Commands matching /{typedCommand}
|
||||
{botMenu ? 'Commands' : <>Commands matching /{typedCommand}</>}
|
||||
</div>
|
||||
{commands.map((c, i) => (
|
||||
<button
|
||||
@@ -1008,7 +1047,14 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
{repliedTo?.avatarUrl && <img className="reply-avatar" src={media(repliedTo.avatarUrl)} alt="" />}
|
||||
<span className="ellipsis">
|
||||
Replying to <strong>{replyingTo.from}</strong>
|
||||
{replyingTo.body ? <span className="muted">{`: ${replyingTo.body}`}</span> : ''}
|
||||
{replyingTo.body ? (
|
||||
<span className="muted">
|
||||
{': '}
|
||||
<ReplyBody text={replyingTo.body} service={service} />
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
{/* Whether the one answered is told, as Discord's own bar has it.
|
||||
Only there: nothing else here can say it. */}
|
||||
@@ -1048,7 +1094,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
{/* On the card's corner, and there only while it is pointed at
|
||||
or focused - they would be a dozen buttons otherwise. */}
|
||||
<div className="staged-actions">
|
||||
{(service === 'discord' || service === 'steam') && (
|
||||
{(service === 'discord' || service === 'steam' || service === 'telegram') && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes('staged-action', att.spoiler && 'on')}
|
||||
@@ -1075,6 +1121,19 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
|
||||
<div className="composer-row">
|
||||
<div className="composer-box">
|
||||
{/* The bot's own menu, where Telegram puts it: ahead of the box. Opens the list of what the bot understands. */}
|
||||
{service === 'telegram' && buffer.nameIcon === 'smart_toy' && !thread && (
|
||||
<button
|
||||
type="button"
|
||||
className="composer-menu-button"
|
||||
title="Show this bot's commands"
|
||||
// Opens the list and nothing else: nothing is written into the box, and choosing a command sends it.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setBotMenu((open) => !open)}
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
)}
|
||||
{/* Everything that is not typed goes through here, inside the box on
|
||||
the left as in Discord. Where there is only the one thing - a
|
||||
file - it is that thing, without a menu in between. */}
|
||||
@@ -1137,6 +1196,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
aria-multiline="true"
|
||||
aria-label={thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
|
||||
onInput={(e) => {
|
||||
restoreSelectionAfterHistory(e.currentTarget, (e.nativeEvent as InputEvent).inputType)
|
||||
setText(composerText(e.currentTarget))
|
||||
noteTyping()
|
||||
noteMention()
|
||||
@@ -1168,6 +1228,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setCommands([])
|
||||
setBotMenu(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1232,7 +1293,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!text &&
|
||||
(service === 'discord' || service === 'sneedchat' || service === 'matrix')
|
||||
(service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'telegram')
|
||||
) {
|
||||
const last = store.lastEditableOwn(buffer.id)
|
||||
if (last) {
|
||||
@@ -1301,10 +1362,12 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
<Icon name="mood" size={18} />
|
||||
</button>
|
||||
|
||||
{/* GIFs, wherever there is a Discord account to ask for them: the
|
||||
picker borrows that account's way of looking, and what it finds can
|
||||
be sent in any conversation - except Twitch's, whose chat takes no pictures. */}
|
||||
{!thread && service !== 'steam' && service !== 'twitch' && accounts.some((a) => a.service === 'discord') && (
|
||||
{/* GIFs, wherever there is a Discord account to ask for them: the picker borrows that account's way of looking.
|
||||
What it finds is sent as a link, so it is offered only on the services that show a link to one as a picture
|
||||
(GIF_SERVICES): not IRC, Steam, Twitch or Sneedchat, which take none. Telegram has GIFs of its own - the account's
|
||||
saved ones and Telegram's GIF search - and gets the picker with those, asking Telegram rather than Discord. A service not on the list is
|
||||
one nobody has checked, and gets no button. */}
|
||||
{!thread && ((GIF_SERVICES.has(service ?? '') && accounts.some((a) => a.service === 'discord')) || service === 'telegram') && (
|
||||
<button
|
||||
ref={gifButtonRef}
|
||||
type="button"
|
||||
@@ -1346,7 +1409,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
messages at all: Discord's flag and base64 waveform, and Matrix's
|
||||
MSC3245 marker with integers. The daemon speaks both from one
|
||||
recording, so this is the same button either way. */}
|
||||
{!thread && (service === 'discord' || service === 'matrix') && <VoiceRecorder bufferId={buffer.id} />}
|
||||
{!thread && (service === 'discord' || service === 'matrix' || service === 'telegram') && <VoiceRecorder bufferId={buffer.id} />}
|
||||
|
||||
{/* Sending is the one thing the box does that its own Enter does not
|
||||
say, so it is only drawn when there is something to send. */}
|
||||
@@ -1384,6 +1447,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
bufferId={buffer.id}
|
||||
accountId={buffer.accountId}
|
||||
discord={service === 'discord'}
|
||||
telegram={service === 'telegram'}
|
||||
onClose={() => setGifPicker(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -72,17 +72,17 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
onTogglePin: () => togglePin(buffer.id),
|
||||
onToggleMute: () => {
|
||||
toggleMute(buffer.id)
|
||||
if (account?.service === 'matrix' || account?.service === 'steam') {
|
||||
if (account?.service === 'matrix' || account?.service === 'steam' || account?.service === 'telegram') {
|
||||
void window.moho
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : account.service === 'telegram' ? 'setTelegramChatMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
},
|
||||
onUnmuteAccount:
|
||||
account?.service === 'matrix' || account?.service === 'steam'
|
||||
account?.service === 'matrix' || account?.service === 'steam' || account?.service === 'telegram'
|
||||
? () =>
|
||||
void window.moho
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : account.service === 'telegram' ? 'setTelegramChatMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
: undefined,
|
||||
onToggleAutojoin:
|
||||
|
||||
@@ -615,6 +615,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
// to a name half written.
|
||||
const filterMenu = suggestions(query)
|
||||
const isMatrix = buffer.accountId.startsWith('matrix:')
|
||||
const isTelegram = buffer.accountId.startsWith('telegram:')
|
||||
const isSteam = buffer.accountId.startsWith('steam:')
|
||||
const stream = useChat((s) => s.kickStreams)[buffer.id]
|
||||
// Whether this window is the one playing this channel. Per window rather
|
||||
@@ -682,11 +683,11 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
if (filtered) setResults([])
|
||||
})
|
||||
}
|
||||
if (!isMatrix) return
|
||||
// Then the homeserver, which has what this client never downloaded
|
||||
if (!isMatrix && !isTelegram) return
|
||||
// Then the server, which has what this client never downloaded
|
||||
// - including everything said before this account joined.
|
||||
return window.moho
|
||||
.rpc<MatrixSearch>('searchMatrixMessages', {
|
||||
.rpc<MatrixSearch>(isTelegram ? 'searchTelegramMessages' : 'searchMatrixMessages', {
|
||||
bufferId: buffer.id,
|
||||
query: text,
|
||||
scope,
|
||||
@@ -711,7 +712,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
.finally(() => setSearching(false))
|
||||
}, 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, buffer.id, store, isMatrix, isDiscord, scope])
|
||||
}, [query, buffer.id, store, isMatrix, isTelegram, isDiscord, scope])
|
||||
|
||||
// A viewer count that never moves is worse than none, and Kick pushes a
|
||||
// stream starting and stopping but never the count. The followed channels
|
||||
@@ -980,7 +981,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
|
||||
{isMatrix && buffer.kind !== 'server' && <ThreadList buffer={buffer} />}
|
||||
{isDiscord && buffer.kind === 'channel' && <DiscordThreads buffer={buffer} />}
|
||||
{(isMatrix || isDiscord) && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
|
||||
{(isMatrix || isDiscord || buffer.accountId.startsWith('telegram:')) && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
|
||||
|
||||
{/* Beside the pins, because it is the same kind of fact about a room:
|
||||
something the room keeps, rather than something said in it. */}
|
||||
@@ -1085,7 +1086,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
{/* Where to look. Only on Matrix, because it is the only protocol
|
||||
here whose server will answer the question - and the room is
|
||||
often exactly what somebody searching has forgotten. */}
|
||||
{isMatrix && (
|
||||
{(isMatrix || isTelegram) && (
|
||||
<div className="search-scope small">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import type { EditAction, EditMenuRequest } from '../../../shared/ipc'
|
||||
import { usePref } from '../state/hooks'
|
||||
import { DEFAULT_SEARCH_ENGINE, engineName, searchUrl, shortened } from '../lib/searchengines'
|
||||
|
||||
/**
|
||||
* The right-click menu for anywhere text is typed: the message box, and every
|
||||
@@ -14,6 +16,8 @@ import type { EditAction, EditMenuRequest } from '../../../shared/ipc'
|
||||
export function EditMenu(): JSX.Element | null {
|
||||
const [menu, setMenu] = useState<EditMenuRequest | null>(null)
|
||||
const focus = useRef<{ element: HTMLElement | null; range: Range | null }>({ element: null, range: null })
|
||||
const [searchEngine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
|
||||
const [searchCustom] = usePref<string>('search.customUrl', '')
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -79,6 +83,18 @@ export function EditMenu(): JSX.Element | null {
|
||||
{ separator: true },
|
||||
{ label: 'Select all', icon: 'select_all', disabled: !menu.can.selectAll, onClick: act('selectAll') }
|
||||
)
|
||||
// Looking up what is selected, in the browser the person already uses.
|
||||
const address = searchUrl(menu.selection ?? '', searchEngine, searchCustom)
|
||||
if (address) {
|
||||
entries.push(
|
||||
{ separator: true },
|
||||
{
|
||||
label: `Search ${engineName(searchEngine, searchCustom)} for “${shortened(menu.selection)}”`,
|
||||
icon: 'search',
|
||||
onClick: () => void window.moho.openExternal(address)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={() => setMenu(null)} />
|
||||
}
|
||||
|
||||
@@ -127,7 +127,9 @@ function stickerKey(s: StickerEntry): string {
|
||||
export function readRecent(accountId?: string): string[] {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(recentKey(accountId)) || '[]')
|
||||
return Array.isArray(stored) ? stored : []
|
||||
// Without the bare numbers older builds kept for a custom emoji picked from another server: they were never a
|
||||
// message, and are not worth showing.
|
||||
return Array.isArray(stored) ? stored.filter((r) => !/^\d+$/.test(String(r))) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function GifPicker({
|
||||
bufferId,
|
||||
accountId,
|
||||
discord,
|
||||
telegram,
|
||||
onClose
|
||||
}: {
|
||||
anchor: HTMLElement | null
|
||||
@@ -50,6 +51,8 @@ export function GifPicker({
|
||||
accountId: string
|
||||
/** Whether the conversation is on Discord, which decides what is sent. */
|
||||
discord: boolean
|
||||
/** Whether the conversation is on Telegram, which has GIFs of its own: the account's saved ones, and its GIF search. */
|
||||
telegram?: boolean
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
@@ -116,7 +119,7 @@ export function GifPicker({
|
||||
const wait = setTimeout(
|
||||
() => {
|
||||
window.moho
|
||||
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>('listGifs', { query: query.trim() })
|
||||
.rpc<{ gifs: GifEntry[]; categories: GifCategory[] }>(telegram ? 'listTelegramGifs' : 'listGifs', telegram ? { bufferId, query: query.trim() } : { query: query.trim() })
|
||||
.then((answer) => {
|
||||
if (!current) return
|
||||
setGifs(answer.gifs)
|
||||
@@ -135,10 +138,15 @@ export function GifPicker({
|
||||
current = false
|
||||
clearTimeout(wait)
|
||||
}
|
||||
}, [query])
|
||||
}, [query, telegram, bufferId])
|
||||
|
||||
const pick = (gif: GifEntry): void => {
|
||||
onClose()
|
||||
// Telegram's are files on Telegram: sending one is sending that file, not a link to it.
|
||||
if (telegram) {
|
||||
void window.moho.rpc('sendTelegramGif', { bufferId, id: gif.id }).catch((e: Error) => store.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
// Discord's own client sends the link, which Discord turns into the GIF.
|
||||
// Everything else is sent the file, since a page address would be only
|
||||
// that.
|
||||
|
||||
@@ -76,6 +76,8 @@ export function JoinPanel(): JSX.Element {
|
||||
return <KickJoin account={account} />
|
||||
case 'twitch':
|
||||
return <TwitchJoin account={account} />
|
||||
case 'telegram':
|
||||
return <TelegramJoin account={account} />
|
||||
case 'sneedchat':
|
||||
return <SneedchatRooms account={account} />
|
||||
case 'steam':
|
||||
@@ -84,7 +86,7 @@ export function JoinPanel(): JSX.Element {
|
||||
return <div className="panel muted">Joining isn't supported for this service yet.</div>
|
||||
}
|
||||
})()
|
||||
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam', 'twitch'].includes(a.service))
|
||||
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam', 'twitch', 'telegram'].includes(a.service))
|
||||
|
||||
return (
|
||||
<div className="join-frame">
|
||||
@@ -380,6 +382,24 @@ function TwitchJoin({ account }: { account: Account }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
function TelegramJoin({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
return (
|
||||
<div className="panel join-panel">
|
||||
<SubmitField
|
||||
label="Join a chat, or open a conversation"
|
||||
placeholder="@name, or a t.me link"
|
||||
onSubmit={(name) => void store.joinAndOpen(account.id, name)}
|
||||
/>
|
||||
<p className="small muted">
|
||||
A public group or channel is joined by its @name or t.me link, a private one by the invite link
|
||||
somebody gave you, and a person's @name opens a conversation with them. Telegram may ask an
|
||||
administrator to accept a request to join. Closing a chat in the list leaves it.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One person in a friend request. */
|
||||
interface SteamPerson {
|
||||
steamid: string
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { Icon } from './Icon'
|
||||
import type { Embed } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* The preview of a link as Telegram draws it: the site's name, the page's title and description, its picture, and
|
||||
* - where Telegram has made an article of the page - an Instant View button that opens the article in a window of its
|
||||
* own. The same card for any link the service previewed; it is only the button that depends on the article.
|
||||
*/
|
||||
export function LinkCard({ embed, bufferId, messageId }: { embed: Embed; bufferId: string; messageId: string }): JSX.Element {
|
||||
const store = useStore()
|
||||
const media = useMediaUrl()
|
||||
const instant = embed.kind === 'instant'
|
||||
const openPage = (): void => {
|
||||
if (embed.url) void window.moho.openExternal(embed.url)
|
||||
}
|
||||
const openArticle = (): void => {
|
||||
window.moho
|
||||
.rpc<{ title: string; html: string }>('telegramInstantView', { bufferId, messageId })
|
||||
.then((article) => window.moho.openArticle(article.title, article.html))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
return (
|
||||
<div className="link-card selectable">
|
||||
{embed.provider && <div className="link-card-site">{embed.provider}</div>}
|
||||
{embed.title && (
|
||||
<a
|
||||
className="link-card-title"
|
||||
href={embed.url}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
openPage()
|
||||
}}
|
||||
>
|
||||
{embed.title}
|
||||
</a>
|
||||
)}
|
||||
{embed.description && <div className="link-card-desc">{embed.description}</div>}
|
||||
{embed.imageUrl && (
|
||||
<img className="link-card-image" src={media(embed.imageUrl)} alt="" loading="lazy" onClick={instant ? openArticle : openPage} />
|
||||
)}
|
||||
{instant && (
|
||||
<button type="button" className="link-card-instant" onClick={openArticle}>
|
||||
<Icon name="bolt" size={15} />
|
||||
INSTANT VIEW
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { Lightbox, type LightboxSource } from './Lightbox'
|
||||
import { registerMedia } from '../lib/gallery'
|
||||
import { VideoPlayer } from './VideoPlayer'
|
||||
import { VoiceMessage } from './VoiceMessage'
|
||||
import { LottieSticker, lottieStickerId } from './LottieSticker'
|
||||
import { fullImageFor, knownFullImage } from '../lib/fullimage'
|
||||
@@ -45,6 +46,8 @@ interface Props {
|
||||
* fills the cell and crops to it rather than keeping its own shape.
|
||||
*/
|
||||
tile?: boolean
|
||||
/** Present when the message this is in can be edited by this person: the video's pencil. */
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
/** What Discord puts at the front of a file name to hide it behind a spoiler. */
|
||||
@@ -94,7 +97,8 @@ export function MediaEmbed({
|
||||
onOpenInDiscord,
|
||||
onRefresh,
|
||||
onStale,
|
||||
tile
|
||||
tile,
|
||||
onEdit
|
||||
}: Props): JSX.Element | null {
|
||||
// Through the account's route when it is strictly routed (#257).
|
||||
const media = useMediaUrl()
|
||||
@@ -178,7 +182,8 @@ export function MediaEmbed({
|
||||
viewerRef.current = viewerSource
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current
|
||||
if (!el || (kind !== 'image' && kind !== 'video')) return
|
||||
// Pictures only: a video goes full screen by itself and is not stepped to from the viewer.
|
||||
if (!el || kind !== 'image') return
|
||||
return registerMedia(el, () => viewerRef.current())
|
||||
}, [kind])
|
||||
|
||||
@@ -421,6 +426,36 @@ export function MediaEmbed({
|
||||
// video/quicktime, which canPlayType refuses outright while the browser
|
||||
// then plays the file perfectly well once it looks inside. Letting it try
|
||||
// and handling the failure is the honest test.
|
||||
//
|
||||
// A Telegram video is not in the cache until it is fetched, and fetching is
|
||||
// the whole file: a feed of videos would pull hundreds of megabytes just to
|
||||
// be scrolled. So it is drawn as its preview, and fetched when played.
|
||||
const deferred =
|
||||
!attachment?.gif && !!attachment?.thumbnailPath && !previewGone && !playing && /[\\/]telegram-media[\\/]/.test(fullSrc)
|
||||
if (deferred) {
|
||||
return (
|
||||
<span className={classes('media-embed-wrap video', tile && 'tile', hidden && 'spoilered')} style={{ ...ratio, position: 'relative' }} {...reveal}>
|
||||
<img
|
||||
className="media-embed"
|
||||
style={ratio}
|
||||
src={media(previewSrc)}
|
||||
alt={attachment?.filename || ''}
|
||||
loading="lazy"
|
||||
onError={() => setPreviewGone(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="video-expand"
|
||||
title="Play"
|
||||
style={{ position: 'absolute', inset: 0, margin: 'auto', width: 48, height: 48 }}
|
||||
onClick={() => setPlaying(true)}
|
||||
>
|
||||
<Icon name="play_circle" size={32} />
|
||||
</button>
|
||||
{cover}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
@@ -429,46 +464,50 @@ export function MediaEmbed({
|
||||
style={ratio}
|
||||
{...reveal}
|
||||
>
|
||||
<video
|
||||
className="media-embed"
|
||||
style={ratio}
|
||||
src={media(fullSrc)}
|
||||
// A GIF is silent, runs on its own and goes round, and has no
|
||||
// player to operate: it plays when it is shown if autoplay is on,
|
||||
// and otherwise while the pointer is over it. Any other video is a
|
||||
// video, with its controls.
|
||||
{...(attachment?.gif
|
||||
? {
|
||||
muted: true,
|
||||
autoPlay: autoplay,
|
||||
loop: true,
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && void e.currentTarget.play().catch(() => {}),
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLVideoElement>) => !autoplay && e.currentTarget.pause()
|
||||
}
|
||||
: { controls: true, loop })}
|
||||
// Enough to draw the first frame and know how long it is, without
|
||||
// pulling megabytes for every video in a scrolled-past backlog.
|
||||
preload="metadata"
|
||||
playsInline
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="video-expand"
|
||||
title="Expand"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<Icon name="fullscreen" size={16} />
|
||||
</button>
|
||||
{attachment?.gif ? (
|
||||
<video
|
||||
className="media-embed"
|
||||
style={ratio}
|
||||
src={media(fullSrc)}
|
||||
// A GIF is silent, runs on its own and goes round, and has no player to operate: it plays when it is
|
||||
// shown if autoplay is on, and otherwise while the pointer is over it.
|
||||
muted
|
||||
autoPlay={autoplay}
|
||||
loop
|
||||
onMouseEnter={(e) => !autoplay && void e.currentTarget.play().catch(() => {})}
|
||||
onMouseLeave={(e) => !autoplay && e.currentTarget.pause()}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
// Any other video is a video, with the one set of controls every service gets.
|
||||
<VideoPlayer
|
||||
src={media(fullSrc)}
|
||||
className="media-embed"
|
||||
style={ratio}
|
||||
loop={loop}
|
||||
autoPlay={playing}
|
||||
filename={shownName}
|
||||
saveFrom={openTarget}
|
||||
onEdit={onEdit}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)}
|
||||
{/* A video has its own full-screen button in its controls. One without controls (a silent looping clip)
|
||||
has none, so it gets this: the same full screen, never the viewer. */}
|
||||
{attachment?.gif && (
|
||||
<button
|
||||
type="button"
|
||||
className="video-expand"
|
||||
title="Full screen"
|
||||
onClick={(e) => void e.currentTarget.parentElement?.querySelector('video')?.requestFullscreen?.().catch(() => {})}
|
||||
>
|
||||
<Icon name="fullscreen" size={16} />
|
||||
</button>
|
||||
)}
|
||||
{cover}
|
||||
</span>
|
||||
{expanded && (
|
||||
<Lightbox
|
||||
source={viewerSource()}
|
||||
anchor={wrapRef.current}
|
||||
onClose={() => setExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,16 +14,52 @@ import type { BufferEntry } from '../state/store'
|
||||
*
|
||||
* Sits above the composer rather than replacing it. The box still works for
|
||||
* every other server, and hiding it here would be one more thing to explain.
|
||||
*
|
||||
* The dialog is the server's own form as Discord's client draws it: the rules
|
||||
* as a numbered list with the agreement under them, and any question the
|
||||
* server added - a line of text, a paragraph, or a choice of answers.
|
||||
*/
|
||||
interface FormField {
|
||||
field_type?: string
|
||||
label?: string
|
||||
description?: string | null
|
||||
required?: boolean
|
||||
values?: string[] | null
|
||||
placeholder?: string | null
|
||||
}
|
||||
|
||||
interface VerificationForm {
|
||||
description?: string | null
|
||||
form_fields?: { field_type?: string; label?: string; description?: string | null }[]
|
||||
form_fields?: FormField[]
|
||||
guild?: {
|
||||
id?: string
|
||||
name?: string
|
||||
icon?: string | null
|
||||
approximate_member_count?: number
|
||||
approximate_presence_count?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** The daemon's answer - or, in the screenshot harness only, a fixture standing in for it (see RewardsPanel). */
|
||||
function ask<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const w = window as unknown as { __mohoShots?: unknown; __rpcFixture?: Record<string, (p: Record<string, unknown>) => unknown> }
|
||||
const stub = w.__mohoShots ? w.__rpcFixture?.[method] : undefined
|
||||
return stub ? Promise.resolve(stub(params) as T) : window.moho.rpc<T>(method, params)
|
||||
}
|
||||
|
||||
/** Whether the person has done what this field needs. */
|
||||
function answered(field: FormField, answer: unknown): boolean {
|
||||
if (!field.required && field.field_type !== 'TERMS') return true
|
||||
if (field.field_type === 'TERMS') return answer === true
|
||||
if (field.field_type === 'MULTIPLE_CHOICE') return typeof answer === 'number'
|
||||
return typeof answer === 'string' && answer.trim().length > 0
|
||||
}
|
||||
|
||||
export function MembershipGate({ buffer }: { buffer: BufferEntry }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const groups = useChat((s) => s.groups)
|
||||
const [rules, setRules] = useState<VerificationForm | null>(null)
|
||||
const [answers, setAnswers] = useState<unknown[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const group = groups.find((g) => g.id === buffer.groupId)
|
||||
@@ -32,22 +68,36 @@ export function MembershipGate({ buffer }: { buffer: BufferEntry }): JSX.Element
|
||||
|
||||
const open = (): void => {
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc<VerificationForm>('getDiscordMemberVerification', {
|
||||
void ask<VerificationForm>('getDiscordMemberVerification', {
|
||||
accountId: group.accountId,
|
||||
guildId
|
||||
})
|
||||
// An empty form still opens the dialog: the server has a gate even if
|
||||
// it wrote nothing to explain it, and agreeing is still what lifts it.
|
||||
.then((form) => setRules(form ?? {}))
|
||||
.then((form) => {
|
||||
setAnswers([])
|
||||
setRules(form ?? {})
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
const fields = rules?.form_fields ?? []
|
||||
const ready = fields.every((f, i) => answered(f, answers[i]))
|
||||
const answer = (i: number, value: unknown): void =>
|
||||
setAnswers((current) => {
|
||||
const next = [...current]
|
||||
next[i] = value
|
||||
return next
|
||||
})
|
||||
|
||||
const accept = (): void => {
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc('acceptDiscordMemberVerification', { accountId: group.accountId, guildId })
|
||||
void ask('acceptDiscordMemberVerification', {
|
||||
accountId: group.accountId,
|
||||
guildId,
|
||||
responses: fields.map((_, i) => answers[i] ?? null)
|
||||
})
|
||||
.then(() => {
|
||||
setRules(null)
|
||||
store.toast('info', `You can talk in ${group.name} now.`)
|
||||
@@ -56,40 +106,114 @@ export function MembershipGate({ buffer }: { buffer: BufferEntry }): JSX.Element
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
// The rules as the server wrote them. Discord keeps them in the TERMS
|
||||
// field's label, with the description above it as a preamble.
|
||||
const terms = rules?.form_fields?.find((f) => f.field_type === 'TERMS') ?? rules?.form_fields?.[0]
|
||||
const guild = rules?.guild
|
||||
const icon = guild?.id && guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128` : group.iconUrl
|
||||
const name = guild?.name || group.name
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="membership-gate">
|
||||
<Icon name="lock" size={16} />
|
||||
<span className="ellipsis">
|
||||
You must complete a few more steps before you can talk in {group.name}.
|
||||
</span>
|
||||
<span className="ellipsis">You must complete a few more steps before you can talk in {group.name}.</span>
|
||||
<button type="button" className="button primary" disabled={busy} onClick={open}>
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rules && (
|
||||
<Modal title={group.name} icon="gavel" className="membership-rules" onClose={() => setRules(null)}>
|
||||
{rules.description && <p className="small">{rules.description}</p>}
|
||||
{terms?.label && <p className="small">{terms.label}</p>}
|
||||
{terms?.description && <p className="small muted">{terms.description}</p>}
|
||||
{!rules.description && !terms?.label && (
|
||||
<p className="small muted">
|
||||
This server asks new members to agree to its rules before talking.
|
||||
</p>
|
||||
)}
|
||||
<div className="membership-rules-actions">
|
||||
<button type="button" className="button" onClick={() => setRules(null)} autoFocus>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button primary" disabled={busy} onClick={accept}>
|
||||
Agree and continue
|
||||
</button>
|
||||
<Modal title={name} icon="gavel" className="membership-rules" onClose={() => setRules(null)}>
|
||||
<div className="membership-body">
|
||||
<div className="membership-card">
|
||||
{icon ? <img className="membership-card-icon" src={icon} alt="" /> : <Icon name="groups" size={40} />}
|
||||
<div className="membership-card-name">{name}</div>
|
||||
{(guild?.approximate_presence_count || guild?.approximate_member_count) && (
|
||||
<div className="small muted">
|
||||
{guild.approximate_presence_count ? `${guild.approximate_presence_count.toLocaleString()} online` : ''}
|
||||
{guild.approximate_presence_count && guild.approximate_member_count ? ' · ' : ''}
|
||||
{guild.approximate_member_count ? `${guild.approximate_member_count.toLocaleString()} members` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="membership-form">
|
||||
<h2 className="membership-title">Before you can talk here…</h2>
|
||||
<p className="small muted">You'll need to complete the required steps below.</p>
|
||||
{rules.description && <p className="small">{rules.description}</p>}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="small muted">This server asks new members to agree to its rules before talking.</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} className="membership-field">
|
||||
{field.field_type === 'TERMS' ? (
|
||||
<>
|
||||
<div className="membership-field-label">{field.label || 'Agree to rules'}</div>
|
||||
{field.description && <p className="small muted">{field.description}</p>}
|
||||
{!!field.values?.length && (
|
||||
<ol className="membership-rules-list">
|
||||
{field.values.map((rule, k) => (
|
||||
<li key={k}>{rule}</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<label className="membership-agree">
|
||||
<input type="checkbox" checked={answers[i] === true} onChange={(e) => answer(i, e.target.checked)} />
|
||||
I have read and agree to the rules
|
||||
</label>
|
||||
</>
|
||||
) : field.field_type === 'MULTIPLE_CHOICE' ? (
|
||||
<>
|
||||
<div className="membership-field-label">
|
||||
{field.label}
|
||||
{field.required && <span className="muted"> *</span>}
|
||||
</div>
|
||||
{field.description && <p className="small muted">{field.description}</p>}
|
||||
{(field.values ?? []).map((choice, k) => (
|
||||
<label key={k} className="membership-agree">
|
||||
<input type="radio" name={`membership-${i}`} checked={answers[i] === k} onChange={() => answer(i, k)} />
|
||||
{choice}
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span className="membership-field-label">
|
||||
{field.label}
|
||||
{field.required && <span className="muted"> *</span>}
|
||||
</span>
|
||||
{field.description && <span className="small muted">{field.description}</span>}
|
||||
{field.field_type === 'PARAGRAPH' ? (
|
||||
<textarea
|
||||
className="text-field"
|
||||
rows={4}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
value={typeof answers[i] === 'string' ? (answers[i] as string) : ''}
|
||||
onChange={(e) => answer(i, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder={field.placeholder ?? ''}
|
||||
value={typeof answers[i] === 'string' ? (answers[i] as string) : ''}
|
||||
onChange={(e) => answer(i, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="membership-rules-actions">
|
||||
<button type="button" className="button" onClick={() => setRules(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button primary" disabled={busy || !ready} onClick={accept}>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -256,14 +256,16 @@ export function MessageList(): JSX.Element {
|
||||
* whose replies are older than the loaded window, which is the honest
|
||||
* failure - it says "at least this many", never "none" for a live one.
|
||||
*/
|
||||
const told = useChat((s) => (bufferId ? s.threadCounts[bufferId] : undefined))
|
||||
const threadReplies = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
// What the service says wins where it says: a Telegram channel's comments are in another chat altogether.
|
||||
const counts: Record<string, number> = { ...(told ?? {}) }
|
||||
for (const m of all) {
|
||||
const root = m.replyTo?.thread ? m.replyTo.id : m.replyTo?.threadRoot
|
||||
if (root) counts[root] = (counts[root] ?? 0) + 1
|
||||
if (root && !(told && root in told)) counts[root] = (counts[root] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
}, [all])
|
||||
}, [all, told])
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const allowed: Record<string, boolean> = {
|
||||
|
||||
@@ -8,12 +8,16 @@ import { EventSource } from './EventSource'
|
||||
import { ForwardPicker } from './ForwardPicker'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { MediaEmbed } from './MediaEmbed'
|
||||
import { LinkCard } from './LinkCard'
|
||||
import { isPlainEmoji } from '../lib/recents'
|
||||
import { KickEventCard, NotificationCard, isKickEvent } from './KickEventCard'
|
||||
import { COMMON_EMOJI, EmojiPicker, readRecent, type StickerEntry } from './EmojiPicker'
|
||||
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'
|
||||
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
|
||||
import { useMediaUrl, useStrictRoute } from '../lib/route'
|
||||
@@ -99,6 +103,11 @@ const ROLE_WORDS: Record<string, string> = ((): Record<string, string> => {
|
||||
'shared-chat': 'SHARED',
|
||||
// A cheer: bits sent with the message.
|
||||
cheer: 'BITS',
|
||||
// Discord's mark on a bot or application, and on one Discord has verified.
|
||||
app: 'APP',
|
||||
// What Discord's own account sends, so a message that claims to be from Discord can be told from one that is.
|
||||
official: '✓ OFFICIAL',
|
||||
'verified-app': '✓ APP',
|
||||
lead_moderator: 'MOD'
|
||||
}
|
||||
return short
|
||||
@@ -124,7 +133,9 @@ const DEFAULT_REACTIONS = ['👍', '❤️', '😂', '🔥']
|
||||
* belongs to a server and may not be sendable from wherever the toolbar is.
|
||||
*/
|
||||
function quickReactions(accountId?: string): string[] {
|
||||
const recent = readRecent(accountId).filter((e) => !e.startsWith('<') && !e.startsWith(':'))
|
||||
// Only ordinary emoji: anything with letters or digits in it is a stand-in for one (a custom emoji's token, or the
|
||||
// bare number older builds stored for one), and is not a picture.
|
||||
const recent = readRecent(accountId).filter(isPlainEmoji)
|
||||
return [...new Set([...recent, ...DEFAULT_REACTIONS])].slice(0, 4)
|
||||
}
|
||||
|
||||
@@ -372,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)
|
||||
@@ -409,8 +423,12 @@ function MessageRowBody({
|
||||
// Discord, Sneedchat and Matrix support editing and deleting your own
|
||||
// messages; IRC has no such concept.
|
||||
const canEditDelete =
|
||||
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix')
|
||||
const canReact = service === 'discord' || service === 'matrix' || service === 'steam'
|
||||
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'telegram')
|
||||
const beginEdit = (): void => {
|
||||
setDraft(message.body)
|
||||
setEditing(true)
|
||||
}
|
||||
const canReact = service === 'discord' || service === 'matrix' || service === 'steam' || service === 'telegram'
|
||||
// Steam's stickers, asked for when the reaction picker opens: it reacts with them too.
|
||||
const [steamStickers, setSteamStickers] = useState<StickerEntry[]>([])
|
||||
useEffect(() => {
|
||||
@@ -536,6 +554,7 @@ function MessageRowBody({
|
||||
isSneedchat,
|
||||
smilies: smilieIndex,
|
||||
channels,
|
||||
markdownEscapes: service === 'discord',
|
||||
// Only IRC's own traffic is drawn with IRC's codes. Elsewhere they
|
||||
// are stripped, which is what the formatter does when not told.
|
||||
ircFormatting: service === 'irc' && ircColours ? 'render' : 'strip'
|
||||
@@ -553,6 +572,12 @@ function MessageRowBody({
|
||||
// have let go of the selection.
|
||||
const selectedRef = useRef('')
|
||||
const selected = selectedRef.current
|
||||
const [searchEngine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
|
||||
const [searchCustom] = usePref<string>('search.customUrl', '')
|
||||
const searchAddress = selected ? searchUrl(selected, searchEngine, searchCustom) : null
|
||||
const search = searchAddress
|
||||
? { url: searchAddress, name: engineName(searchEngine, searchCustom), shown: shortened(selected) }
|
||||
: null
|
||||
const canOpenProfile = !!message.from && !isSystem && !message.isOwn
|
||||
const openProfile = (): void => store.showProfile(bufferId, message.from, message.senderId)
|
||||
// Back to what this answers, bringing it into view first if it is a long
|
||||
@@ -604,6 +629,12 @@ function MessageRowBody({
|
||||
...(selected
|
||||
? ([{ label: 'Copy', icon: 'content_copy', onClick: () => void window.moho.copyText(selected) }] as MenuEntry[])
|
||||
: []),
|
||||
// Looking up what is selected: in the browser the person already uses, with
|
||||
// the engine they chose in Settings. Only for a selection - there is no
|
||||
// sensible thing to search for in a whole message.
|
||||
...(search
|
||||
? ([{ label: `Search ${search.name} for “${search.shown}”`, icon: 'search', onClick: () => void window.moho.openExternal(search.url) }] as MenuEntry[])
|
||||
: []),
|
||||
...(message.body && !isSystem
|
||||
? ([{ label: 'Copy text', icon: 'content_copy', onClick: () => void window.moho.copyText(message.body) }] as MenuEntry[])
|
||||
: []),
|
||||
@@ -641,7 +672,7 @@ function MessageRowBody({
|
||||
// rather than gated on a power level read here: the server decides, and
|
||||
// it refuses in words worth showing - hiding the action from somebody who
|
||||
// could have used it is the worse mistake.
|
||||
...((service === 'matrix' || service === 'discord') && !isSystem
|
||||
...((service === 'matrix' || service === 'discord' || service === 'telegram') && !isSystem
|
||||
? ([
|
||||
{
|
||||
label: pinned ? 'Unpin from this conversation' : 'Pin to this conversation',
|
||||
@@ -991,13 +1022,7 @@ function MessageRowBody({
|
||||
>
|
||||
{message.isAction ? `* ${message.from}` : message.from}
|
||||
</span>
|
||||
{/* The time on the name's own line, as Discord does it, in the
|
||||
full form the tooltip also carries. */}
|
||||
{mode === 'comfy' && (
|
||||
<span className="message-stamp" title={formatFullTime(message.ts)}>
|
||||
{timeLabel}
|
||||
</span>
|
||||
)}
|
||||
{/* Beside the name, ahead of the time: a mark on who is talking, as Discord puts its APP pill. */}
|
||||
{message.badges?.map((badge) =>
|
||||
badge.image && showBadgePictures ? (
|
||||
// The service's own pictures, unless they are turned off. The tooltip
|
||||
@@ -1022,6 +1047,13 @@ function MessageRowBody({
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{/* The time on the name's own line, as Discord does it, in the
|
||||
full form the tooltip also carries. */}
|
||||
{mode === 'comfy' && (
|
||||
<span className="message-stamp" title={formatFullTime(message.ts)}>
|
||||
{timeLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1071,8 +1103,79 @@ 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
|
||||
// somewhere to go when it was a post in a channel this account has open.
|
||||
(() => {
|
||||
const source = message.replyTo
|
||||
const open = source.chat && source.id
|
||||
return (
|
||||
<div className="tg-forward small">
|
||||
<span className="muted">Forwarded from </span>
|
||||
{open ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tg-forward-source link-button"
|
||||
title="Go to the original"
|
||||
onClick={() =>
|
||||
void store.selectBuffer(source.chat!, true).then(() =>
|
||||
store.jumpToMessage(source.chat!, source.id).then((found) => {
|
||||
if (found) store.setJumpTarget(source.id)
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon name="campaign" size={13} />
|
||||
{source.from}
|
||||
</button>
|
||||
) : (
|
||||
<span className="tg-forward-source">{source.from}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
) : service === 'telegram' && !message.replyTo.forwarded ? (
|
||||
// As Telegram draws an answer: an accent bar, who said it (and where, when it was another channel), what
|
||||
// they said, and the quote mark. Going to it is a click, to the other channel if that is where it is.
|
||||
<div
|
||||
className="tg-quote selectable"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={message.replyTo.chat ? 'Go to the original' : 'Go to the message'}
|
||||
onClick={() => {
|
||||
const target = message.replyTo!
|
||||
if (target.chat) {
|
||||
void store.selectBuffer(target.chat, true).then(() =>
|
||||
store.jumpToMessage(target.chat!, target.id).then((found) => {
|
||||
if (found) store.setJumpTarget(target.id)
|
||||
})
|
||||
)
|
||||
} else jumpToReply()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as HTMLElement).click()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="tg-quote-from">
|
||||
{message.replyTo.chat && <Icon name="campaign" size={13} />}
|
||||
{message.replyTo.from || 'Message'}
|
||||
</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>
|
||||
) : (
|
||||
<div
|
||||
className={classes('reply-preview small muted', replyId && 'reply-jump')}
|
||||
@@ -1109,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 && (
|
||||
@@ -1214,7 +1319,9 @@ function MessageRowBody({
|
||||
))}
|
||||
|
||||
{(message.embeds || []).map((embed, i) =>
|
||||
embed.kind === 'gif' || isKickEvent(embed) ? null : (
|
||||
embed.kind === 'gif' || isKickEvent(embed) ? null : service === 'telegram' ? (
|
||||
<LinkCard key={`e${i}`} embed={embed} bufferId={bufferId} messageId={message.id} />
|
||||
) : (
|
||||
<div
|
||||
key={`e${i}`}
|
||||
className={classes('rich-embed selectable', embed.kind === 'notice' && 'notice')}
|
||||
@@ -1251,7 +1358,7 @@ function MessageRowBody({
|
||||
{embed.description && (
|
||||
<div className="rich-embed-desc small">
|
||||
<RichText
|
||||
html={formatMessage(embed.description, { channels })}
|
||||
html={formatMessage(embed.description, { channels, markdownEscapes: service === 'discord' })}
|
||||
ownMarkup
|
||||
onOpenChannel={(id) => void store.selectBuffer(id, true)}
|
||||
onOpenLink={(url) => store.followDeepLink(url)}
|
||||
@@ -1263,7 +1370,17 @@ function MessageRowBody({
|
||||
{embed.fields.map((f, k) => (
|
||||
<div key={k} className="rich-embed-field">
|
||||
<div className="rich-embed-field-name">{f.name}</div>
|
||||
<div>{f.value}</div>
|
||||
{/* Markdown on Discord, like the description above; plain text elsewhere. */}
|
||||
{service === 'discord' ? (
|
||||
<RichText
|
||||
html={formatMessage(f.value, { channels, markdownEscapes: true })}
|
||||
ownMarkup
|
||||
onOpenChannel={(id) => void store.selectBuffer(id, true)}
|
||||
onOpenLink={(url) => store.followDeepLink(url)}
|
||||
/>
|
||||
) : (
|
||||
<div>{f.value}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1329,6 +1446,7 @@ function MessageRowBody({
|
||||
onOpenInDiscord={(b, m) => void store.openInDiscord(b, m)}
|
||||
onRefresh={(b, m) => store.refreshAttachments(b, m)}
|
||||
onStale={(b, m) => store.resignWhenIdle(b, m)}
|
||||
onEdit={canEditDelete ? beginEdit : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1350,6 +1468,7 @@ function MessageRowBody({
|
||||
onOpenInDiscord={(b, m) => void store.openInDiscord(b, m)}
|
||||
onRefresh={(b, m) => store.refreshAttachments(b, m)}
|
||||
onStale={(b, m) => store.resignWhenIdle(b, m)}
|
||||
onEdit={canEditDelete ? beginEdit : undefined}
|
||||
/>
|
||||
))}
|
||||
{/* Links someone typed, unfurled from the body. Still needed
|
||||
@@ -1742,12 +1861,15 @@ function moderationEntries(
|
||||
// display names collide and can be changed at will.
|
||||
const userId = message.senderId
|
||||
if (!userId) return []
|
||||
const telegram = store.accountFor(bufferId)?.service === 'telegram'
|
||||
const call = (method: string): void => {
|
||||
const account = store.accountFor(bufferId)
|
||||
if (!account) return
|
||||
void window.moho
|
||||
.rpc(method, { accountId: account.id, bufferId, userId })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
// Telegram's three are one call that names which.
|
||||
const asked = telegram
|
||||
? window.moho.rpc('moderateTelegramMember', { bufferId, userId, action: method.replace('MatrixMember', '') })
|
||||
: window.moho.rpc(method, { accountId: account.id, bufferId, userId })
|
||||
void asked.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
const out: MenuEntry[] = []
|
||||
if (permissions.canMute)
|
||||
@@ -1758,7 +1880,7 @@ function moderationEntries(
|
||||
out.push({ label: 'Ban sender', icon: 'gavel', danger: true, onClick: () => call('banMatrixMember') })
|
||||
// Reachable only from here, not the member list: a banned user is no
|
||||
// longer a member, so their scrollback is the one place left to find them.
|
||||
out.push({ label: 'Unban sender', icon: 'lock_open', onClick: () => call('unbanMatrixMember') })
|
||||
if (!telegram) out.push({ label: 'Unban sender', icon: 'lock_open', onClick: () => call('unbanMatrixMember') })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -381,6 +381,14 @@ export function NickList(): JSX.Element {
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
if (account.service === 'telegram') {
|
||||
if (action !== 'kick' && action !== 'ban' && action !== 'mute') return
|
||||
void window.moho
|
||||
.rpc('moderateTelegramMember', { bufferId: buffer.id, userId: member.userId, action })
|
||||
.then(() => store.toast('info', `${action === 'ban' ? 'Banned' : action === 'mute' ? 'Muted for 10 minutes' : 'Removed'} ${member.nick}`))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
if (account.service === 'steam') {
|
||||
const group = steamRoomIds(buffer)?.group
|
||||
if (!group || !member.userId || (action !== 'kick' && action !== 'ban' && action !== 'mute')) return
|
||||
|
||||
@@ -89,7 +89,7 @@ function PinnedMessagesBanner({ bufferId }: { bufferId: string }): JSX.Element |
|
||||
const rows = useChat((s) => s.pinnedRows)[bufferId]
|
||||
const [at, setAt] = useState(0)
|
||||
const [, redraw] = useState(0)
|
||||
const hasPins = account.startsWith('discord:') || account.startsWith('matrix:')
|
||||
const hasPins = account.startsWith('discord:') || account.startsWith('matrix:') || account.startsWith('telegram:')
|
||||
|
||||
useEffect(() => {
|
||||
setAt(0)
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useStore } from '../state/hooks'
|
||||
|
||||
/** The spec's own ceiling, so twenty-one answers is refused here not there. */
|
||||
const MAX_ANSWERS = 20
|
||||
/** Telegram's: ten answers, and a count that can be held back until the poll closes. */
|
||||
const TELEGRAM_MAX_ANSWERS = 10
|
||||
|
||||
/**
|
||||
* Starting a poll.
|
||||
@@ -18,6 +20,7 @@ const MAX_ANSWERS = 20
|
||||
*/
|
||||
export function PollComposer({ bufferId, onClose }: { bufferId: string; onClose: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const max = store.accountFor(bufferId)?.service === 'telegram' ? TELEGRAM_MAX_ANSWERS : MAX_ANSWERS
|
||||
const [question, setQuestion] = useState('')
|
||||
const [answers, setAnswers] = useState(['', ''])
|
||||
const [disclosed, setDisclosed] = useState(true)
|
||||
@@ -77,7 +80,7 @@ export function PollComposer({ bufferId, onClose }: { bufferId: string; onClose:
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle small"
|
||||
disabled={answers.length >= MAX_ANSWERS}
|
||||
disabled={answers.length >= max}
|
||||
onClick={() => setAnswers([...answers, ''])}
|
||||
>
|
||||
Add an answer
|
||||
|
||||
@@ -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={() => {}} />
|
||||
}
|
||||
@@ -304,7 +304,7 @@ function RailTile(props: TileProps): JSX.Element {
|
||||
? [
|
||||
{ separator: true } as const,
|
||||
{
|
||||
label: group.kind === 'space' ? 'Leave space' : 'Leave server',
|
||||
label: group.kind === 'space' ? 'Leave space' : group.service === 'telegram' ? 'Leave group' : 'Leave server',
|
||||
icon: 'logout',
|
||||
danger: true,
|
||||
onClick: props.onLeave
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { classes } from '../lib/util'
|
||||
import { useStore } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* A video with the same controls on every service, drawn over the picture: play, a seek bar, the volume (which opens
|
||||
* out to a slider when the pointer is on it), the time (a click shows what is left instead of what has passed), the
|
||||
* speed, and full screen - which is the player's own, not a viewer's. Down the right at the top: save it, and edit the
|
||||
* message it is in when that message is the person's own.
|
||||
*
|
||||
* Over a real `<video>` element, so the rest of the window still sees a video: the "fetch it again" recovery for a
|
||||
* cached file that has gone listens for that element failing.
|
||||
*/
|
||||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2]
|
||||
|
||||
/** The volume one video was left at is the volume of the next. */
|
||||
let lastVolume = 1
|
||||
let lastMuted = false
|
||||
|
||||
function clock(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
|
||||
const whole = Math.floor(seconds)
|
||||
const h = Math.floor(whole / 3600)
|
||||
const m = Math.floor((whole % 3600) / 60)
|
||||
const s = whole % 60
|
||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
className,
|
||||
style,
|
||||
loop,
|
||||
autoPlay,
|
||||
filename,
|
||||
saveFrom,
|
||||
onEdit,
|
||||
onError
|
||||
}: {
|
||||
src: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
loop: boolean
|
||||
autoPlay: boolean
|
||||
filename?: string
|
||||
/** What to save: the original where there is one, else what is playing. */
|
||||
saveFrom?: string
|
||||
/** Present when the message the video is in can be edited by this person. */
|
||||
onEdit?: () => void
|
||||
onError: () => void
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const wrap = useRef<HTMLDivElement>(null)
|
||||
const video = useRef<HTMLVideoElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [time, setTime] = useState(0)
|
||||
const [length, setLength] = useState(0)
|
||||
const [remaining, setRemaining] = useState(false)
|
||||
const [volume, setVolume] = useState(lastVolume)
|
||||
const [muted, setMuted] = useState(lastMuted)
|
||||
const [rate, setRate] = useState(1)
|
||||
const [speeds, setSpeeds] = useState(false)
|
||||
const [full, setFull] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
v.volume = lastVolume
|
||||
v.muted = lastMuted
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onFull = (): void => setFull(document.fullscreenElement === wrap.current)
|
||||
document.addEventListener('fullscreenchange', onFull)
|
||||
return () => document.removeEventListener('fullscreenchange', onFull)
|
||||
}, [])
|
||||
|
||||
// The speed list closes on a click anywhere else.
|
||||
useEffect(() => {
|
||||
if (!speeds) return
|
||||
const away = (e: MouseEvent): void => {
|
||||
if (!(e.target as HTMLElement | null)?.closest('.vp-speed')) setSpeeds(false)
|
||||
}
|
||||
document.addEventListener('mousedown', away)
|
||||
return () => document.removeEventListener('mousedown', away)
|
||||
}, [speeds])
|
||||
|
||||
const toggle = (): void => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
if (v.paused) void v.play().catch(() => {})
|
||||
else v.pause()
|
||||
}
|
||||
|
||||
const seek = (to: number): void => {
|
||||
const v = video.current
|
||||
if (v) v.currentTime = to
|
||||
}
|
||||
|
||||
const setLevel = (level: number): void => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
v.volume = level
|
||||
v.muted = level === 0
|
||||
lastVolume = level || lastVolume
|
||||
lastMuted = level === 0
|
||||
}
|
||||
|
||||
const mute = (): void => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
v.muted = !v.muted
|
||||
lastMuted = v.muted
|
||||
if (!v.muted && v.volume === 0) v.volume = 1
|
||||
}
|
||||
|
||||
const fullScreen = (): void => {
|
||||
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {})
|
||||
else void wrap.current?.requestFullscreen?.().catch(() => {})
|
||||
}
|
||||
|
||||
const save = (): void => {
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
void window.moho
|
||||
.downloadMedia(saveFrom || src, filename)
|
||||
.then((res) => store.toast(res.error ? 'error' : 'info', res.error ? `Couldn't save: ${res.error}` : `Saved to ${res.path}`))
|
||||
.catch((e: Error) => store.toast('error', `Couldn't save: ${e.message}`))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
const shownVolume = muted ? 0 : volume
|
||||
const progress = length > 0 ? (time / length) * 100 : 0
|
||||
|
||||
return (
|
||||
<div ref={wrap} className={classes('vp', playing && 'playing', full && 'full')} style={style}>
|
||||
<video
|
||||
ref={video}
|
||||
className={classes('vp-video', className)}
|
||||
src={src}
|
||||
loop={loop}
|
||||
autoPlay={autoPlay}
|
||||
// Enough to draw the first frame and know how long it is, without pulling megabytes for every video in a
|
||||
// scrolled-past backlog.
|
||||
preload="metadata"
|
||||
playsInline
|
||||
onClick={toggle}
|
||||
onDoubleClick={fullScreen}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onTimeUpdate={(e) => setTime(e.currentTarget.currentTime)}
|
||||
onDurationChange={(e) => setLength(e.currentTarget.duration)}
|
||||
onLoadedMetadata={(e) => setLength(e.currentTarget.duration)}
|
||||
onVolumeChange={(e) => {
|
||||
setVolume(e.currentTarget.volume)
|
||||
setMuted(e.currentTarget.muted)
|
||||
}}
|
||||
onRateChange={(e) => setRate(e.currentTarget.playbackRate)}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<div className="vp-top">
|
||||
{onEdit && (
|
||||
<button type="button" className="vp-button" title="Edit message" onClick={onEdit}>
|
||||
<Icon name="edit" size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="vp-button" title="Save to your downloads folder" disabled={saving} onClick={save}>
|
||||
<Icon name={saving ? 'hourglass_empty' : 'download'} size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="vp-bar">
|
||||
<input
|
||||
className="vp-seek"
|
||||
type="range"
|
||||
min={0}
|
||||
max={length || 0}
|
||||
step="any"
|
||||
value={Math.min(time, length || 0)}
|
||||
style={{ ['--vp-progress' as string]: `${progress}%` }}
|
||||
aria-label="Seek"
|
||||
onChange={(e) => seek(Number(e.target.value))}
|
||||
/>
|
||||
<div className="vp-controls">
|
||||
<button type="button" className="vp-button" title={playing ? 'Pause' : 'Play'} onClick={toggle}>
|
||||
<Icon name={playing ? 'pause' : 'play_arrow'} size={22} fill />
|
||||
</button>
|
||||
<div className="vp-volume">
|
||||
<button type="button" className="vp-button" title={muted ? 'Unmute' : 'Mute'} onClick={mute}>
|
||||
<Icon name={shownVolume === 0 ? 'volume_off' : shownVolume < 0.5 ? 'volume_down' : 'volume_up'} size={20} fill />
|
||||
</button>
|
||||
<input
|
||||
className="vp-level"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.01"
|
||||
value={shownVolume}
|
||||
style={{ ['--vp-progress' as string]: `${shownVolume * 100}%` }}
|
||||
aria-label="Volume"
|
||||
onChange={(e) => setLevel(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vp-time"
|
||||
title={remaining ? 'Showing the time left; click for the time passed' : 'Showing the time passed; click for the time left'}
|
||||
onClick={() => setRemaining((r) => !r)}
|
||||
>
|
||||
{remaining ? `-${clock(Math.max(0, length - time))}` : clock(time)}
|
||||
</button>
|
||||
<span className="vp-fill" />
|
||||
<div className="vp-speed">
|
||||
<button type="button" className="vp-button" title="Playback speed" aria-haspopup="menu" aria-expanded={speeds} onClick={() => setSpeeds((o) => !o)}>
|
||||
<Icon name="speed" size={20} />
|
||||
{rate !== 1 && <span className="vp-rate">{rate}×</span>}
|
||||
</button>
|
||||
{speeds && (
|
||||
<div className="vp-speeds" role="menu">
|
||||
{SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={rate === s}
|
||||
className={classes('vp-speed-option', rate === s && 'on')}
|
||||
onClick={() => {
|
||||
if (video.current) video.current.playbackRate = s
|
||||
setSpeeds(false)
|
||||
}}
|
||||
>
|
||||
{s === 1 ? 'Normal' : `${s}×`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="vp-button" title={full ? 'Exit full screen' : 'Full screen'} onClick={fullScreen}>
|
||||
<Icon name={full ? 'fullscreen_exit' : 'fullscreen'} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DirectorySetting,
|
||||
SavedMark,
|
||||
SettingsSection,
|
||||
DropdownSetting,
|
||||
SelectionSetting,
|
||||
StringSetting,
|
||||
ToggleSetting,
|
||||
@@ -17,6 +18,7 @@ import { acceleratorLabel, acceleratorOf } from '../../lib/accelerator'
|
||||
import { AccountsPanel } from '../AccountsPanel'
|
||||
import { DownloadsPanel } from '../DownloadsPanel'
|
||||
import { humanBytes } from '../../lib/util'
|
||||
import { DEFAULT_SEARCH_ENGINE, SEARCH_ENGINES, validTemplate } from '../../lib/searchengines'
|
||||
import { TunnelAllSwitch, useNetSettings } from './Tunnel'
|
||||
import type { Account, DccPrefs } from '../../../../shared/wire'
|
||||
|
||||
@@ -127,6 +129,7 @@ const CATEGORIES: { id: string; label: string; group: string; icon?: string; ser
|
||||
{ id: 'matrix', label: 'Matrix', group: 'Networks', service: 'matrix' },
|
||||
{ id: 'kick', label: 'Kick', group: 'Networks', service: 'kick' },
|
||||
{ id: 'twitch', label: 'Twitch', group: 'Networks', service: 'twitch' },
|
||||
{ id: 'telegram', label: 'Telegram', group: 'Networks', service: 'telegram' },
|
||||
{ id: 'about', label: 'About', group: 'About' }
|
||||
]
|
||||
|
||||
@@ -247,6 +250,8 @@ function Page({ id }: { id: string }): JSX.Element | null {
|
||||
return <KickSettings />
|
||||
case 'twitch':
|
||||
return <TwitchSettings />
|
||||
case 'telegram':
|
||||
return <TelegramSettings />
|
||||
case 'about':
|
||||
return <AboutSettings />
|
||||
default:
|
||||
@@ -843,6 +848,41 @@ function AppearanceSettings(): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which search engine "Search for…" uses on selected text. The search opens in
|
||||
* the system's default browser, so moho sends the engine nothing itself - and
|
||||
* neither Tor nor a proxy set up here applies to it.
|
||||
*/
|
||||
function SearchSettings(): JSX.Element {
|
||||
const [engine] = usePref<string>('search.engine', DEFAULT_SEARCH_ENGINE)
|
||||
const [custom] = usePref<string>('search.customUrl', '')
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Search"
|
||||
description="Select text in a message, right-click it and search for it on the web. The search opens in your default browser, outside moho, so it does not go through Tor or a proxy set here."
|
||||
>
|
||||
<DropdownSetting
|
||||
settingKey="search.engine"
|
||||
label="Search engine"
|
||||
defaultValue={DEFAULT_SEARCH_ENGINE}
|
||||
options={SEARCH_ENGINES.map((e) => ({ label: e.label, value: e.id }))}
|
||||
/>
|
||||
{engine === 'custom' && (
|
||||
<StringSetting
|
||||
settingKey="search.customUrl"
|
||||
label="Address"
|
||||
description={
|
||||
custom && !validTemplate(custom)
|
||||
? 'Not usable yet: it has to start with http:// or https:// and contain %s where the search goes.'
|
||||
: 'A web address with %s where the search goes, for example https://example.org/search?q=%s'
|
||||
}
|
||||
placeholder="https://example.org/search?q=%s"
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function GeneralSettings(): JSX.Element {
|
||||
const [defaultDir, setDefaultDir] = useState('')
|
||||
useEffect(() => {
|
||||
@@ -888,6 +928,8 @@ function GeneralSettings(): JSX.Element {
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SearchSettings />
|
||||
|
||||
<SettingsSection title="Media">
|
||||
<ToggleSetting
|
||||
settingKey="media.autoplay"
|
||||
@@ -1262,6 +1304,32 @@ function KickSettings(): JSX.Element {
|
||||
* Twitch. What is here is what the plan (Gitea issue #55) has built so far:
|
||||
* where a channel's recent past comes from, and what is said about signing in.
|
||||
*/
|
||||
function TelegramSettings(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Telegram"
|
||||
description="Each account signs in with its own api_id and api_hash, which Telegram gives you at my.telegram.org/apps, and one session that moho keeps and reuses. It never asks for a new code just because the connection dropped. Add an account from the Accounts pane."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Where moho shows up"
|
||||
description="Telegram lists every session of an account under Settings → Devices in its own apps, named after this computer, for example “moho (linux x86_64)”. Ending it there signs moho out; moho then says so and stops, rather than signing in again."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Muting and notifications"
|
||||
description="Muting a chat from its menu is Telegram's own mute for the account, so it holds on your phone as well, and a chat you muted on your phone is muted here."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="What moho does not do"
|
||||
description="Secret chats, voice and video calls, stories, payments and bots' inline mode are not part of moho. Signing in by QR code, folders, forum topics, drafts and scheduled messages are not done yet."
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TwitchSettings(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -1464,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."
|
||||
@@ -1476,6 +1556,7 @@ function DiscordSettings(): JSX.Element {
|
||||
</div>
|
||||
{result && <p className="small muted">{result}</p>}
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,40 @@ export function SelectionSetting({
|
||||
)
|
||||
}
|
||||
|
||||
/** A choice among too many to lay out as buttons: a drop-down of them. */
|
||||
export function DropdownSetting({
|
||||
settingKey,
|
||||
label,
|
||||
description,
|
||||
defaultValue,
|
||||
options
|
||||
}: {
|
||||
settingKey: string
|
||||
label: string
|
||||
description?: string
|
||||
defaultValue: string
|
||||
options: { label: string; value: string }[]
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = usePref<string>(settingKey, defaultValue)
|
||||
return (
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>{label}</div>
|
||||
{description && <div className="small muted">{description}</div>}
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<select className="text-field" value={value} aria-label={label} onChange={(e) => setValue(e.target.value)}>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder chosen through the system picker, with the platform default shown
|
||||
* (and used) until the user overrides it.
|
||||
|
||||
@@ -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``')
|
||||
@@ -94,3 +109,13 @@ describe('markup details', () => {
|
||||
expect(wrap('sneedchat', 'color', 'g', '#00fc00')).toBe('[color=#00fc00]g[/color]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Telegram formatting', () => {
|
||||
it('is offered the formats the daemon turns into entities, written as Discord reads them', () => {
|
||||
expect(formatsFor('telegram')).toEqual(['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote'])
|
||||
expect(wrap('telegram', 'bold', 'x')).toBe('**x**')
|
||||
expect(wrap('telegram', 'underline', 'x')).toBe('__x__')
|
||||
expect(wrap('telegram', 'spoiler', 'x')).toBe('||x||')
|
||||
expect(wrap('telegram', 'quote', 'a\nb')).toBe('\n> a\n> b\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ const CAPABILITIES: Record<string, FormatKind[]> = {
|
||||
discord: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote'],
|
||||
// markup.rs in the daemon: bold, italic, strike and code.
|
||||
matrix: ['bold', 'italic', 'strike', 'code'],
|
||||
// Written as Markdown, the way Discord reads it, and turned into Telegram's entities by the daemon (telegram/markup.rs).
|
||||
telegram: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote'],
|
||||
sneedchat: ['bold', 'italic', 'underline', 'strike', 'code', 'spoiler', 'quote', 'color'],
|
||||
// Quote is left out of IRC: a quote is a line of its own, and a newline
|
||||
// reaching IRC is a malformed message.
|
||||
@@ -129,7 +131,7 @@ export function wrap(service: string | undefined, kind: FormatKind, inner: strin
|
||||
const trail = inner.match(/\s*$/)?.[0] ?? ''
|
||||
const core = inner.slice(lead.length, inner.length - trail.length)
|
||||
const marked = (open: string, close = open): string => `${lead}${open}${core}${close}${trail}`
|
||||
const markdown = service === 'discord' || service === 'matrix'
|
||||
const markdown = service === 'discord' || service === 'matrix' || service === 'telegram'
|
||||
|
||||
if (service === 'sneedchat') {
|
||||
switch (kind) {
|
||||
@@ -172,11 +174,11 @@ export function wrap(service: string | undefined, kind: FormatKind, inner: strin
|
||||
const [open, close] = codeFence(core)
|
||||
return marked(open, close)
|
||||
}
|
||||
case 'underline': return service === 'discord' ? marked('__') : inner
|
||||
case 'spoiler': return service === 'discord' ? marked('||') : inner
|
||||
case 'underline': return service === 'discord' || service === 'telegram' ? marked('__') : inner
|
||||
case 'spoiler': return service === 'discord' || service === 'telegram' ? marked('||') : inner
|
||||
case 'quote': {
|
||||
if (service !== 'discord') return inner
|
||||
// A quote is a line of its own on Discord, however it was typed.
|
||||
if (service !== 'discord' && service !== 'telegram') return inner
|
||||
// A quote is a line of its own on Discord and Telegram, however it was typed.
|
||||
const quoted = core.split('\n').map((line) => `> ${line}`).join('\n')
|
||||
return `${lead}\n${quoted}\n${trail}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { textNodes, transformFormat } from './composeFormatDom'
|
||||
import type { FormatKind } from './composeFormat'
|
||||
|
||||
/** What the box says, with the formats marked as they would be on Discord. */
|
||||
function marked(root: Node): string {
|
||||
const mark: Record<string, string> = { bold: '**', italic: '_', underline: '__', strike: '~~', code: '`', spoiler: '||', quote: '> ' }
|
||||
return Array.from(root.childNodes)
|
||||
.map((n) => {
|
||||
if (n.nodeType === 3) return n.nodeValue
|
||||
if (n instanceof HTMLElement && n.dataset.fmt === 'color') return `{${n.dataset.color}:${marked(n)}}`
|
||||
if (n instanceof HTMLElement && n.dataset.fmt) return `${mark[n.dataset.fmt]}${marked(n)}${mark[n.dataset.fmt]}`
|
||||
return marked(n)
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** A box holding `html`, with `needle` selected in its text. */
|
||||
function box(html: string, needle: string): { root: HTMLElement; range: Range } {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = html
|
||||
const nodes = textNodes(root)
|
||||
const all = nodes.map((n) => n.nodeValue).join('')
|
||||
const at = all.indexOf(needle)
|
||||
expect(at).toBeGreaterThanOrEqual(0)
|
||||
const locate = (offset: number, preferEnd: boolean): [Text, number] => {
|
||||
let seen = 0
|
||||
for (const n of nodes) {
|
||||
const len = n.nodeValue!.length
|
||||
if (preferEnd ? offset <= seen + len : offset < seen + len) return [n, offset - seen]
|
||||
seen += len
|
||||
}
|
||||
throw new Error('outside')
|
||||
}
|
||||
const range = document.createRange()
|
||||
range.setStart(...locate(at, false))
|
||||
range.setEnd(...locate(at + needle.length, true))
|
||||
return { root, range }
|
||||
}
|
||||
|
||||
function run(html: string, needle: string, kind: FormatKind, colour?: string): { text: string; selected: string; done: boolean } {
|
||||
const { root, range } = box(html, needle)
|
||||
const done = transformFormat(root, range, kind, colour)
|
||||
return { text: marked(root), selected: range.toString(), done }
|
||||
}
|
||||
|
||||
const B = (s: string): string => `<span data-fmt="bold">${s}</span>`
|
||||
|
||||
describe('putting a format on', () => {
|
||||
it('wraps the selection and leaves it selected', () => {
|
||||
expect(run('one two three', 'two', 'bold')).toEqual({ text: 'one **two** three', selected: 'two', done: true })
|
||||
})
|
||||
|
||||
it('makes a selection that is partly bold all bold, once', () => {
|
||||
const r = run(`one ${B('two')} three four`, 'one two thr', 'bold')
|
||||
expect(r.text).toBe('**one two thr**ee four')
|
||||
expect(r.selected).toBe('one two thr')
|
||||
})
|
||||
|
||||
it('joins a run that ends where the new one begins', () => {
|
||||
expect(run(`${B('ab')}cd`, 'cd', 'bold').text).toBe('**abcd**')
|
||||
expect(run(`ab${B('cd')}`, 'ab', 'bold').text).toBe('**abcd**')
|
||||
})
|
||||
|
||||
it('stacks on another format without breaking it', () => {
|
||||
const r = run('one <span data-fmt="italic">two three</span> four', 'three four', 'bold')
|
||||
expect(r.text).toBe('one _two _**_three_ four**')
|
||||
})
|
||||
|
||||
it('does nothing with nothing selected', () => {
|
||||
const { root, range } = box('one two', 'two')
|
||||
range.collapse(true)
|
||||
expect(transformFormat(root, range, 'bold')).toBe(false)
|
||||
expect(marked(root)).toBe('one two')
|
||||
})
|
||||
})
|
||||
|
||||
describe('taking a format off', () => {
|
||||
it('takes it off a whole run', () => {
|
||||
expect(run(`one ${B('two')} three`, 'two', 'bold')).toEqual({ text: 'one two three', selected: 'two', done: true })
|
||||
})
|
||||
|
||||
it('takes it off part of a run and leaves the rest of the run bold', () => {
|
||||
const r = run(`one ${B('two three')} four`, 'wo th', 'bold')
|
||||
expect(r.text).toBe('one **t**wo th**ree** four')
|
||||
expect(r.selected).toBe('wo th')
|
||||
})
|
||||
|
||||
it('takes it off the end of a run', () => {
|
||||
expect(run(`one ${B('two three')}`, 'three', 'bold').text).toBe('one **two **three')
|
||||
})
|
||||
|
||||
it('takes it off from two runs side by side when all of the selection has it', () => {
|
||||
expect(run(`${B('one')}${B('two')}`, 'netw', 'bold').text).toBe('**o**ne' + 'tw' + '**o**')
|
||||
})
|
||||
|
||||
it('does not take it off a selection that reaches a space which is not bold: that is put on instead', () => {
|
||||
expect(run(`${B('one')} ${B('two')}`, 'e t', 'bold').text).toBe('**one two**')
|
||||
})
|
||||
|
||||
it('only the format asked for', () => {
|
||||
const r = run('<span data-fmt="bold"><span data-fmt="italic">two</span></span>', 'two', 'bold')
|
||||
expect(r.text).toBe('_two_')
|
||||
})
|
||||
})
|
||||
|
||||
describe('colour', () => {
|
||||
it('puts a colour on, and a different one replaces it', () => {
|
||||
const on = box('one two', 'two')
|
||||
expect(transformFormat(on.root, on.range, 'color', '#ff0000')).toBe(true)
|
||||
expect(marked(on.root)).toBe('one {#ff0000:two}')
|
||||
expect(transformFormat(on.root, on.range, 'color', '#00ff00')).toBe(true)
|
||||
expect(marked(on.root)).toBe('one {#00ff00:two}')
|
||||
expect(transformFormat(on.root, on.range, 'color')).toBe(true)
|
||||
expect(marked(on.root)).toBe('one two')
|
||||
})
|
||||
|
||||
it('takes no colour that is not a plain hex value', () => {
|
||||
expect(run('one two', 'two', 'color', 'red; background:url(x)').done).toBe(false)
|
||||
})
|
||||
|
||||
it('has nothing to take off when there is no colour', () => {
|
||||
expect(run('one two', 'two', 'color').done).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,68 +1,327 @@
|
||||
import type { FormatKind } from './composeFormat'
|
||||
|
||||
/** The nearest enclosing element in the box that stands for `kind`, if any. */
|
||||
function holderOf(node: Node | null, root: HTMLElement, kind: FormatKind): HTMLElement | null {
|
||||
for (let n: Node | null = node; n && n !== root; n = n.parentNode) {
|
||||
if (n instanceof HTMLElement && n.dataset.fmt === kind) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Colours reach a style attribute, so only a plain hex value gets there. */
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
const isRun = (node: Node | null): node is HTMLElement => node instanceof HTMLElement && node.dataset.fmt !== undefined
|
||||
const isKind = (node: Node | null, kind: FormatKind): node is HTMLElement => isRun(node) && node.dataset.fmt === kind
|
||||
const isMarker = (node: Node | null): boolean => node instanceof HTMLElement && node.dataset.mark !== undefined
|
||||
const sameRun = (a: HTMLElement, b: HTMLElement): boolean => a.dataset.fmt === b.dataset.fmt && a.dataset.color === b.dataset.color
|
||||
|
||||
function nearestRun(from: Node, root: Node, kind: FormatKind): HTMLElement | null {
|
||||
for (let n: Node | null = from.parentNode; n && n !== root; n = n.parentNode) {
|
||||
if (isKind(n, kind)) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function marker(which: 's' | 'e'): HTMLElement {
|
||||
const m = document.createElement('i')
|
||||
m.dataset.mark = which
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a format to what is selected in the box, or takes it off again if
|
||||
* the selection is exactly that format already. Returns whether it did
|
||||
* anything.
|
||||
*
|
||||
* Goes through `insertHTML` rather than rearranging nodes, so the box's own
|
||||
* undo still steps back over it.
|
||||
* Takes a marker out of every run of `kind` around it, splitting each run in
|
||||
* two at the marker. A start marker ends up in front of the second half and
|
||||
* an end marker behind the first, so what lies between the two markers is
|
||||
* never inside a run that reaches beyond it.
|
||||
*/
|
||||
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return false
|
||||
const range = selection.getRangeAt(0)
|
||||
if (range.collapsed || !root.contains(range.commonAncestorContainer)) return false
|
||||
root.focus()
|
||||
|
||||
// The selection can be a whole formatted run (it is, right after one is
|
||||
// applied, so another can be stacked on), which names the run's parent as
|
||||
// its ancestor and the run itself only by position.
|
||||
const whole =
|
||||
range.startContainer === range.endContainer && range.endOffset - range.startOffset === 1
|
||||
? range.startContainer.childNodes[range.startOffset]
|
||||
: null
|
||||
const holder = holderOf(whole ?? range.commonAncestorContainer, root, kind)
|
||||
if (holder && (kind === 'color' ? !colour : holder.textContent === range.toString())) {
|
||||
const around = document.createRange()
|
||||
around.selectNode(holder)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(around)
|
||||
document.execCommand('insertHTML', false, holder.innerHTML)
|
||||
return true
|
||||
function lift(root: Node, point: HTMLElement, kind: FormatKind, atStart: boolean): void {
|
||||
for (let run = nearestRun(point, root, kind); run; run = nearestRun(point, root, kind)) {
|
||||
const rest = document.createRange()
|
||||
if (atStart) rest.setStartBefore(point)
|
||||
else rest.setStartAfter(point)
|
||||
rest.setEnd(run, run.childNodes.length)
|
||||
const tail = run.cloneNode(false) as HTMLElement
|
||||
tail.append(rest.extractContents())
|
||||
run.after(tail)
|
||||
if (atStart) tail.before(point)
|
||||
else run.after(point)
|
||||
}
|
||||
if (kind === 'color' && !(colour && HEX.test(colour))) return false
|
||||
}
|
||||
|
||||
const scratch = document.createElement('div')
|
||||
scratch.appendChild(range.cloneContents())
|
||||
const colourAttrs = kind === 'color' ? ` data-color="${colour}" style="color:${colour}"` : ''
|
||||
document.execCommand(
|
||||
'insertHTML',
|
||||
false,
|
||||
`<span data-fmt="${kind}"${colourAttrs} data-new="1">${scratch.innerHTML}</span>`
|
||||
/** Every text node under `node`, in document order. */
|
||||
export function textNodes(node: Node, into: Text[] = []): Text[] {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === 3) into.push(child as Text)
|
||||
else textNodes(child, into)
|
||||
}
|
||||
return into
|
||||
}
|
||||
|
||||
/** Text between two markers, in document order. */
|
||||
function textBetween(root: Node, from: Node, to: Node): Text[] {
|
||||
return textNodes(root).filter(
|
||||
(n) =>
|
||||
n.nodeValue &&
|
||||
from.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING &&
|
||||
to.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING
|
||||
)
|
||||
// Left selected, so another format can be stacked on without selecting again.
|
||||
const made = root.querySelector('[data-new]')
|
||||
if (made) {
|
||||
made.removeAttribute('data-new')
|
||||
// The run itself rather than its contents: Chromium replaces the whole
|
||||
// element when what is selected is all of what is inside it, which would
|
||||
// swallow the format just applied.
|
||||
const around = document.createRange()
|
||||
around.selectNode(made)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(around)
|
||||
}
|
||||
|
||||
function charactersBefore(root: Node, point: Node): number {
|
||||
return textNodes(root).reduce((sum, n) => sum + (point.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING ? n.nodeValue!.length : 0), 0)
|
||||
}
|
||||
|
||||
/** The point in the text that `count` characters in: a start prefers the beginning of the next piece, an end the finish of the last. */
|
||||
function place(root: Node, count: number, atStart: boolean): [Node, number] {
|
||||
const text = textNodes(root)
|
||||
let seen = 0
|
||||
for (const n of text) {
|
||||
const length = n.nodeValue!.length
|
||||
if (atStart ? count < seen + length : count <= seen + length) return [n, count - seen]
|
||||
seen += length
|
||||
}
|
||||
const last = text[text.length - 1]
|
||||
return last ? [last, last.nodeValue!.length] : [root, 0]
|
||||
}
|
||||
|
||||
function unwrap(run: HTMLElement): void {
|
||||
run.replaceWith(...Array.from(run.childNodes))
|
||||
}
|
||||
|
||||
/** Splits leave runs and wrappers with nothing in them; they would serialise as stray markers. */
|
||||
function dropEmpty(parent: Node): void {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child.nodeType === 3) {
|
||||
if (!child.nodeValue) child.remove()
|
||||
} else if (child instanceof HTMLElement && !isMarker(child)) {
|
||||
dropEmpty(child)
|
||||
if (!child.hasChildNodes() && child.nodeName !== 'BR' && child.nodeName !== 'IMG') child.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Two runs of one format side by side are one run, or they would be marked up twice. Markers between them stay in order. */
|
||||
function mergeRuns(parent: Node): void {
|
||||
let child = parent.firstChild
|
||||
while (child) {
|
||||
if (isRun(child)) {
|
||||
const between: Node[] = []
|
||||
let next = child.nextSibling
|
||||
while (next && (isMarker(next) || (next.nodeType === 3 && !next.nodeValue))) {
|
||||
between.push(next)
|
||||
next = next.nextSibling
|
||||
}
|
||||
if (isRun(next) && sameRun(child, next)) {
|
||||
child.append(...between, ...Array.from(next.childNodes))
|
||||
next.remove()
|
||||
continue
|
||||
}
|
||||
}
|
||||
child = child.nextSibling
|
||||
}
|
||||
for (const element of Array.from(parent.childNodes)) if (element instanceof HTMLElement && !isMarker(element)) mergeRuns(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection of `range` with `kind` put on - or taken off, if all of it
|
||||
* already has it. Works on `root` (a copy of the box, in practice) and
|
||||
* leaves `range` on the same text afterwards. False when there was nothing
|
||||
* to do.
|
||||
*
|
||||
* Applying over text that is part bold makes all of it bold, once, and
|
||||
* taking it off from the middle of a run leaves the rest of the run as it was;
|
||||
* both come from cutting every run of the kind at the selection's ends first.
|
||||
*/
|
||||
export function transformFormat(root: HTMLElement, range: Range, kind: FormatKind, colour?: string): boolean {
|
||||
if (kind === 'color' && colour && !HEX.test(colour)) return false
|
||||
const start = marker('s')
|
||||
const end = marker('e')
|
||||
const atEnd = range.cloneRange()
|
||||
atEnd.collapse(false)
|
||||
atEnd.insertNode(end)
|
||||
const atStart = range.cloneRange()
|
||||
atStart.collapse(true)
|
||||
atStart.insertNode(start)
|
||||
|
||||
const leave = (done: boolean): boolean => {
|
||||
start.remove()
|
||||
end.remove()
|
||||
return done
|
||||
}
|
||||
if (textBetween(root, start, end).length === 0) return leave(false)
|
||||
|
||||
lift(root, end, kind, false)
|
||||
lift(root, start, kind, true)
|
||||
|
||||
const text = textBetween(root, start, end)
|
||||
const runs = Array.from(root.querySelectorAll<HTMLElement>(`[data-fmt="${kind}"]`)).filter(
|
||||
(run) => start.compareDocumentPosition(run) & Node.DOCUMENT_POSITION_FOLLOWING && end.compareDocumentPosition(run) & Node.DOCUMENT_POSITION_PRECEDING
|
||||
)
|
||||
const covered = text.every((t) => {
|
||||
for (let n: Node | null = t.parentNode; n && n !== root; n = n.parentNode) if (isKind(n, kind)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
if (kind === 'color' && !colour) {
|
||||
if (runs.length === 0) return leave(false)
|
||||
}
|
||||
// A colour is never toggled by choosing it again: a different one replaces.
|
||||
const off = kind === 'color' ? !colour : covered
|
||||
for (const run of runs) unwrap(run)
|
||||
if (!off) {
|
||||
const around = document.createRange()
|
||||
around.setStartAfter(start)
|
||||
around.setEndBefore(end)
|
||||
const span = document.createElement('span')
|
||||
span.dataset.fmt = kind
|
||||
if (kind === 'color' && colour) {
|
||||
span.dataset.color = colour
|
||||
span.style.color = colour
|
||||
}
|
||||
span.append(around.extractContents())
|
||||
around.insertNode(span)
|
||||
}
|
||||
|
||||
dropEmpty(root)
|
||||
mergeRuns(root)
|
||||
|
||||
// The markers say where the selection was, as a count of characters; the
|
||||
// text nodes are about to be joined, and a count survives that.
|
||||
const from = charactersBefore(root, start)
|
||||
const to = charactersBefore(root, end)
|
||||
start.remove()
|
||||
end.remove()
|
||||
root.normalize()
|
||||
range.setStart(...place(root, from, true))
|
||||
range.setEnd(...place(root, to, false))
|
||||
return true
|
||||
}
|
||||
|
||||
/** 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]
|
||||
for (let n: Node = node; n !== root; n = n.parentNode as Node) {
|
||||
path.unshift(Array.prototype.indexOf.call((n.parentNode as Node).childNodes, n))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function resolve(root: Node, path: number[]): [Node, number] {
|
||||
let node = root
|
||||
for (const index of path.slice(0, -1)) node = node.childNodes[index]
|
||||
return [node, path[path.length - 1]]
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a format to what is selected in the box, or takes it off again if
|
||||
* all of the selection already has it. Returns whether it did anything.
|
||||
*
|
||||
* The change is worked out on a copy of the box and put back by replacing the
|
||||
* box's contents with one `insertHTML`, so it is a single step of the box's
|
||||
* own undo - and so the browser's habit of keeping the formatting of what a
|
||||
* replacement lands in cannot put back a format that was just taken off.
|
||||
*/
|
||||
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
|
||||
const 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()
|
||||
if (!selection || selection.rangeCount === 0) return false
|
||||
const picked = selection.getRangeAt(0)
|
||||
if (picked.collapsed || !root.contains(picked.commonAncestorContainer)) return false
|
||||
root.focus()
|
||||
|
||||
const copy = root.cloneNode(true) as HTMLElement
|
||||
// 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, 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)
|
||||
const end = pathOf(copy, range.endContainer, range.endOffset)
|
||||
selection.selectAllChildren(root)
|
||||
document.execCommand('insertHTML', false, copy.innerHTML)
|
||||
|
||||
// Left selected, so another format can be stacked on without selecting again.
|
||||
select(root, { start, end })
|
||||
if (beforeSelection) remember({ before, beforeSelection, after: root.innerHTML, afterSelection: { start, end } })
|
||||
return true
|
||||
}
|
||||
|
||||
interface Picked {
|
||||
start: number[]
|
||||
end: number[]
|
||||
}
|
||||
|
||||
/** What the box held and had selected either side of a format, so undo and redo can put the selection back. */
|
||||
interface Edit {
|
||||
before: string
|
||||
beforeSelection: Picked
|
||||
after: string
|
||||
afterSelection: Picked
|
||||
}
|
||||
|
||||
const edits: Edit[] = []
|
||||
|
||||
function remember(edit: Edit): void {
|
||||
edits.push(edit)
|
||||
if (edits.length > 100) edits.shift()
|
||||
}
|
||||
|
||||
function currentSelection(root: HTMLElement): Picked | null {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return null
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null
|
||||
return { start: pathOf(root, range.startContainer, range.startOffset), end: pathOf(root, range.endContainer, range.endOffset) }
|
||||
}
|
||||
|
||||
function select(root: HTMLElement, picked: Picked): void {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
try {
|
||||
const range = document.createRange()
|
||||
range.setStart(...resolve(root, picked.start))
|
||||
range.setEnd(...resolve(root, picked.end))
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} catch {
|
||||
/* the contents are not built the way they were; the caret is wherever the browser left it */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo and redo step over a format as the whole box being replaced, and the
|
||||
* browser leaves everything selected afterwards - where the next key typed
|
||||
* would replace the message. Called after either, this puts back the
|
||||
* selection that was there at that point, when the box holds exactly what it
|
||||
* held then.
|
||||
*/
|
||||
export function restoreSelectionAfterHistory(root: HTMLElement, inputType: string): void {
|
||||
const undo = inputType === 'historyUndo'
|
||||
if (!undo && inputType !== 'historyRedo') return
|
||||
const now = root.innerHTML
|
||||
for (let i = edits.length - 1; i >= 0; i--) {
|
||||
const edit = edits[i]
|
||||
if ((undo ? edit.before : edit.after) === now) {
|
||||
select(root, undo ? edit.beforeSelection : edit.afterSelection)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
normalizeBBCode,
|
||||
sneedGreentext,
|
||||
sneedSameText,
|
||||
sourcePick,
|
||||
stripCodeBlocks,
|
||||
stripEmbeddedUrls,
|
||||
stripQuoteBlocks,
|
||||
@@ -357,3 +358,33 @@ describe("Sneedchat's greentext", () => {
|
||||
expect(sneedSameText('hello', '[color=#72ff72]>hello')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdown escapes', () => {
|
||||
const on = { markdownEscapes: true }
|
||||
it('drops the backslash of an escaped mark, and keeps the mark as text', () => {
|
||||
expect(formatMessage('The Austin Police \\(APD\\) is aware\\. Call 911\\.', on)).toBe('The Austin Police (APD) is aware. Call 911.')
|
||||
expect(formatMessage('a \\* b \\* c', on)).toBe('a * b * c')
|
||||
expect(formatMessage('one \\\\ backslash', on)).toBe('one \\ backslash')
|
||||
expect(formatMessage('Q \\& A', on)).toBe('Q & A')
|
||||
})
|
||||
it('keeps an escaped asterisk from starting bold', () => {
|
||||
expect(formatMessage('\\*not bold\\*', on)).toBe('*not bold*')
|
||||
})
|
||||
it('leaves backslashes alone where nobody asked', () => {
|
||||
expect(formatMessage('a regex \\d+\\.\\d', {})).toBe('a regex \\d+\\.\\d')
|
||||
})
|
||||
it('leaves a backslash before a letter alone even when asked', () => {
|
||||
expect(formatMessage('C:\\Users\\me', on)).toBe('C:\\Users\\me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a picker section sends back', () => {
|
||||
it("is Discord's own token for a custom emoji, never the bare number that names it", () => {
|
||||
expect(sourcePick('discord', { id: '1413156421880647762', name: 'lettyCrazy' })).toBe('<:lettyCrazy:1413156421880647762>')
|
||||
expect(sourcePick('discord', { id: '99', name: 'dance', animated: true })).toBe('<a:dance:99>')
|
||||
})
|
||||
it('leaves the other services as they were', () => {
|
||||
expect(sourcePick('sneedchat', { id: ':lol:' })).toBe(':lol:')
|
||||
expect(sourcePick('steam', { id: 'steam:steambored' })).toBe('[steam:emote:steambored]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,8 +172,13 @@ export function emojiPreview(text: string, smilies: SmilieEntry[] = []): EmojiPr
|
||||
* `steam:name` key, which is no message at all: it was inserted as the words
|
||||
* "steam:steambored". It leaves as the emoticon token, which the box draws.
|
||||
*/
|
||||
export function sourcePick(service: string, e: { id: string }): string {
|
||||
return service === 'steam' && e.id.startsWith('steam:') ? `[steam:emote:${e.id.slice(6)}]` : e.id
|
||||
export function sourcePick(service: string, e: { id: string; name?: string; animated?: boolean }): string {
|
||||
if (service === 'steam' && e.id.startsWith('steam:')) return `[steam:emote:${e.id.slice(6)}]`
|
||||
// Discord's is named by a number alone, which is no message at all: picked from another server's section it went
|
||||
// into the box (and the recent list) as the bare digits. It leaves as the form Discord parses, which the box draws
|
||||
// as its picture - and which Nitro lets any server take.
|
||||
if (service === 'discord' && /^\d+$/.test(e.id)) return `<${e.animated ? 'a' : ''}:${e.name || 'emoji'}:${e.id}>`
|
||||
return e.id
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,6 +385,14 @@ export interface FormatOptions {
|
||||
* bold.
|
||||
*/
|
||||
ircFormatting?: 'render' | 'strip'
|
||||
/**
|
||||
* Whether a backslash in front of a punctuation mark is an escape and goes away, leaving the mark as plain text.
|
||||
*
|
||||
* Discord's markdown works that way, and the text it hands over for a link's preview is escaped for it
|
||||
* (`\(APD\)`, `\.`): drawn as it is, the backslashes show. Not elsewhere - a regular expression or a path pasted
|
||||
* into IRC has backslashes that mean themselves.
|
||||
*/
|
||||
markdownEscapes?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -557,6 +570,12 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
|
||||
let out = text
|
||||
|
||||
// Escapes first, so an escaped mark is never read as markdown by a later pass. What follows a backslash is
|
||||
// already HTML-escaped (a `&` is `&` by now), so those forms are matched whole.
|
||||
if (opts.markdownEscapes) {
|
||||
out = out.replace(/\\(&|<|>|"|'|[!#-%(-\/:-@[-`{-~])/g, (_m, mark: string) => stow(mark))
|
||||
}
|
||||
|
||||
// Inline `code` spans, stowed before anything else touches them so backticked
|
||||
// content is never reinterpreted as markdown or links, and escaped since code
|
||||
// commonly contains < > & that would corrupt the parse.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { resolveMediaUrl } from './util'
|
||||
|
||||
/** The caches the daemon can restore from, by folder. Kick's emotes are
|
||||
* not here: lib/emotecache puts Kick's own picture up at once instead. */
|
||||
const RESTORABLE = /[\\/]nobilis[\\/](matrix-media|sneedchat-attachments|sneedchat-avatars|discord-thumbnails|discord-icons)[\\/]/
|
||||
const RESTORABLE = /[\\/]nobilis[\\/](matrix-media|sneedchat-attachments|sneedchat-avatars|discord-thumbnails|discord-icons|telegram-media|telegram-avatars)[\\/]/
|
||||
|
||||
/** Paths being fetched, so a picture drawn twice asks once. */
|
||||
const inFlight = new Map<string, Promise<string | null>>()
|
||||
@@ -54,8 +54,10 @@ function restore(path: string, bufferId?: string, messageId?: string): Promise<s
|
||||
}
|
||||
|
||||
/** Lets a failure through to the element's own handler after all. */
|
||||
function failAfterAll(el: Media): void {
|
||||
el.dataset.restoreGaveUp = '1'
|
||||
function failAfterAll(el: Media, path: string): void {
|
||||
// Of this file, not of the element: a picture that falls back from its preview to the original is the same element
|
||||
// with another file, and that one has not been tried.
|
||||
el.dataset.restoreGaveUp = path
|
||||
el.dispatchEvent(new Event('error'))
|
||||
}
|
||||
|
||||
@@ -63,19 +65,27 @@ export function restoreMissingMedia(): () => void {
|
||||
const onError = (e: Event): void => {
|
||||
const el = e.target
|
||||
if (!(el instanceof HTMLImageElement || el instanceof HTMLVideoElement || el instanceof HTMLAudioElement)) return
|
||||
if (el.dataset.restoreGaveUp) return
|
||||
const path = cachedPath(el)
|
||||
if (!path) return
|
||||
if (!path) {
|
||||
if ((el.currentSrc || el.src).includes('telegram')) console.info(`[restore] a failed picture is not one the daemon can restore: ${(el.currentSrc || el.src).slice(-90)}`)
|
||||
return
|
||||
}
|
||||
if (el.dataset.restoreGaveUp === path) return
|
||||
// Known to be past saving, or already restored once for this element and
|
||||
// still failing: let it fail as it always did.
|
||||
if (gone.has(path) || el.dataset.restoreTried === path) return
|
||||
if (gone.has(path) || el.dataset.restoreTried === path) {
|
||||
console.info(`[restore] ${path.split(/[\\/]/).slice(-2).join('/')} failed again, not asked for twice`)
|
||||
return
|
||||
}
|
||||
el.dataset.restoreTried = path
|
||||
e.stopPropagation()
|
||||
console.info(`[restore] ${path.split(/[\\/]/).slice(-2).join('/')} asked for`)
|
||||
|
||||
const row = el.closest<HTMLElement>('[data-msg-id]')
|
||||
void restore(path, row?.dataset.bufferId, row?.dataset.msgId).then((restored) => {
|
||||
console.info(`[restore] ${path.split(/[\\/]/).slice(-2).join('/')} ${restored ? 'came back' : 'could not be fetched'}`)
|
||||
if (!restored) {
|
||||
failAfterAll(el)
|
||||
failAfterAll(el, path)
|
||||
return
|
||||
}
|
||||
// A new URL for the same file, so nothing serves the failure again
|
||||
|
||||
@@ -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,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SEARCH_ENGINES, cleanQuery, engineName, searchUrl, shortened, validTemplate } from './searchengines'
|
||||
|
||||
describe('where a search goes', () => {
|
||||
it('encodes the selection into the engine the person chose', () => {
|
||||
expect(searchUrl('hello world & more', 'google')).toBe('https://www.google.com/search?q=hello%20world%20%26%20more')
|
||||
expect(searchUrl('café', 'duckduckgo')).toBe('https://duckduckgo.com/?q=caf%C3%A9')
|
||||
expect(searchUrl('x', 'yandex')).toBe('https://yandex.com/search/?text=x')
|
||||
})
|
||||
|
||||
it('falls back to the first engine for one it does not know', () => {
|
||||
expect(searchUrl('x', 'nonsense')).toBe(searchUrl('x', SEARCH_ENGINES[0].id))
|
||||
})
|
||||
|
||||
it('has every engine ask for the query once', () => {
|
||||
for (const e of SEARCH_ENGINES.filter((e) => e.id !== 'custom')) {
|
||||
expect(e.template.split('%s')).toHaveLength(2)
|
||||
expect(e.template.startsWith('https://')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('searches nothing for nothing', () => {
|
||||
expect(searchUrl(' \n ', 'google')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a replacement pattern in the query as the text it is', () => {
|
||||
expect(searchUrl('$& $1', 'google')).toBe('https://www.google.com/search?q=%24%26%20%241')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the query', () => {
|
||||
it('is one line, trimmed', () => {
|
||||
expect(cleanQuery(' two\n\nlines\there ')).toBe('two lines here')
|
||||
})
|
||||
|
||||
it('is cut short rather than sent whole', () => {
|
||||
expect(cleanQuery('a'.repeat(1000)).length).toBe(300)
|
||||
})
|
||||
|
||||
it('is shortened for a menu entry', () => {
|
||||
expect(shortened('short')).toBe('short')
|
||||
expect(shortened('a very long selected sentence that goes on and on')).toMatch(/^a very long.*…$/)
|
||||
expect(shortened('a very long selected sentence that goes on and on').length).toBeLessThanOrEqual(28)
|
||||
})
|
||||
})
|
||||
|
||||
describe('an engine of the person’s own', () => {
|
||||
it('needs a web address with a place for the query', () => {
|
||||
expect(validTemplate('https://example.org/s?q=%s')).toBe(true)
|
||||
expect(validTemplate('http://example.org/%s')).toBe(true)
|
||||
expect(validTemplate('https://example.org/s')).toBe(false)
|
||||
expect(validTemplate('javascript:alert(%s)')).toBe(false)
|
||||
expect(validTemplate('file:///etc/passwd?%s')).toBe(false)
|
||||
expect(validTemplate('')).toBe(false)
|
||||
})
|
||||
|
||||
it('is used when valid and refuses to send anywhere when not', () => {
|
||||
expect(searchUrl('a b', 'custom', 'https://example.org/s?q=%s')).toBe('https://example.org/s?q=a%20b')
|
||||
expect(searchUrl('a b', 'custom', 'not a url')).toBeNull()
|
||||
expect(searchUrl('a b', 'custom')).toBeNull()
|
||||
})
|
||||
|
||||
it('is named by its host', () => {
|
||||
expect(engineName('custom', 'https://www.example.org/s?q=%s')).toBe('example.org')
|
||||
expect(engineName('custom', 'garbage')).toBe('the web')
|
||||
expect(engineName('google', '')).toBe('Google')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The web search engines "Search for…" can send selected text to. Each is a
|
||||
* template with `%s` where the (percent-encoded) query goes; the person picks
|
||||
* one in Settings > General, or gives a template of their own.
|
||||
*/
|
||||
export interface SearchEngine {
|
||||
id: string
|
||||
label: string
|
||||
/** Where the query goes; `%s` is replaced. Empty for the custom entry, whose template is the person's own. */
|
||||
template: string
|
||||
}
|
||||
|
||||
export const SEARCH_ENGINES: SearchEngine[] = [
|
||||
{ id: 'duckduckgo', label: 'DuckDuckGo', template: 'https://duckduckgo.com/?q=%s' },
|
||||
{ id: 'google', label: 'Google', template: 'https://www.google.com/search?q=%s' },
|
||||
{ id: 'bing', label: 'Bing', template: 'https://www.bing.com/search?q=%s' },
|
||||
{ id: 'brave', label: 'Brave Search', template: 'https://search.brave.com/search?q=%s' },
|
||||
{ id: 'startpage', label: 'Startpage', template: 'https://www.startpage.com/do/search?q=%s' },
|
||||
{ id: 'ecosia', label: 'Ecosia', template: 'https://www.ecosia.org/search?q=%s' },
|
||||
{ id: 'qwant', label: 'Qwant', template: 'https://www.qwant.com/?q=%s' },
|
||||
{ id: 'yandex', label: 'Yandex', template: 'https://yandex.com/search/?text=%s' },
|
||||
{ id: 'kagi', label: 'Kagi', template: 'https://kagi.com/search?q=%s' },
|
||||
{ id: 'custom', label: 'Other…', template: '' }
|
||||
]
|
||||
|
||||
export const DEFAULT_SEARCH_ENGINE = 'duckduckgo'
|
||||
|
||||
/** The longest query sent: a whole pasted wall of text is not a search, and a URL has limits. */
|
||||
const MAX_QUERY = 300
|
||||
|
||||
/** A custom template is usable if it is a web address with a place for the query. */
|
||||
export function validTemplate(template: string): boolean {
|
||||
return /^https?:\/\/[^\s%]+/i.test(template.trim()) && template.includes('%s')
|
||||
}
|
||||
|
||||
/** What the person chose, as a template - or null if it is custom and not (yet) usable. */
|
||||
function templateFor(engineId: string, custom: string): string | null {
|
||||
const engine = SEARCH_ENGINES.find((e) => e.id === engineId) ?? SEARCH_ENGINES[0]
|
||||
if (engine.id === 'custom') return validTemplate(custom) ? custom.trim() : null
|
||||
return engine.template
|
||||
}
|
||||
|
||||
/** The name shown on the menu entry: the engine's, or the custom address's host. */
|
||||
export function engineName(engineId: string, custom: string): string {
|
||||
const engine = SEARCH_ENGINES.find((e) => e.id === engineId) ?? SEARCH_ENGINES[0]
|
||||
if (engine.id !== 'custom') return engine.label
|
||||
try {
|
||||
return new URL(custom.trim().replace('%s', 'x')).hostname.replace(/^www\./, '') || 'the web'
|
||||
} catch {
|
||||
return 'the web'
|
||||
}
|
||||
}
|
||||
|
||||
/** The query as it will be sent: one line, trimmed, and not absurdly long. */
|
||||
export function cleanQuery(text: string): string {
|
||||
const one = text.replace(/\s+/g, ' ').trim()
|
||||
return one.length > MAX_QUERY ? one.slice(0, MAX_QUERY).trimEnd() : one
|
||||
}
|
||||
|
||||
/** The address to open to search for `text`, or null if there is nothing to search for or nowhere to send it. */
|
||||
export function searchUrl(text: string, engineId: string, custom = ''): string | null {
|
||||
const query = cleanQuery(text)
|
||||
const template = templateFor(engineId, custom)
|
||||
if (!query || !template) return null
|
||||
// Split rather than replaced: a `$&` in the query is not a replacement pattern.
|
||||
return template.split('%s').join(encodeURIComponent(query))
|
||||
}
|
||||
|
||||
/** A selection shortened to fit a menu entry. */
|
||||
export function shortened(text: string, max = 28): string {
|
||||
const one = cleanQuery(text)
|
||||
return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import matrixSvg from '../assets/matrix-mark.svg'
|
||||
import kickSvg from '../assets/kick.svg'
|
||||
import steamSvg from '../assets/steam-mark.svg'
|
||||
import twitchSvg from '../assets/twitch-mark.svg'
|
||||
import telegramSvg from '../assets/telegram-mark.svg'
|
||||
|
||||
/**
|
||||
* Discord channel buffers are named "GuildName/#channel" internally - that's
|
||||
@@ -62,7 +63,7 @@ export interface ServiceIcon {
|
||||
* the same menus, as "Whisper".
|
||||
*/
|
||||
export function hasDirectMessages(service: string | undefined): boolean {
|
||||
return service === 'irc' || service === 'discord' || service === 'matrix' || service === 'twitch'
|
||||
return service === 'irc' || service === 'discord' || service === 'matrix' || service === 'twitch' || service === 'telegram'
|
||||
}
|
||||
|
||||
export function serviceIcon(service: string): ServiceIcon {
|
||||
@@ -75,6 +76,8 @@ export function serviceIcon(service: string): ServiceIcon {
|
||||
return { mark: steamSvg }
|
||||
case 'twitch':
|
||||
return { mark: twitchSvg }
|
||||
case 'telegram':
|
||||
return { mark: telegramSvg }
|
||||
case 'irc':
|
||||
return { glyph: 'tag' }
|
||||
case 'slack':
|
||||
@@ -110,6 +113,8 @@ export function serviceLabel(service: string): string {
|
||||
return 'Steam'
|
||||
case 'twitch':
|
||||
return 'Twitch'
|
||||
case 'telegram':
|
||||
return 'Telegram'
|
||||
case 'slack':
|
||||
return 'Slack'
|
||||
case 'jabber':
|
||||
|
||||
@@ -600,6 +600,8 @@ export interface ChatState {
|
||||
/** Which messages each Matrix room has pinned, newest last. */
|
||||
/** Which messages a conversation has pinned, where the service says so. */
|
||||
pinnedMessages: Record<string, string[]>
|
||||
/** How many replies or comments a message has, where the service says so (Telegram): by conversation, then message. */
|
||||
threadCounts: Record<string, Record<string, number>>
|
||||
/**
|
||||
* The pinned messages themselves, once somebody has asked for them. The ids
|
||||
* above say that something is pinned; these say what, for the banner over
|
||||
@@ -827,6 +829,7 @@ const INITIAL: ChatState = {
|
||||
pinnedByBuffer: {},
|
||||
reviewCard: null,
|
||||
pinnedMessages: {},
|
||||
threadCounts: {},
|
||||
pinnedRows: {},
|
||||
forumPages: {},
|
||||
discordFriends: {},
|
||||
@@ -1041,7 +1044,7 @@ function applyOwnReaction(list: Reaction[], emoji: string, add: boolean): Reacti
|
||||
* backwards through what they do offer.
|
||||
*/
|
||||
function canFetchAround(bufferId: string): boolean {
|
||||
return bufferId.startsWith('discord:') || bufferId.startsWith('matrix:')
|
||||
return bufferId.startsWith('discord:') || bufferId.startsWith('matrix:') || bufferId.startsWith('telegram:')
|
||||
}
|
||||
|
||||
function bestEffort(work: Promise<unknown>, what: string): void {
|
||||
@@ -1346,6 +1349,12 @@ export class ChatStore {
|
||||
this.set({ activeBufferId: initialBufferId, activeGroupId: initialGroupId })
|
||||
|
||||
void this.refreshNetSettings()
|
||||
const looked = (): void => {
|
||||
const id = this.ackPending
|
||||
if (id && id === this.state.activeBufferId && this.windowIsActive()) bestEffort(this.ackRead(id), 'ack read')
|
||||
}
|
||||
window.addEventListener('focus', looked)
|
||||
document.addEventListener('visibilitychange', looked)
|
||||
void window.moho.popout.list().then((popouts) => this.set({ popouts }))
|
||||
window.moho.popout.onChange((popouts) => this.set({ popouts }))
|
||||
|
||||
@@ -2602,6 +2611,13 @@ export class ChatStore {
|
||||
|
||||
// --- push events ----------------------------------------------------
|
||||
|
||||
/** The conversation to be told is read as soon as the window is looked at. */
|
||||
private ackPending: string | null = null
|
||||
|
||||
private windowIsActive(): boolean {
|
||||
return document.visibilityState === 'visible' && document.hasFocus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the service a conversation has been read.
|
||||
*
|
||||
@@ -2615,6 +2631,14 @@ export class ChatStore {
|
||||
* leave a room read here unread on your phone forever.
|
||||
*/
|
||||
private async ackRead(bufferId: string): Promise<void> {
|
||||
// Only what is being read. A window that is hidden, or is not the one in front, is not reading anything: a
|
||||
// conversation opened at startup behind other windows, or selected from a notification, is not read by anybody.
|
||||
// Said when the window is next looked at (`ackPending`), and only if that is still the conversation on screen.
|
||||
if (!this.windowIsActive()) {
|
||||
this.ackPending = bufferId
|
||||
return
|
||||
}
|
||||
this.ackPending = null
|
||||
const prefs = await window.moho.prefs.getAll()
|
||||
await window.moho.rpc('markBufferRead', {
|
||||
bufferId,
|
||||
@@ -2679,6 +2703,8 @@ export class ChatStore {
|
||||
attachment,
|
||||
spoilers
|
||||
)
|
||||
// A Telegram comment lands in the channel's discussion group, not in this conversation, so the panel asks again.
|
||||
if (this.accountFor(open.bufferId)?.service === 'telegram') void this.openThreadPanel(open.bufferId, open.rootId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2929,6 +2955,18 @@ export class ChatStore {
|
||||
* and guessing at it would mean showing an answer that never came.
|
||||
*/
|
||||
useComponent(bufferId: string, messageId: string, customId: string, select: boolean, values: string[]): void {
|
||||
// A Telegram bot answers a press with a notice, or an address to open, and no more: whatever else it does arrives
|
||||
// as an edit to the message or a new one.
|
||||
if (this.accountFor(bufferId)?.service === 'telegram') {
|
||||
void window.moho
|
||||
.rpc<{ message?: string | null; alert?: boolean; url?: string | null }>('pressTelegramButton', { bufferId, messageId, customId })
|
||||
.then((answer) => {
|
||||
if (answer.message) this.toast(answer.alert ? 'error' : 'info', answer.message)
|
||||
if (answer.url) void window.moho.openExternal(answer.url)
|
||||
})
|
||||
.catch((e: Error) => this.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
void window.moho
|
||||
.rpc('useDiscordComponent', { bufferId, messageId, customId, select, values })
|
||||
.catch((e: Error) => this.toast('error', e.message))
|
||||
@@ -3601,7 +3639,7 @@ export class ChatStore {
|
||||
// forward without an author.
|
||||
case 'messageReplyRenamed': {
|
||||
this.mapMessage(data.bufferId, data.id, (m) =>
|
||||
m.replyTo ? { ...m, replyTo: { ...m.replyTo, from: data.from } } : m
|
||||
m.replyTo ? { ...m, replyTo: { ...m.replyTo, from: data.from, ...(data.body ? { body: data.body } : {}) } } : m
|
||||
)
|
||||
break
|
||||
}
|
||||
@@ -3642,6 +3680,14 @@ export class ChatStore {
|
||||
this.mapMessage(data.bufferId, data.id, (m) => ({ ...m, moderated: data.moderated ?? {} }))
|
||||
break
|
||||
|
||||
case 'threadCount': {
|
||||
const d = data as unknown as { bufferId: string; id: string; count: number }
|
||||
const mine = this.state.threadCounts[d.bufferId] ?? {}
|
||||
if (mine[d.id] === d.count) break
|
||||
this.set({ threadCounts: { ...this.state.threadCounts, [d.bufferId]: { ...mine, [d.id]: d.count } } })
|
||||
break
|
||||
}
|
||||
|
||||
case 'reactionsChanged':
|
||||
this.mapMessage(data.bufferId, data.id, (m) => ({ ...m, reactions: data.reactions }))
|
||||
break
|
||||
@@ -5432,6 +5478,15 @@ export class ChatStore {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (account?.service === 'telegram') {
|
||||
try {
|
||||
const perms = await window.moho.rpc<RoomPermissions>('telegramPermissions', { bufferId })
|
||||
this.set({ matrixPermissions: { ...this.state.matrixPermissions, [bufferId]: perms } })
|
||||
} catch {
|
||||
/* advisory only */
|
||||
}
|
||||
return
|
||||
}
|
||||
if (account?.service !== 'matrix') return
|
||||
try {
|
||||
const perms = await window.moho.rpc<RoomPermissions>('getMatrixRoomPermissions', {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const IPC = {
|
||||
windowClose: 'moho:window:close',
|
||||
windowIsMaximized: 'moho:window:isMaximized',
|
||||
openExternal: 'moho:openExternal',
|
||||
openArticle: 'moho:openArticle',
|
||||
resolveImagePage: 'moho:resolveImagePage',
|
||||
pickFile: 'moho:pickFile',
|
||||
pickSavePath: 'moho:pickSavePath',
|
||||
@@ -118,4 +119,6 @@ export interface EditMenuRequest {
|
||||
suggestions: string[]
|
||||
can: Record<EditAction, boolean>
|
||||
hasSelection: boolean
|
||||
/** What is selected, for searching the web for it. */
|
||||
selection: string
|
||||
}
|
||||
|
||||
+5
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'twitch' | 'jabber' | 'slack'
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'twitch' | 'telegram' | 'jabber' | 'slack'
|
||||
displayName: string
|
||||
/**
|
||||
* How the user is presenting: "online" | "idle". Distinct from
|
||||
@@ -173,6 +173,8 @@ export interface Buffer {
|
||||
* refuses, so offering to leave it is offering something that fails.
|
||||
*/
|
||||
serviceRoom?: boolean
|
||||
/** A glyph drawn in front of the name in the list: Telegram's broadcast channels and bots. */
|
||||
nameIcon?: string
|
||||
/**
|
||||
* Set while the buffer exists but the service has not yet said what is in
|
||||
* it - a Matrix room between joining it and its first sync. The list draws
|
||||
@@ -437,6 +439,8 @@ export interface ReplyPreview {
|
||||
* says which thread that message is part of, and a reply can be both.
|
||||
*/
|
||||
threadRoot?: string
|
||||
/** The conversation (a buffer id) the answered message is in, when it is not this one: Telegram can answer a post in another channel. */
|
||||
chat?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user