Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e708e428d9 | ||
|
|
df0edf1cf8 | ||
|
|
1d674edfe6 | ||
|
|
0e1e6f38ed | ||
|
|
120fb10134 | ||
|
|
66a680167b | ||
|
|
f4e8136ff3 |
No files matched your search
+14
-2
@@ -236,8 +236,20 @@ called `subscribe` for that buffer.
|
||||
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/`.
|
||||
account. Also stickers, group bot commands, forum topics with their own unread counts, sign-in by QR code, and
|
||||
folders kept in step with moho's categories both ways. Not supported: secret chats, calls, stories, payments,
|
||||
drafts and scheduled messages. The code is `nobilis/src/backend/telegram/`.
|
||||
|
||||
- **Stoat** (formerly Revolt; on the `protocol/stoat` branch, not yet released) - the REST api plus the events
|
||||
websocket, for the official instance or a self-hosted one the account names. Sign-in by email and password
|
||||
(with its MFA code) or a pasted token; a captcha or email check is said in words and left to Stoat's own app.
|
||||
Servers, categories, channels, groups and DMs; the account's permissions worked out per channel (so a channel it
|
||||
may not write in shows why); history and live messages, edits, deletions, replies, embeds, masquerade names,
|
||||
system messages as cards, files both ways through Autumn, reactions, custom emoji, members with roles and
|
||||
presence, profile cards, friends, blocks, invites, moderation (kick, ban, timeout, roles), and voice calls on
|
||||
LiveKit through the same client Matrix uses. Limits and slowmode are kept to before a request is made. The
|
||||
code is `nobilis/src/backend/stoat/`; the verification scripts run it against a self-hosted instance in docker
|
||||
(`scripts/probe-stoat-local.mjs`, `probe-stoat-voice.mjs`).
|
||||
|
||||
### Notifications are cards
|
||||
|
||||
|
||||
+1
-1
Submodule nobilis updated: 5983d71baf...39a65bd0df.
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The Stoat parts of the window, in a scratch moho (nothing is typed into a form that would be sent): the account
|
||||
* picker offers Stoat with its mark, the form asks for an instance (optional), an email and a password, keeps the
|
||||
* button off until the last two are given, and offers a pasted session token as the other way in.
|
||||
*
|
||||
* node scripts/check-stoat-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('Stoat')); return c ? { text: c.textContent.trim(), mark: !!c.querySelector('.mask-icon, img, span[style*="mask"]') } : null })()`)
|
||||
check('the picker offers Stoat', !!chip, JSON.stringify(chip))
|
||||
await ev(`[...document.querySelectorAll('.service-chip')].find((x) => x.textContent.includes('Stoat')).click()`)
|
||||
await sleep(500)
|
||||
const form = await ev(`(() => { const f = document.querySelector('.add-form'); return f ? f.innerText : '' })()`)
|
||||
check('the form says the instance can be left empty and where accounts are made', /official one/.test(form) && /own app/.test(form), form.slice(0, 160))
|
||||
check('it asks for an email and a password', /Email/.test(form) && /Password/.test(form))
|
||||
const signIn = () => ev(`[...document.querySelectorAll('.add-form button.primary')].find((b) => /Sign in/.test(b.textContent))?.disabled`)
|
||||
check('nothing can be sent while the form is empty', (await signIn()) === true)
|
||||
const fill = (label, value) => ev(`(() => {
|
||||
const field = [...document.querySelectorAll('.add-form label.field')].find((l) => l.textContent.includes(${JSON.stringify(label)}))
|
||||
const input = field.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('Email', '[email protected]')
|
||||
check('an email alone is not enough', (await signIn()) === true)
|
||||
await fill('Password', 'hunter2')
|
||||
await sleep(200)
|
||||
check('an email and a password turn the button on', (await signIn()) === false)
|
||||
await shot('stoat-form.png')
|
||||
await ev(`[...document.querySelectorAll('.add-form .segmented button')].find((b) => /token/i.test(b.textContent)).click()`)
|
||||
await sleep(300)
|
||||
check('a session token can be pasted instead', /Session token/.test(await ev(`document.querySelector('.add-form').innerText`)))
|
||||
check('which needs only the token', (await signIn()) === true)
|
||||
await fill('Session token', 'abc')
|
||||
await sleep(200)
|
||||
check('and then it can be sent', (await signIn()) === false)
|
||||
} finally {
|
||||
await stop()
|
||||
console.log(results.every(Boolean) ? 'ALL OK' : 'SOME FAILED')
|
||||
process.exitCode = results.every(Boolean) ? 0 : 1
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The local self-hosted Stoat the Stoat probes talk to (http://localhost:8880, started with
|
||||
* ~/.local/share/moho-dev/stoat-docker.sh and set up with stoat-local.py): its two test accounts, and calls made as
|
||||
* either of them - the "other person" in a conversation moho is one side of.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
|
||||
export const API = 'http://localhost:8880/api'
|
||||
export const accounts = () => JSON.parse(fs.readFileSync('/1tb/stoat-test/accounts.json', 'utf8'))
|
||||
|
||||
/** One call as `who` ('alice' or 'bob'). */
|
||||
export async function as(who, method, path, body) {
|
||||
return withToken(accounts()[who].token, method, path, body)
|
||||
}
|
||||
|
||||
/** A call with a token, waiting out the instance's rate limit as a client has to (a 429 says how long). */
|
||||
async function withToken(token, method, path, body) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const r = await fetch(API + path, {
|
||||
method,
|
||||
headers: { 'X-Session-Token': token, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
})
|
||||
const text = await r.text()
|
||||
if (r.status === 429 && attempt < 4) {
|
||||
await new Promise((resolve) => setTimeout(resolve, (JSON.parse(text).retry_after ?? 1000) + 50))
|
||||
continue
|
||||
}
|
||||
if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${text.slice(0, 200)}`)
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
}
|
||||
|
||||
/** A file put on the file server as `who`, answering its id. `tag` is attachments, emojis, avatars, icons... */
|
||||
export async function upload(who, name, bytes, type, tag = 'attachments') {
|
||||
const a = accounts()[who]
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([bytes], { type }), name)
|
||||
const r = await fetch(`http://localhost:8880/autumn/${tag}`, { method: 'POST', headers: { 'X-Session-Token': a.token }, body: form })
|
||||
if (!r.ok) throw new Error(`upload: ${r.status} ${await r.text()}`)
|
||||
return (await r.json()).id
|
||||
}
|
||||
|
||||
/** A new account made on the local instance (never the official one): answers its token and id, for a probe that needs a stranger. */
|
||||
export async function makeAccount(name) {
|
||||
const email = `${name}@test.local`
|
||||
const password = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
|
||||
const post = async (path, body, token) => {
|
||||
const r = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Session-Token': token } : {}) }, body: JSON.stringify(body) })
|
||||
const text = await r.text()
|
||||
if (!r.ok) throw new Error(`POST ${path}: ${r.status} ${text.slice(0, 200)}`)
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
await post('/auth/account/create', { email, password })
|
||||
const session = await post('/auth/session/login', { email, password, friendly_name: 'probe' })
|
||||
await post('/onboard/complete', { username: name }, session.token)
|
||||
const me = await (await fetch(API + '/users/@me', { headers: { 'X-Session-Token': session.token } })).json()
|
||||
const call = (method, path, body) => withToken(session.token, method, path, body)
|
||||
return { id: me._id, username: me.username, token: session.token, call }
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* moho against the local self-hosted Stoat, signed in as alice, with bob as the other person: what bob does is seen,
|
||||
* and what alice does in moho is seen from bob's side.
|
||||
*
|
||||
* ~/.local/share/moho-dev/stoat-docker.sh start && cd /1tb/stoat-test/selfhosted && DOCKER_HOST=unix:///run/docker-stoat.sock docker compose up -d
|
||||
* node scripts/probe-stoat-local.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
import { accounts, as, upload, makeAccount, API } from './lib/stoat-local.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const acc = accounts()
|
||||
// A stranger for this run: asks to be a friend, and has a server to be invited to.
|
||||
const stranger = await makeAccount(`probe${Date.now().toString(36)}`)
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const rpc = (method, params = {}) => ev(`window.moho.rpc(${JSON.stringify(method)}, ${JSON.stringify(params)})`)
|
||||
const shot = async (name) => {
|
||||
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
let crashed = false
|
||||
const results = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, detail)
|
||||
}
|
||||
const until = async (what, fn, ms = 15000) => {
|
||||
const t = Date.now()
|
||||
while (Date.now() - t < ms) {
|
||||
try {
|
||||
const v = await fn()
|
||||
if (v) return v
|
||||
} catch {
|
||||
/* not yet */
|
||||
}
|
||||
await sleep(250)
|
||||
}
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await sleep(2500)
|
||||
// The two are friends to begin with (a block ends a friendship, and an earlier run may have made one).
|
||||
await as('alice', 'PUT', `/users/${acc.bob.id}/friend`).catch(() => {})
|
||||
await as('bob', 'PUT', `/users/${acc.alice.id}/friend`).catch(() => {})
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await rpc('addStoatAccountToken', { instance: API, token: acc.alice.token })
|
||||
const account = `stoat:${acc.alice.id}`
|
||||
const connected = await until('connected', async () => (await rpc('listAccounts')).find((a) => a.id === account && a.state === 'connected'))
|
||||
check('alice connects to the local instance', !!connected)
|
||||
await ev(`window.__mohoShots?.store.refreshAccounts?.(); window.__mohoShots?.store.refreshBuffers?.()`)
|
||||
const general = `${account}|Test Hall/#General`
|
||||
const buffers = await until('buffers', async () => {
|
||||
const b = (await rpc('listBuffers')).filter((x) => x.accountId === account)
|
||||
return b.some((x) => x.id === general) ? b : null
|
||||
})
|
||||
check('the server and its channels are made', !!buffers, JSON.stringify(buffers?.map((b) => b.name)))
|
||||
check('a voice room is a conversation too, marked as one', buffers?.find((b) => /Voice Room/.test(b.name))?.nameIcon === 'volume_up')
|
||||
const notes = await rpc('openDirectMessage', { accountId: account, userId: acc.alice.id })
|
||||
check('the account’s own notes open as a conversation', (notes.bufferId ?? notes).endsWith('|Saved Notes'), JSON.stringify(notes))
|
||||
const channel = acc.server.channels.General
|
||||
|
||||
// bob speaks.
|
||||
await rpc('subscribe', { bufferId: general }).catch(() => {})
|
||||
await as('bob', 'POST', `/channels/${channel}/messages`, { content: 'hello from bob, **bold** and !!a secret!!' })
|
||||
const heard = await until('bob heard', async () => (await rpc('getBacklog', { bufferId: general })).find((m) => /hello from bob/.test(m.body)))
|
||||
check('what bob says is heard', !!heard, heard && JSON.stringify([heard.from, heard.body]))
|
||||
check('with its spoiler in the window’s marks', /\|\|a secret\|\|/.test(heard?.body ?? ''))
|
||||
|
||||
// alice speaks, in moho.
|
||||
await rpc('sendMessage', { bufferId: general, body: 'hello from moho' })
|
||||
const seen = await until('alice seen', async () => (await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).find((m) => m.content === 'hello from moho'))
|
||||
check('what alice says in moho is seen by bob', !!seen)
|
||||
|
||||
// a reply, an edit, a reaction, a delete.
|
||||
const mine = (await rpc('getBacklog', { bufferId: general })).find((m) => m.body === 'hello from moho')
|
||||
await rpc('editMessage', { bufferId: general, messageId: mine.id, body: 'hello from moho (edited)' })
|
||||
const edited = await until('edited', async () => (await as('bob', 'GET', `/channels/${channel}/messages/${mine.id}`)).content === 'hello from moho (edited)')
|
||||
check('an edit reaches bob', !!edited)
|
||||
await rpc('toggleReaction', { bufferId: general, messageId: mine.id, emoji: '👍', add: true }).catch((e) => console.log('react:', e.message))
|
||||
const reacted = await until('reacted', async () => Object.keys((await as('bob', 'GET', `/channels/${channel}/messages/${mine.id}`)).reactions ?? {}).includes('👍'))
|
||||
check('a reaction reaches bob', !!reacted)
|
||||
await as('bob', 'PUT', `/channels/${channel}/messages/${mine.id}/reactions/${encodeURIComponent('🔥')}`)
|
||||
const flame = await until('flame', async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.id === mine.id)?.reactions?.some((r) => r.emoji === '🔥'))
|
||||
check('and bob’s reaches moho', !!flame)
|
||||
await rpc('deleteMessage', { bufferId: general, messageId: mine.id })
|
||||
const gone = await until('gone', async () => !(await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).some((m) => m.id === mine.id))
|
||||
check('a delete reaches bob', !!gone)
|
||||
|
||||
|
||||
// The people here, and who one of them is.
|
||||
await rpc('requestMemberList', { bufferId: general })
|
||||
const roster = await until('roster', () => ev(`window.__mohoShots.state().presenceByBuffer[${JSON.stringify(general)}]`))
|
||||
check('the member list names alice and bob', !!roster?.some((m) => m.nick === 'alice') && !!roster?.some((m) => m.nick === 'bob'), JSON.stringify(roster?.map((m) => [m.nick, m.status])))
|
||||
check('alice, who is connected, is online, and bob, who is not, is offline', roster?.find((m) => m.nick === 'alice')?.status === 'online' && roster?.find((m) => m.nick === 'bob')?.status === 'offline')
|
||||
await rpc('requestProfile', { bufferId: general, nick: 'bob', userId: acc.bob.id })
|
||||
const card = await until('profile', () => ev(`(() => { const p = window.__mohoShots.state().profile; return p && p.id === ${JSON.stringify(acc.bob.id)} && !p.pending ? p : null })()`))
|
||||
check('bob’s card says who he is, when the account was made and when he joined', !!card && /^bob#/.test(card.handle ?? '') && !!card.createdTs && !!card.joinedTs, JSON.stringify(card && [card.name, card.handle, card.createdTs, card.joinedTs]))
|
||||
|
||||
// Files, both ways.
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
|
||||
const fileId = await upload('bob', 'dot.png', png, 'image/png')
|
||||
await as('bob', 'POST', `/channels/${channel}/messages`, { content: 'a picture', attachments: [fileId] })
|
||||
const pic = await until('picture heard', async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.body === 'a picture' && m.attachments?.length))
|
||||
if (!pic) console.log('backlog:', JSON.stringify((await rpc('getBacklog', { bufferId: general })).slice(-4).map((m) => [m.body, m.attachments])), 'bob’s view:', JSON.stringify((await as('bob', 'GET', `/channels/${channel}/messages?limit=2`)).map((m) => m.attachments)))
|
||||
check('a picture bob sends arrives as an attachment', !!pic && pic.attachments[0].kind === 'image' && pic.attachments[0].filename === 'dot.png', JSON.stringify(pic?.attachments?.[0]))
|
||||
const fetched = await rpc('restoreMedia', { path: pic.attachments[0].path }).catch((e) => ({ error: e.message }))
|
||||
const local = (fetched.path ?? '').replace('file://', '')
|
||||
check('and is fetched from the file server when looked at', !!local && fs.existsSync(local) && fs.statSync(local).size > 0, JSON.stringify(fetched))
|
||||
const mine2 = path.join(out, 'from-moho.png')
|
||||
fs.writeFileSync(mine2, png)
|
||||
await rpc('sendMessage', { bufferId: general, body: 'a picture from moho', attachmentPaths: [mine2] })
|
||||
const theirs = await until('picture seen', async () => (await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).find((m) => m.content === 'a picture from moho' && m.attachments?.length))
|
||||
check('a picture alice sends is seen by bob', !!theirs && theirs.attachments[0].filename === 'from-moho.png')
|
||||
await rpc('sendMessage', { bufferId: general, body: 'hidden', attachmentPaths: [mine2], spoilerPaths: [mine2] })
|
||||
const spoiled = await until('spoiler seen', async () => (await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).find((m) => m.content === 'hidden' && m.attachments?.length))
|
||||
check('and one marked as a spoiler goes under the name Stoat reads as one', !!spoiled && /^SPOILER_/.test(spoiled.attachments[0].filename))
|
||||
|
||||
// Custom emoji: one made on the server is offered, drawn in what others say, and sent by name.
|
||||
const emojiFile = await upload('alice', 'party.png', png, 'image/png', 'emojis')
|
||||
const emojiName = `party${Date.now().toString(36).slice(-4)}`
|
||||
await as('alice', 'PUT', `/custom/emoji/${emojiFile}`, { name: emojiName, parent: { type: 'Server', id: acc.server.id } })
|
||||
const offered = await until('emoji offered', async () => {
|
||||
const sources = await rpc('listAllEmoji', { bufferId: general })
|
||||
return sources.find((s) => s.service === 'stoat' && s.emoji.some((e) => e.name === emojiName))
|
||||
})
|
||||
check('a custom emoji made on the server is offered in the picker under the server’s name', !!offered && offered.name === 'Test Hall', JSON.stringify(offered && [offered.name, offered.emoji.length, offered.usableHere]))
|
||||
await as('bob', 'POST', `/channels/${channel}/messages`, { content: `yay :${emojiFile}:` })
|
||||
const drawn = await until('emoji heard', async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.body.startsWith(`yay [stoat:emoji:${emojiName}:`)))
|
||||
check('and one in what bob says is a picture token naming the cache', !!drawn && new RegExp(`^yay \\[stoat:emoji:${emojiName}:file://.*/stoat-media/[0-9a-f]+\\.png\\]$`).test(drawn.body), drawn?.body)
|
||||
await rpc('sendMessage', { bufferId: general, body: `back :${emojiName}: !!and a secret!!` })
|
||||
const said = await until('emoji seen', async () => (await as('bob', 'GET', `/channels/${channel}/messages?limit=10`)).find((m) => (m.content ?? '').startsWith('back :')))
|
||||
check('what alice types by name goes out as the emoji’s id', said?.content === `back :${emojiFile}: !!and a secret!!`, said?.content)
|
||||
|
||||
// Moderation: roles, a timeout, removing and barring, and an invite to the server.
|
||||
const powers = await rpc('getStoatPowers', { bufferId: general })
|
||||
check('the owner may kick, ban, silence and hand out roles', powers.canKick && powers.canBan && powers.canMute && powers.canAssignRoles, JSON.stringify(powers))
|
||||
const role = await as('alice', 'POST', `/servers/${acc.server.id}/roles`, { name: `Helpers ${Date.now().toString(36).slice(-3)}` })
|
||||
await until('role heard', async () => (await rpc('getStoatPowers', { bufferId: general })).roles.some((r) => r.id === role.id))
|
||||
check('a role made on the server is one the menu can give', true)
|
||||
await rpc('setStoatMemberRole', { bufferId: general, userId: acc.bob.id, roleId: role.id, give: true })
|
||||
check('giving it reaches bob', (await as('alice', 'GET', `/servers/${acc.server.id}/members/${acc.bob.id}`)).roles.includes(role.id))
|
||||
await rpc('setStoatMemberRole', { bufferId: general, userId: acc.bob.id, roleId: role.id, give: false })
|
||||
check('and taking it away does too', !(await as('alice', 'GET', `/servers/${acc.server.id}/members/${acc.bob.id}`)).roles.includes(role.id))
|
||||
await rpc('moderateStoatMember', { bufferId: general, userId: acc.bob.id, action: 'mute', minutes: 5 })
|
||||
check('a timeout is put on bob', !!(await as('alice', 'GET', `/servers/${acc.server.id}/members/${acc.bob.id}`)).timeout)
|
||||
await rpc('moderateStoatMember', { bufferId: general, userId: acc.bob.id, action: 'unmute' })
|
||||
check('and lifted', !(await as('alice', 'GET', `/servers/${acc.server.id}/members/${acc.bob.id}`)).timeout)
|
||||
const link = await rpc('createStoatInvite', { bufferId: general })
|
||||
check('an invite link is made, on the instance’s own web app', /^http:\/\/localhost:8880\/invite\/[A-Za-z0-9]+$/.test(link.link), link.link)
|
||||
await stranger.call('POST', `/invites/${link.link.split('/').pop()}`, {})
|
||||
check('and the stranger joins by it', (await stranger.call('GET', `/servers/${acc.server.id}/members/${stranger.id}`))._id.user === stranger.id)
|
||||
await rpc('moderateStoatMember', { bufferId: general, userId: stranger.id, action: 'kick' })
|
||||
check('kicking removes the stranger', await stranger.call('GET', `/servers/${acc.server.id}/members/${stranger.id}`).then(() => false, () => true))
|
||||
await stranger.call('POST', `/invites/${link.link.split('/').pop()}`, {})
|
||||
await rpc('moderateStoatMember', { bufferId: general, userId: stranger.id, action: 'ban', reason: 'probe' })
|
||||
check('barring does too, and the stranger cannot come back', await stranger.call('POST', `/invites/${link.link.split('/').pop()}`, {}).then(() => false, () => true))
|
||||
await rpc('moderateStoatMember', { bufferId: general, userId: stranger.id, action: 'unban' })
|
||||
|
||||
// A conversation with bob, both ways.
|
||||
const opened = await rpc('openDirectMessage', { accountId: account, userId: acc.bob.id, nick: 'bob' })
|
||||
const dm = opened.bufferId ?? opened
|
||||
check('a direct message with bob opens', typeof dm === 'string' && dm.includes('|bob'), JSON.stringify(opened))
|
||||
const dmChannel = (await as('bob', 'GET', `/users/${acc.alice.id}/dm`))._id
|
||||
await as('bob', 'POST', `/channels/${dmChannel}/messages`, { content: 'psst, it is bob' })
|
||||
const whisper = await until('dm heard', async () => (await rpc('getBacklog', { bufferId: dm })).find((m) => m.body === 'psst, it is bob'))
|
||||
check('what bob says in it is heard', !!whisper)
|
||||
await rpc('sendMessage', { bufferId: dm, body: 'psst back' })
|
||||
const back = await until('dm seen', async () => (await as('bob', 'GET', `/channels/${dmChannel}/messages?limit=5`)).find((m) => m.content === 'psst back'))
|
||||
check('and what alice says reaches bob', !!back)
|
||||
|
||||
// Blocking, and being let through again.
|
||||
await rpc('stoatBlock', { accountId: account, userId: acc.bob.id, block: true })
|
||||
const locked = await until('locked', async () => (await rpc('listBuffers')).find((b) => b.id === dm)?.readOnly)
|
||||
check('blocking bob closes the conversation to writing', !!locked, locked)
|
||||
await rpc('stoatBlock', { accountId: account, userId: acc.bob.id, block: false })
|
||||
const stillShut = await until('shut', async () => (await rpc('listBuffers')).find((b) => b.id === dm)?.readOnly)
|
||||
check('and unblocking does not make them friends again: it stays shut, with the reason', /friends/.test(stillShut ?? ''), stillShut)
|
||||
await as('alice', 'PUT', `/users/${acc.bob.id}/friend`)
|
||||
await as('bob', 'PUT', `/users/${acc.alice.id}/friend`)
|
||||
const open = await until('open again', async () => !(await rpc('listBuffers')).find((b) => b.id === dm)?.readOnly)
|
||||
check('and being friends again opens it', !!open)
|
||||
|
||||
// Somebody asks to be a friend.
|
||||
await stranger.call('PUT', `/users/${acc.alice.id}/friend`)
|
||||
const asked = await until('request', async () => (await rpc('stoatFriendRequests', { accountId: account })).incoming.find((r) => r.id === stranger.id))
|
||||
check('a friend request is listed with the name of who asked', !!asked && asked.name === stranger.username, JSON.stringify(asked))
|
||||
await rpc('answerStoatFriendRequest', { accountId: account, userId: stranger.id, accept: true })
|
||||
const friends = await until('friends', async () => (await stranger.call('GET', `/users/${acc.alice.id}`)).relationship === 'Friend')
|
||||
check('accepting makes them friends', !!friends)
|
||||
check('and they are no longer waiting', !(await rpc('stoatFriendRequests', { accountId: account })).incoming.some((r) => r.id === stranger.id))
|
||||
|
||||
// Another server, joined by its invite and left.
|
||||
const hall = await stranger.call('POST', '/servers/create', { name: 'Stranger Den' })
|
||||
const invite = await stranger.call('POST', `/channels/${hall.channels[0]._id}/invites`, {})
|
||||
await rpc('joinBuffer', { accountId: account, name: `http://localhost:8880/invite/${invite._id}` })
|
||||
const joined = await until('joined', async () => (await rpc('listBuffers')).find((b) => b.accountId === account && /^Stranger Den\//.test(b.name)))
|
||||
check('a server is joined by its invite link and its channels are made', !!joined, joined?.name)
|
||||
await rpc('leaveGroup', { groupId: `${account}|server:${hall.server._id}` })
|
||||
const left = await until('left', async () => !(await rpc('listBuffers')).some((b) => /^Stranger Den\//.test(b.name)))
|
||||
check('and leaving it takes it away', !!left)
|
||||
const ownLeft = await rpc('leaveGroup', { groupId: `${account}|server:${acc.server.id}` }).then(() => 'left', (e) => e.message)
|
||||
check('a server the account owns is not left, since that deletes it', /own this server/.test(ownLeft), ownLeft)
|
||||
|
||||
await ev(`window.__mohoShots.store.refreshBuffers(); window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
|
||||
await sleep(800)
|
||||
await shot('stoat-local.png')
|
||||
} catch (e) {
|
||||
crashed = true
|
||||
console.log('CRASHED:', e.message)
|
||||
} finally {
|
||||
await stop()
|
||||
console.log(results.every(Boolean) && !crashed ? 'ALL OK' : 'SOME FAILED')
|
||||
process.exitCode = results.every(Boolean) && !crashed ? 0 : 1
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* A call between two moho windows on the local self-hosted Stoat: alice and bob each sign in, join the voice room with a
|
||||
* (fake) camera and microphone, and see each other - tiles, video arriving, who the daemon says is in the call - and
|
||||
* then one shares a screen.
|
||||
*
|
||||
* node scripts/probe-stoat-voice.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
import { accounts, API } from './lib/stoat-local.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const acc = accounts()
|
||||
const FAKE = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']
|
||||
let crashed = false
|
||||
const results = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, detail)
|
||||
}
|
||||
const until = async (fn, ms = 20000) => {
|
||||
const t = Date.now()
|
||||
while (Date.now() - t < ms) {
|
||||
try {
|
||||
const v = await fn()
|
||||
if (v) return v
|
||||
} catch {
|
||||
/* not yet */
|
||||
}
|
||||
await sleep(300)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function person(who) {
|
||||
const app = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: FAKE })
|
||||
const ev = (code) => app.page.evaluate(code)
|
||||
const rpc = (method, params = {}) => ev(`window.moho.rpc(${JSON.stringify(method)}, ${JSON.stringify(params)})`)
|
||||
await sleep(2500)
|
||||
await app.page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await rpc('addStoatAccountToken', { instance: API, token: acc[who].token })
|
||||
const account = `stoat:${acc[who].id}`
|
||||
await until(async () => (await rpc('listAccounts')).find((a) => a.id === account && a.state === 'connected'))
|
||||
await ev(`window.__mohoShots.store.refreshAccounts(); window.__mohoShots.store.refreshBuffers()`)
|
||||
const room = `${account}|Test Hall/#Voice Room`
|
||||
await until(async () => (await rpc('listBuffers')).some((b) => b.id === room))
|
||||
await ev(`window.__mohoShots.store.refreshBuffers(); window.__mohoShots.store.selectBuffer(${JSON.stringify(room)})`)
|
||||
const shot = async (name) => {
|
||||
const { data } = await app.page.call('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
|
||||
}
|
||||
return { ...app, ev, rpc, account, room, shot }
|
||||
}
|
||||
|
||||
const apps = []
|
||||
try {
|
||||
const a = await person('alice')
|
||||
apps.push(a)
|
||||
const b = await person('bob')
|
||||
apps.push(b)
|
||||
check('both are in the room', !!a.room && !!b.room)
|
||||
|
||||
await a.ev(`window.__mohoShots.store.joinStoatCall(${JSON.stringify(a.room)}, true)`)
|
||||
const aUp = await until(async () => (await a.ev(`window.__mohoShots.state().activeCall?.phase`)) === 'connected')
|
||||
check('alice joins the voice room', !!aUp)
|
||||
await b.ev(`window.__mohoShots.store.joinStoatCall(${JSON.stringify(b.room)}, true)`)
|
||||
const bUp = await until(async () => (await b.ev(`window.__mohoShots.state().activeCall?.phase`)) === 'connected')
|
||||
check('bob joins it', !!bUp)
|
||||
|
||||
const named = await until(async () => {
|
||||
const who = await a.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(a.room)}] ?? []).map((m) => m.name).sort().join(',')`)
|
||||
return who === 'alice,bob' ? who : null
|
||||
})
|
||||
check('the daemon says both are in the call', !!named)
|
||||
const tiles = await until(async () => {
|
||||
const t = await a.ev(`window.__mohoShots.store.callTiles().map((t) => ({ id: t.id, label: t.label, hasVideo: t.hasVideo }))`)
|
||||
return t.length === 2 && t.some((x) => x.label === 'bob') ? t : null
|
||||
})
|
||||
check('alice has a tile for bob', !!tiles, JSON.stringify(tiles))
|
||||
const arriving = await until(async () => {
|
||||
const r = await a.ev(`window.__mohoShots.store.callArrivals()`)
|
||||
return r.length > 0 && r.some((x) => x.packets > 0) ? r : null
|
||||
})
|
||||
check('and what bob sends arrives', !!arriving, JSON.stringify(arriving))
|
||||
const arrivingB = await until(async () => {
|
||||
const r = await b.ev(`window.__mohoShots.store.callArrivals()`)
|
||||
return r.some((x) => x.packets > 0) ? r : null
|
||||
})
|
||||
check('and what alice sends reaches bob', !!arrivingB)
|
||||
const video = await until(async () => (await a.ev(`window.__mohoShots.store.callTiles().find((t) => t.label === 'bob')?.hasVideo`)) === true)
|
||||
check('bob’s picture is video', !!video)
|
||||
await a.shot('stoat-call-alice.png')
|
||||
|
||||
// The camera and the microphone, from the buttons' own methods.
|
||||
await a.ev(`window.__mohoShots.store.toggleCallMute()`)
|
||||
check('alice mutes', await until(async () => (await a.ev(`window.__mohoShots.state().activeCall?.muted`)) === true))
|
||||
const mutedForBob = await until(async () => (await b.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(b.room)}] ?? []).find((m) => m.name === 'alice')?.muted`)) === true)
|
||||
check('and the daemon tells bob she is muted', !!mutedForBob)
|
||||
await a.ev(`window.__mohoShots.store.toggleCamera()`)
|
||||
check('alice turns her camera off', await until(async () => (await a.ev(`window.__mohoShots.state().activeCall?.cameraOn`)) === false))
|
||||
const camOff = await until(async () => (await b.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(b.room)}] ?? []).find((m) => m.name === 'alice')?.camera`)) === false)
|
||||
check('and bob is told', !!camOff)
|
||||
|
||||
// A screen.
|
||||
await a.ev(`window.__mohoShots.store.toggleScreenShare().catch(() => {})`)
|
||||
const sources = await until(async () => a.ev(`window.__mohoShots.state().screenSources`), 8000)
|
||||
if (sources?.length) await a.ev(`window.__mohoShots.store.chooseScreenSource(${JSON.stringify({ id: sources[0].id, name: sources[0].name })})`)
|
||||
const sharing = await until(async () => (await a.ev(`window.__mohoShots.state().activeCall?.sharingScreen`)) === true, 12000)
|
||||
check('alice shares a screen', !!sharing, sources ? `picked ${sources[0]?.name}` : 'no picker offered')
|
||||
if (sharing) {
|
||||
const told = await until(async () => (await b.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(b.room)}] ?? []).find((m) => m.name === 'alice')?.screen`)) === true)
|
||||
check('and bob is told it is a screen', !!told)
|
||||
}
|
||||
await b.shot('stoat-call-bob.png')
|
||||
|
||||
await a.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
|
||||
const left = await until(async () => (await b.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(b.room)}] ?? []).map((m) => m.name).join(',')`)) === 'bob')
|
||||
check('when alice hangs up, bob is alone in it', !!left)
|
||||
await b.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
|
||||
} catch (e) {
|
||||
crashed = true
|
||||
console.log('CRASHED:', e.message)
|
||||
} finally {
|
||||
for (const app of apps) await app.stop()
|
||||
console.log(results.every(Boolean) && !crashed ? 'ALL OK' : 'SOME FAILED')
|
||||
process.exitCode = results.every(Boolean) && !crashed ? 0 : 1
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- A small animal's head as a plain silhouette: a stand-in until Stoat's own mark is put here. -->
|
||||
<path fill="#fff" d="M5 3c-1.7 0-3 1.3-3 3 0 1.3.8 2.4 1.9 2.8C3.3 9.9 3 11 3 12.2 3 16.5 7 20 12 20s9-3.5 9-7.8c0-1.2-.3-2.3-.9-3.4C21.2 8.4 22 7.3 22 6c0-1.7-1.3-3-3-3-1.5 0-2.7 1-3 2.4A9.9 9.9 0 0 0 12 5c-1.3 0-2.6.2-4 .4C7.700 4 6.500 3 5 3zm3.300 8.200a1.200 1.200 0 1 1 0 2.400 1.200 1.200 0 0 1 0-2.400zm7.400 0a1.200 1.200 0 1 1 0 2.400 1.200 1.200 0 0 1 0-2.400zM12 14.500c.8 0 1.500.5 1.500 1.100 0 .5-.7 1-1.500 1s-1.500-.5-1.500-1c0-.6.700-1.100 1.500-1.100z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 670 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', 'telegram'] as const
|
||||
const FORMS = ['irc', 'discord', 'sneedchat', 'matrix', 'kick', 'steam', 'twitch', 'telegram', 'stoat'] as const
|
||||
type AddableService = (typeof FORMS)[number]
|
||||
|
||||
/** What `listProtocols` answers with. */
|
||||
@@ -139,6 +139,7 @@ export function AccountsPanel(): JSX.Element {
|
||||
{adding === 'steam' && <SteamForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'twitch' && <TwitchForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'telegram' && <TelegramForm onDone={() => setAdding(null)} />}
|
||||
{adding === 'stoat' && <StoatForm onDone={() => setAdding(null)} />}
|
||||
{adding && (
|
||||
<button type="button" className="button subtle small" onClick={() => setAdding(null)}>
|
||||
Cancel
|
||||
@@ -1755,6 +1756,225 @@ function TelegramForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding a Stoat account (formerly Revolt): an email and password, then the code of its authenticator app or a recovery
|
||||
* code if the account has a second factor; or a session token pasted from another app.
|
||||
*
|
||||
* Stoat can be hosted by anybody, so the instance can be named; left empty it is the official one. Nothing typed here
|
||||
* is kept but the session Stoat issues, and moho does not make accounts: that is done in Stoat's own app, where its
|
||||
* captcha and email check are.
|
||||
*/
|
||||
function StoatForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [instance, setInstance] = useState('')
|
||||
const [method, setMethod] = useState<'password' | 'token'>('password')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [useTor, setUseTor] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loginId, setLoginId] = useState('')
|
||||
const [detail, setDetail] = useState('')
|
||||
const [needs, setNeeds] = useState<{ methods: 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; methods?: string[]; error?: string; success?: boolean }
|
||||
if (!e.event.startsWith('stoatLogin')) return
|
||||
if (current.current && d.loginId !== current.current) return
|
||||
if (e.event === 'stoatLoginStatus') {
|
||||
setDetail(d.text || '')
|
||||
} else if (e.event === 'stoatLoginNeeds') {
|
||||
setNeeds({ methods: d.methods ?? [], error: d.error ?? undefined })
|
||||
setTyped('')
|
||||
setDetail('')
|
||||
} else if (e.event === 'stoatLoginResult') {
|
||||
setNeeds(null)
|
||||
setId('')
|
||||
setBusy(false)
|
||||
setDetail('')
|
||||
if (d.success) {
|
||||
setPassword('')
|
||||
setToken('')
|
||||
void store.refreshAccounts()
|
||||
onDone()
|
||||
} else {
|
||||
store.toast('error', d.error || 'Stoat 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('cancelStoatLogin', { loginId: current.current }).catch(() => {})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const begin = (): void => {
|
||||
setBusy(true)
|
||||
setDetail('')
|
||||
setNeeds(null)
|
||||
current.current = ''
|
||||
const call =
|
||||
method === 'password'
|
||||
? window.moho.rpc<{ loginId: string }>('addStoatAccount', { instance: instance.trim(), email: email.trim(), password, useTor })
|
||||
: window.moho.rpc<{ loginId: string }>('addStoatAccountToken', { instance: instance.trim(), token: token.trim(), useTor })
|
||||
void call
|
||||
.then((r) => setId(r.loginId))
|
||||
.catch((e: Error) => {
|
||||
setBusy(false)
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const send = (): void => {
|
||||
const value = typed.trim()
|
||||
if (!value || !loginId || !needs) return
|
||||
void window.moho
|
||||
.rpc('submitStoatCode', { loginId, value })
|
||||
.then(() => setDetail('checking'))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
if (current.current) void window.moho.rpc('cancelStoatLogin', { loginId: current.current }).catch(() => {})
|
||||
setId('')
|
||||
setNeeds(null)
|
||||
setDetail('')
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
if (needs) {
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p>This account has a second factor. Type the six-digit code from your authenticator app, or a recovery code.</p>
|
||||
<label className="field">
|
||||
<span className="small muted">Code</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
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}>
|
||||
Submit code
|
||||
</button>
|
||||
<button type="button" className="button" onClick={cancel}>
|
||||
Cancel sign-in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ready = method === 'password' ? email.trim() !== '' && password !== '' : token.trim() !== ''
|
||||
return (
|
||||
<div className="add-form">
|
||||
<p className="small muted">
|
||||
Sign in to your Stoat (formerly Revolt) account. Making an account, and answering its captcha or email check, is
|
||||
done in Stoat's own app first. moho keeps only the session Stoat issues, never the password.
|
||||
</p>
|
||||
<TorSwitch service="stoat" on={useTor} onChange={setUseTor} />
|
||||
<label className="field">
|
||||
<span className="small muted">Instance (leave empty for the official one, stoat.chat)</span>
|
||||
<input
|
||||
className="text-field"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="https://api.stoat.chat"
|
||||
value={instance}
|
||||
disabled={busy}
|
||||
onChange={(e) => setInstance(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="segmented">
|
||||
<button type="button" className={method === 'password' ? 'active' : undefined} disabled={busy} onClick={() => setMethod('password')}>
|
||||
Email and password
|
||||
</button>
|
||||
<button type="button" className={method === 'token' ? 'active' : undefined} disabled={busy} onClick={() => setMethod('token')}>
|
||||
Session token
|
||||
</button>
|
||||
</div>
|
||||
{method === 'password' ? (
|
||||
<div className="field-row">
|
||||
<label className="field">
|
||||
<span className="small muted">Email</span>
|
||||
<input className="text-field" type="email" autoComplete="off" spellCheck={false} value={email} disabled={busy} onChange={(e) => setEmail(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="small muted">Password</span>
|
||||
<input
|
||||
className="text-field"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={password}
|
||||
disabled={busy}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && ready && !busy) begin()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span className="small muted">Session token</span>
|
||||
<input
|
||||
className="text-field"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={token}
|
||||
disabled={busy}
|
||||
onChange={(e) => setToken(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…' : 'Sign in'}
|
||||
</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.
|
||||
*
|
||||
|
||||
@@ -1151,7 +1151,7 @@ function BufferRow({
|
||||
// Calling from the row it belongs to, as well as from the header of the
|
||||
// conversation once it is open - the list is where you look for somebody
|
||||
// you want to reach, so it is where reaching them should be offered.
|
||||
const canCall = (account?.service === 'discord' || account?.service === 'steam') && buffer.kind === 'dm'
|
||||
const canCall = (account?.service === 'discord' || account?.service === 'steam' || account?.service === 'stoat') && buffer.kind === 'dm'
|
||||
|
||||
// The same entries the header's nameplate opens - see lib/buffermenu.ts for
|
||||
// why they live there rather than here.
|
||||
|
||||
@@ -355,6 +355,49 @@ export function RoomCallBar({ bufferId }: { bufferId: string }): JSX.Element | n
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Stoat conversation's call, and the way into it.
|
||||
*
|
||||
* A voice room, a direct message and a group can each be called into; who is in the call is said by the daemon as
|
||||
* they come and go, so a room says who is talking before anybody joins. The call itself is held on a media server and
|
||||
* drawn by the same stage every other call is.
|
||||
*/
|
||||
export function StoatCallBar({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const buffer = useChat((s) => s.buffers).find((b) => b.id === bufferId)
|
||||
const members = useChat((s) => s.stoatVoice[bufferId]) ?? []
|
||||
const activeCall = useChat((s) => s.activeCall)
|
||||
if (!buffer?.accountId.startsWith('stoat:')) return null
|
||||
const callable = buffer.nameIcon === 'volume_up' || buffer.kind === 'dm' || buffer.category === 'Groups'
|
||||
if (!callable) return null
|
||||
const inIt = activeCall?.bufferId === bufferId
|
||||
// A conversation nobody is calling in has no bar: a direct message is called from its header, and a voice room has
|
||||
// its own entry beside the box.
|
||||
if (members.length === 0 && buffer.nameIcon !== 'volume_up') return null
|
||||
const who =
|
||||
members.length === 0
|
||||
? 'Nobody is in the voice room yet'
|
||||
: members.length === 1
|
||||
? `${members[0].name} is in the call`
|
||||
: `${members.length} people are in the call: ${members.map((m) => m.name).join(', ')}`
|
||||
return (
|
||||
<div className="room-call-bar">
|
||||
<Icon name={members.some((m) => m.camera || m.screen) ? 'videocam' : 'volume_up'} size={16} />
|
||||
<span className="small ellipsis">{who}</span>
|
||||
{!inIt && (
|
||||
<>
|
||||
<button type="button" className="button" onClick={() => void store.joinStoatCall(bufferId, false)}>
|
||||
Join voice
|
||||
</button>
|
||||
<button type="button" className="button" onClick={() => void store.joinStoatCall(bufferId, true)}>
|
||||
Join with video
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which screen or window to show the room.
|
||||
*
|
||||
|
||||
@@ -276,7 +276,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 === 'telegram'
|
||||
service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc' || service === 'steam' || service === 'telegram' || service === 'stoat'
|
||||
|
||||
// Starting a reply puts the cursor in the box: the next thing anybody does
|
||||
// is write the answer.
|
||||
@@ -1302,7 +1302,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!text &&
|
||||
(service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'telegram')
|
||||
(service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'telegram' || service === 'stoat')
|
||||
) {
|
||||
const last = store.lastEditableOwn(buffer.id)
|
||||
if (last) {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
filed: !!assignment[buffer.id],
|
||||
inCall: voiceSessions.some((s) => s.bufferId === buffer.id),
|
||||
poppedOut: popouts.open.includes(buffer.id),
|
||||
canCall: (account?.service === 'discord' || account?.service === 'steam') && buffer.kind === 'dm',
|
||||
canCall: (account?.service === 'discord' || account?.service === 'steam' || account?.service === 'stoat') && buffer.kind === 'dm',
|
||||
live,
|
||||
watched,
|
||||
autojoins: store.autojoins(buffer.accountId, name),
|
||||
|
||||
@@ -953,6 +953,18 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stoat's calls are held on a media server and drawn by the same stage as Matrix's: a direct message, a group or a
|
||||
voice room is joined, with the camera as a second choice. */}
|
||||
{buffer.accountId.startsWith('stoat:') && (buffer.kind === 'dm' || buffer.category === 'Groups' || buffer.nameIcon === 'volume_up') && matrixCall?.bufferId !== buffer.id && !matrixCall && (
|
||||
<>
|
||||
<IconButton name="call" title="Join the call" onClick={() => void store.joinStoatCall(buffer.id, false)} />
|
||||
<IconButton name="videocam" title="Join the call with video" onClick={() => void store.joinStoatCall(buffer.id, true)} />
|
||||
</>
|
||||
)}
|
||||
{buffer.accountId.startsWith('stoat:') && matrixCall?.bufferId === buffer.id && (
|
||||
<IconButton name="call_end" title="Hang up" className="calling" onClick={() => store.hangUpMatrixCall()} />
|
||||
)}
|
||||
|
||||
{(isDiscord || isSteam) && isDm && (
|
||||
<IconButton
|
||||
name={inCall ? 'call_end' : 'call'}
|
||||
|
||||
@@ -78,6 +78,8 @@ export function JoinPanel(): JSX.Element {
|
||||
return <TwitchJoin account={account} />
|
||||
case 'telegram':
|
||||
return <TelegramJoin account={account} />
|
||||
case 'stoat':
|
||||
return <StoatJoin account={account} />
|
||||
case 'sneedchat':
|
||||
return <SneedchatRooms account={account} />
|
||||
case 'steam':
|
||||
@@ -86,7 +88,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', 'telegram'].includes(a.service))
|
||||
const joinable = all.filter((a) => ['irc', 'matrix', 'discord', 'kick', 'sneedchat', 'steam', 'twitch', 'telegram', 'stoat'].includes(a.service))
|
||||
|
||||
return (
|
||||
<div className="join-frame">
|
||||
@@ -382,6 +384,58 @@ function TwitchJoin({ account }: { account: Account }): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/** Joining a Stoat server by its invite, and asking somebody to be a friend. */
|
||||
function StoatJoin({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [friend, setFriend] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const connected = account.state === 'connected'
|
||||
const ask = (): void => {
|
||||
const typed = friend.trim()
|
||||
if (!typed) return
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc('addStoatFriend', { accountId: account.id, username: typed })
|
||||
.then(() => {
|
||||
setFriend('')
|
||||
store.toast('info', `Friend request sent to ${typed}`)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
return (
|
||||
<div className="panel join-panel">
|
||||
<SubmitField
|
||||
label="Join a server"
|
||||
placeholder="An invite link, or its code"
|
||||
onSubmit={(code) => void store.joinAndOpen(account.id, code)}
|
||||
/>
|
||||
<span className="small muted">Add a friend</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="Their name and number, like name#1234"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={friend}
|
||||
disabled={!connected || busy}
|
||||
onChange={(e) => setFriend(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') ask()
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="button" disabled={!connected || busy || !friend.trim()} onClick={ask}>
|
||||
Add friend
|
||||
</button>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
Somebody who asks to be your friend waits as a tile in the server list, where you answer. Closing a server
|
||||
from its menu leaves it, unless you own it.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TelegramJoin({ account }: { account: Account }): JSX.Element {
|
||||
const store = useStore()
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MessageRow, useRowContext, type MessageMode } from './MessageRow'
|
||||
import { LiveCards } from './LiveCards'
|
||||
import { PinnedBar } from './PinnedBar'
|
||||
import { CallView } from './CallView'
|
||||
import { CallStage, RoomCallBar } from './CallStage'
|
||||
import { CallStage, RoomCallBar, StoatCallBar } from './CallStage'
|
||||
import { StreamStage } from './StreamStage'
|
||||
import { ServiceBanner } from './ServiceBanner'
|
||||
import { ConversationNotice } from './ConversationNotice'
|
||||
@@ -810,6 +810,7 @@ export function MessageList(): JSX.Element {
|
||||
{/* And a call already happening here, which nobody was invited to
|
||||
because a group call has no invitation - only people in it. */}
|
||||
<RoomCallBar bufferId={bufferId} />
|
||||
<StoatCallBar bufferId={bufferId} />
|
||||
{/* And a Kick stream in the same place, for the same reason: the picture
|
||||
and the chat about it are one conversation. Never both at once - the
|
||||
store refuses to start a stream during a call. */}
|
||||
|
||||
@@ -423,12 +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' || service === 'telegram')
|
||||
!!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'telegram' || service === 'stoat')
|
||||
const beginEdit = (): void => {
|
||||
setDraft(message.body)
|
||||
setEditing(true)
|
||||
}
|
||||
const canReact = service === 'discord' || service === 'matrix' || service === 'steam' || service === 'telegram'
|
||||
const canReact = service === 'discord' || service === 'matrix' || service === 'steam' || service === 'telegram' || service === 'stoat'
|
||||
// Steam's stickers, asked for when the reaction picker opens: it reacts with them too.
|
||||
const [steamStickers, setSteamStickers] = useState<StickerEntry[]>([])
|
||||
useEffect(() => {
|
||||
|
||||
@@ -172,9 +172,9 @@ export function NickList(): JSX.Element {
|
||||
const [discordPowers, setDiscordPowers] = useState<DiscordPowers | null>(null)
|
||||
useEffect(() => {
|
||||
setDiscordPowers(null)
|
||||
if (account?.service !== 'discord' || !buffer) return
|
||||
if ((account?.service !== 'discord' && account?.service !== 'stoat') || !buffer) return
|
||||
void window.moho
|
||||
.rpc<DiscordPowers>('getDiscordPowers', { bufferId: buffer.id })
|
||||
.rpc<DiscordPowers>(account.service === 'stoat' ? 'getStoatPowers' : 'getDiscordPowers', { bufferId: buffer.id })
|
||||
.then(setDiscordPowers)
|
||||
.catch(() => {
|
||||
/* advisory only: no answer means no moderation entries */
|
||||
@@ -206,7 +206,7 @@ export function NickList(): JSX.Element {
|
||||
? ircPermissions(members, account.currentNick)
|
||||
: account?.service === 'kick'
|
||||
? kickPermissions(members, account.currentNick)
|
||||
: account?.service === 'discord'
|
||||
: account?.service === 'discord' || account?.service === 'stoat'
|
||||
? discordPowers ?? {}
|
||||
: (buffer && permissions[buffer.id]) || {}
|
||||
|
||||
@@ -407,6 +407,14 @@ export function NickList(): JSX.Element {
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
return
|
||||
}
|
||||
if (account.service === 'stoat') {
|
||||
if (action !== 'kick' && action !== 'ban' && action !== 'mute') return
|
||||
void window.moho
|
||||
.rpc('moderateStoatMember', { bufferId: buffer.id, userId: member.userId, action, minutes: 10 })
|
||||
.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 === 'discord') {
|
||||
// Discord's own three, and "mute" is a timeout there -
|
||||
// the same word this menu uses everywhere, meaning what
|
||||
@@ -442,7 +450,7 @@ export function NickList(): JSX.Element {
|
||||
onSetRole={(roleId, give) => {
|
||||
if (!buffer) return
|
||||
void window.moho
|
||||
.rpc('setDiscordMemberRole', {
|
||||
.rpc(account?.service === 'stoat' ? 'setStoatMemberRole' : 'setDiscordMemberRole', {
|
||||
bufferId: buffer.id,
|
||||
userId: member.userId || member.nick,
|
||||
roleId,
|
||||
@@ -655,7 +663,7 @@ function MemberRow({
|
||||
// Discord's roles, offered as entries rather than a submenu: a guild has
|
||||
// a handful worth giving by hand, and the rest are managed by bots and
|
||||
// were filtered out before they got here.
|
||||
...(service === 'discord' && perms.canAssignRoles && (perms.roles?.length ?? 0) > 0
|
||||
...((service === 'discord' || service === 'stoat') && perms.canAssignRoles && (perms.roles?.length ?? 0) > 0
|
||||
? ([
|
||||
{ separator: true },
|
||||
...(perms.roles ?? []).slice(0, 12).map((role) => ({
|
||||
|
||||
@@ -75,6 +75,8 @@ interface TileProps {
|
||||
onToggleMute: () => void
|
||||
/** Absent where there is nothing to leave - an account's own entry. */
|
||||
onLeave?: () => void
|
||||
/** Makes an invite link to the server and copies it. Stoat. */
|
||||
onInvite?: () => void
|
||||
/** Only a Matrix space has rooms to look through. */
|
||||
onBrowse?: () => void
|
||||
/** Only a Steam chat group keeps settings of its own: rooms, roles, invite links. */
|
||||
@@ -288,6 +290,15 @@ function RailTile(props: TileProps): JSX.Element {
|
||||
} as const
|
||||
]
|
||||
: []),
|
||||
...(props.onInvite
|
||||
? [
|
||||
{
|
||||
label: 'Copy invite link',
|
||||
icon: 'link',
|
||||
onClick: props.onInvite
|
||||
} as const
|
||||
]
|
||||
: []),
|
||||
...(props.onSettings
|
||||
? [
|
||||
{
|
||||
@@ -575,6 +586,18 @@ export function ServerRail(): JSX.Element | null {
|
||||
g.kind === 'guild' || g.kind === 'space' ? () => setLeaving(g) : undefined
|
||||
}
|
||||
onBrowse={g.kind === 'space' ? () => setBrowsing(g) : undefined}
|
||||
onInvite={
|
||||
g.service === 'stoat' && g.kind === 'guild'
|
||||
? () => {
|
||||
const inside = buffers.find((b) => b.groupId === g.id)
|
||||
if (!inside) return
|
||||
void window.moho
|
||||
.rpc<{ link: string }>('createStoatInvite', { bufferId: inside.id })
|
||||
.then((r) => window.moho.copyText(r.link).then(() => store.toast('info', 'Invite link copied')))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSettings={steamGroupOfRail(g.id) ? () => setConfiguring(g) : undefined}
|
||||
unread={t?.unread ?? 0}
|
||||
highlight={t?.highlight ?? false}
|
||||
|
||||
@@ -131,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' || service === 'telegram'
|
||||
const markdown = service === 'discord' || service === 'matrix' || service === 'telegram' || service === 'stoat'
|
||||
|
||||
if (service === 'sneedchat') {
|
||||
switch (kind) {
|
||||
@@ -175,9 +175,9 @@ export function wrap(service: string | undefined, kind: FormatKind, inner: strin
|
||||
return marked(open, close)
|
||||
}
|
||||
case 'underline': return service === 'discord' || service === 'telegram' ? marked('__') : inner
|
||||
case 'spoiler': return service === 'discord' || service === 'telegram' ? marked('||') : inner
|
||||
case 'spoiler': return service === 'discord' || service === 'telegram' ? marked('||') : service === 'stoat' ? marked('!!') : inner
|
||||
case 'quote': {
|
||||
if (service !== 'discord' && service !== 'telegram') return inner
|
||||
if (service !== 'discord' && service !== 'telegram' && service !== 'stoat') 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}`
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
sneedGreentext,
|
||||
sneedSameText,
|
||||
sourcePick,
|
||||
stoatSameText,
|
||||
stripCodeBlocks,
|
||||
stripEmbeddedUrls,
|
||||
stripQuoteBlocks,
|
||||
@@ -388,3 +389,27 @@ describe('what a picker section sends back', () => {
|
||||
expect(sourcePick('steam', { id: 'steam:steambored' })).toBe('[steam:emote:steambored]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stoat text', () => {
|
||||
it('a spoiler typed with !! is the one logged with ||', () => {
|
||||
expect(stoatSameText('the !!end!! is near', 'the ||end|| is near')).toBe(true)
|
||||
expect(stoatSameText('plain', 'plain ')).toBe(true)
|
||||
expect(stoatSameText('one', 'two')).toBe(false)
|
||||
})
|
||||
|
||||
it('a custom emoji typed by name is the picture token logged', () => {
|
||||
expect(stoatSameText('yay :party:', 'yay [stoat:emoji:party:file:///home/me/.cache/nobilis/stoat-media/abc.png]')).toBe(true)
|
||||
expect(stoatSameText('yay :other:', 'yay [stoat:emoji:party:file:///home/me/.cache/nobilis/stoat-media/abc.png]')).toBe(false)
|
||||
})
|
||||
|
||||
it('draws a custom emoji from the daemon’s own cache folder and from nowhere else', () => {
|
||||
const own = formatMessage('hi [stoat:emoji:party:file:///home/me/.cache/nobilis/stoat-media/abc.png]')
|
||||
expect(own).toContain('<img src="file:///home/me/.cache/nobilis/stoat-media/abc.png" class="custom-emoji" alt=":party:">')
|
||||
const forged = formatMessage('hi [stoat:emoji:x:file:///etc/passwd]')
|
||||
expect(forged).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('is picked by name between colons', () => {
|
||||
expect(sourcePick('stoat', { id: '01ABC', name: 'party' })).toBe(':party:')
|
||||
})
|
||||
})
|
||||
@@ -178,6 +178,8 @@ export function sourcePick(service: string, e: { id: string; name?: string; anim
|
||||
// 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}>`
|
||||
// Stoat's is typed by name between colons; the daemon turns it into the id Stoat reads as it goes out.
|
||||
if (service === 'stoat') return `:${e.name || e.id}:`
|
||||
return e.id
|
||||
}
|
||||
|
||||
@@ -196,6 +198,19 @@ export function sneedSameText(sent: string, logged: string): boolean {
|
||||
return sent.trim() === sneedGreentext(logged).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether what was typed into a Stoat conversation is what Stoat logged.
|
||||
*
|
||||
* Not spelled alike when the message holds a spoiler (`!!x!!` is typed and Stoat's marks are carried here as `||x||`) or a custom
|
||||
* emoji (typed `:party:`, logged as the daemon's picture token): matched strictly, the message sat grey until it timed out as
|
||||
* failed, with its own echo shown beside it.
|
||||
*/
|
||||
export function stoatSameText(sent: string, logged: string): boolean {
|
||||
const sentForm = sent.replace(/!!([^\n]+?)!!/g, '||$1||').trim()
|
||||
const loggedForm = logged.replace(/\[stoat:emoji:([A-Za-z0-9_]+):file:\/\/[^\]\s]*\]/g, ':$1:').trim()
|
||||
return sentForm === loggedForm
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether what was typed into a Steam conversation is what Steam logged.
|
||||
*
|
||||
@@ -603,6 +618,12 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
stow(`<img src="${discordEmojiUrl(id, 48)}" class="custom-emoji" alt=":${name}:">`)
|
||||
)
|
||||
|
||||
// Stoat's custom emoji, which the daemon writes as `[stoat:emoji:name:file://...]`: the picture is a file it has named in
|
||||
// its own cache (and fetches when this draws it), and only that folder is accepted, so a message cannot name another.
|
||||
out = out.replace(/\[stoat:emoji:([A-Za-z0-9_]{1,32}):(file:\/\/[^\]\s]*\/stoat-media\/[A-Za-z0-9._-]+)\]/g, (_m, name: string, src: string) =>
|
||||
stow(`<img src="${src}" class="custom-emoji" alt=":${name}:">`)
|
||||
)
|
||||
|
||||
// Kick emotes, which arrive as `[emote:1082364:xqcAM]`. Like Discord's
|
||||
// above, no lookup is needed: the id is in the token and the picture is
|
||||
// public, so this draws every emote in a channel - including the
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
import stoatSvg from '../assets/stoat-mark.svg'
|
||||
|
||||
/**
|
||||
* Discord channel buffers are named "GuildName/#channel" internally - that's
|
||||
@@ -63,7 +64,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' || service === 'telegram'
|
||||
return service === 'irc' || service === 'discord' || service === 'matrix' || service === 'twitch' || service === 'telegram' || service === 'stoat'
|
||||
}
|
||||
|
||||
export function serviceIcon(service: string): ServiceIcon {
|
||||
@@ -78,6 +79,8 @@ export function serviceIcon(service: string): ServiceIcon {
|
||||
return { mark: twitchSvg }
|
||||
case 'telegram':
|
||||
return { mark: telegramSvg }
|
||||
case 'stoat':
|
||||
return { mark: stoatSvg }
|
||||
case 'irc':
|
||||
return { glyph: 'tag' }
|
||||
case 'slack':
|
||||
@@ -115,6 +118,8 @@ export function serviceLabel(service: string): string {
|
||||
return 'Twitch'
|
||||
case 'telegram':
|
||||
return 'Telegram'
|
||||
case 'stoat':
|
||||
return 'Stoat'
|
||||
case 'slack':
|
||||
return 'Slack'
|
||||
case 'jabber':
|
||||
|
||||
@@ -26,7 +26,7 @@ import { addToast, type ToastAction, type ToastItem } from '../lib/toasts'
|
||||
import { ALL_STATUSES, statusesFor, supportsStatus } from '../lib/status'
|
||||
import type { SteamGroup, GroupCall } from '../lib/steamgroups'
|
||||
import type { TelegramFolder } from '../lib/telegramfolders'
|
||||
import { buildSmilieIndex, sneedSameText, steamSameText, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import { buildSmilieIndex, sneedSameText, steamSameText, stoatSameText, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { runExport } from '../lib/exporter'
|
||||
import type { ForumPost } from '../lib/forum'
|
||||
@@ -35,6 +35,7 @@ import { listInvitations, type Invitation } from '../lib/invitations'
|
||||
import { ircNetworkFor } from '../lib/networks'
|
||||
import { parseDeepLink, type IrcLink } from '../../../shared/deeplink'
|
||||
import { MatrixCalls, type CallMember, type MatrixCallEvent, type RingingCall } from '../lib/matrixcall'
|
||||
import { SfuCall, type SfuParticipant } from '../lib/livekit'
|
||||
import type { CallPhase } from '../lib/webrtc'
|
||||
import { Levels } from '../lib/levels'
|
||||
import { firstFrame, SCREEN_CHOICE_MS } from '../lib/capture'
|
||||
@@ -701,6 +702,8 @@ export interface ChatState {
|
||||
discordCameras: Record<string, true>
|
||||
/** The stream being watched, if any. One at a time, deliberately. */
|
||||
discordWatching: { accountId: string; streamKey: string; userId: string; nick: string } | null
|
||||
/** Who is in each Stoat conversation's call, by buffer, as the daemon last said. */
|
||||
stoatVoice: Record<string, StoatVoiceMember[]>
|
||||
activeCall: {
|
||||
accountId: string
|
||||
bufferId: string
|
||||
@@ -776,6 +779,16 @@ function personToRequest(p: SteamPerson): FriendRequest {
|
||||
return { id: p.steamid, name: p.name, avatarUrl: p.avatarUrl }
|
||||
}
|
||||
|
||||
/** One person in a Stoat call. */
|
||||
export interface StoatVoiceMember {
|
||||
userId: string
|
||||
name: string
|
||||
avatarUrl?: string | null
|
||||
camera: boolean
|
||||
screen: boolean
|
||||
muted: boolean
|
||||
}
|
||||
|
||||
export type Toast = ToastItem
|
||||
|
||||
const INITIAL: ChatState = {
|
||||
@@ -850,6 +863,7 @@ const INITIAL: ChatState = {
|
||||
dismissedLiveEvents: [],
|
||||
eventsPane: null,
|
||||
eventCreate: null,
|
||||
stoatVoice: {},
|
||||
activeCall: null,
|
||||
discordSharing: null,
|
||||
discordShareStream: null,
|
||||
@@ -910,6 +924,7 @@ function echoMatches(echo: ChatMessage, real: Message): boolean {
|
||||
if (echo.pendingBody === real.body) return true
|
||||
if (real.bufferId.startsWith('steam:') && echo.pendingBody !== undefined && steamSameText(echo.pendingBody, real.body)) return true
|
||||
if (real.bufferId.startsWith('sneedchat:') && echo.pendingBody !== undefined && sneedSameText(echo.pendingBody, real.body)) return true
|
||||
if (real.bufferId.startsWith('stoat:') && echo.pendingBody !== undefined && stoatSameText(echo.pendingBody, real.body)) return true
|
||||
// A file is posted as markup the daemon builds - a BBCode image tag on
|
||||
// Sneedchat, a bare link on IRC - so what comes back is never what was
|
||||
// typed, and usually nothing was typed at all. Matching on the whole body
|
||||
@@ -1137,6 +1152,8 @@ export class ChatStore {
|
||||
private toastSeq = 0
|
||||
/** Who is talking in a call this window is holding. */
|
||||
private levels = new Levels()
|
||||
/** The Stoat call this window is in, which is held on a media server like Element's. */
|
||||
private stoatCall: { sfu: SfuCall; bufferId: string; people: SfuParticipant[]; local: MediaStream | null } | null = null
|
||||
/**
|
||||
* The Discord screen share, while one is running.
|
||||
*
|
||||
@@ -1567,6 +1584,15 @@ export class ChatStore {
|
||||
// Not connected yet, or an older daemon: nothing to answer.
|
||||
}
|
||||
}
|
||||
// Stoat's are the people whose relationship to this account is "incoming": the daemon lists them.
|
||||
for (const account of this.state.accounts.filter((a) => a.service === 'stoat' && a.state === 'connected')) {
|
||||
try {
|
||||
const r = await window.moho.rpc<{ incoming: FriendRequest[] }>('stoatFriendRequests', { accountId: account.id })
|
||||
next[account.id] = r.incoming
|
||||
} catch {
|
||||
// Not connected yet: the daemon's own push fills it in.
|
||||
}
|
||||
}
|
||||
this.set({ friendRequests: next })
|
||||
// Discord keeps its requests among its friends; the daemon pushes changes,
|
||||
// and this is the first reading, so one that arrived while moho was
|
||||
@@ -1615,6 +1641,8 @@ export class ChatStore {
|
||||
} else if (invitation.kind === 'friend') {
|
||||
if (invitation.service === 'discord') {
|
||||
await window.moho.rpc('answerDiscordFriendRequest', { accountId, userId: id, accept })
|
||||
} else if (invitation.service === 'stoat') {
|
||||
await window.moho.rpc('answerStoatFriendRequest', { accountId, userId: id, accept })
|
||||
} else {
|
||||
await window.moho.rpc('steamAnswerFriendRequest', { accountId, steamid: id, accept })
|
||||
}
|
||||
@@ -2190,6 +2218,11 @@ export class ChatStore {
|
||||
* resolves it to the channel the call happens in.
|
||||
*/
|
||||
async callBuffer(bufferId: string): Promise<void> {
|
||||
// A Stoat conversation is called into on its media server, with the camera off until it is asked for.
|
||||
if (this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('stoat:')) {
|
||||
await this.joinStoatCall(bufferId, false)
|
||||
return
|
||||
}
|
||||
if (this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('steam:')) {
|
||||
await this.callSteamFriend(bufferId)
|
||||
return
|
||||
@@ -2248,6 +2281,18 @@ export class ChatStore {
|
||||
return
|
||||
}
|
||||
|
||||
// A Stoat invite joins the server, in the first Stoat account there is; with none, it is a web page for the browser.
|
||||
if (link.service === 'stoat') {
|
||||
const account = this.state.accounts.find((a) => a.service === 'stoat' && a.state === 'connected') ?? this.state.accounts.find((a) => a.service === 'stoat')
|
||||
if (!account) {
|
||||
void window.moho.openExternal(link.url)
|
||||
return
|
||||
}
|
||||
this.set({ activePanel: '' })
|
||||
void this.joinAndOpen(account.id, link.url)
|
||||
return
|
||||
}
|
||||
|
||||
// A Telegram link goes to wherever it points: the chat, joined if the account is not in it, and the post.
|
||||
if (link.service === 'telegram') {
|
||||
void this.openTelegramLink(link.url)
|
||||
@@ -2852,6 +2897,14 @@ export class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks who is in a Stoat account's calls, for when it has just connected: the daemon says the changes after. */
|
||||
loadStoatVoice(accountId: string): void {
|
||||
void window.moho
|
||||
.rpc<Record<string, StoatVoiceMember[]>>('listStoatVoice', { accountId })
|
||||
.then((voice) => this.set({ stoatVoice: { ...this.state.stoatVoice, ...voice } }))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Asks what folders a Telegram account has, for when it has just connected. */
|
||||
loadTelegramFolders(accountId: string): void {
|
||||
void window.moho
|
||||
@@ -3180,6 +3233,7 @@ export class ChatStore {
|
||||
/** What is actually arriving in a call held on a media server, if one is
|
||||
* up: a check that the media is readable rather than merely connected. */
|
||||
async callArrivals(): Promise<unknown> {
|
||||
if (this.stoatCall) return this.stoatCall.sfu.arriving()
|
||||
const group = this.matrixCalls.currentGroup
|
||||
return group?.sfu ? group.sfu.arriving() : []
|
||||
}
|
||||
@@ -3215,7 +3269,77 @@ export class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the call in a Stoat conversation, starting one if nobody is in it.
|
||||
*
|
||||
* Stoat holds calls on a LiveKit server, as Element does, so the window is the client: the daemon asks Stoat for the
|
||||
* way in and the rest is the same machinery a Matrix call on a media server runs on - the tiles, the buttons and
|
||||
* the screen picker are the ones every other call has.
|
||||
*/
|
||||
async joinStoatCall(bufferId: string, video: boolean): Promise<void> {
|
||||
const account = this.accountFor(bufferId)
|
||||
if (!account || account.service !== 'stoat') return
|
||||
if (this.state.activeCall || this.stoatCall) {
|
||||
this.toast('info', 'Leave the call you are in first')
|
||||
return
|
||||
}
|
||||
this.set({
|
||||
callMinimized: false,
|
||||
activeCall: { accountId: account.id, bufferId, phase: 'connecting', video, muted: false, sharingScreen: false, cameraOn: video, onServer: true }
|
||||
})
|
||||
try {
|
||||
const way = await window.moho.rpc<{ token: string; url: string }>('joinStoatCall', { bufferId })
|
||||
let wasConnected = false
|
||||
const sfu = new SfuCall(
|
||||
{
|
||||
onParticipants: (people) => {
|
||||
if (this.stoatCall) this.stoatCall.people = people
|
||||
this.set({})
|
||||
},
|
||||
onConnected: (connected) => {
|
||||
if (!this.stoatCall) return
|
||||
// Connecting is the room's first state, and not being connected: only losing a connection that was made
|
||||
// ends the call.
|
||||
if (connected) wasConnected = true
|
||||
if (!connected && wasConnected && this.state.activeCall?.bufferId === bufferId) {
|
||||
this.stoatCall = null
|
||||
this.levels.dispose()
|
||||
this.set({ activeCall: null })
|
||||
return
|
||||
}
|
||||
if (this.state.activeCall) this.set({ activeCall: { ...this.state.activeCall, phase: connected ? 'connected' : 'connecting' } })
|
||||
},
|
||||
onError: (message) => this.toast('error', message)
|
||||
},
|
||||
false
|
||||
)
|
||||
this.stoatCall = { sfu, bufferId, people: [], local: null }
|
||||
await sfu.join(way.url, way.token, video)
|
||||
if (this.stoatCall) this.stoatCall.local = sfu.local
|
||||
const current = this.state.activeCall as ChatState['activeCall']
|
||||
if (current) this.set({ activeCall: { ...current, cameraOn: sfu.cameraOn } })
|
||||
} catch (e) {
|
||||
this.stoatCall?.sfu.leave().catch(() => {})
|
||||
this.stoatCall = null
|
||||
this.set({ activeCall: null })
|
||||
this.toast('error', `Couldn't join the call: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Somebody's name in a Stoat call: the daemon's, which knows people the roster has not listed. */
|
||||
private stoatCallName(bufferId: string, userId: string): string {
|
||||
return this.state.stoatVoice[bufferId]?.find((m) => m.userId === userId)?.name ?? this.matrixDisplayName(bufferId, userId)
|
||||
}
|
||||
|
||||
hangUpMatrixCall(): void {
|
||||
if (this.stoatCall) {
|
||||
const call = this.stoatCall
|
||||
this.stoatCall = null
|
||||
void call.sfu.leave().catch(() => {})
|
||||
this.levels.dispose()
|
||||
this.set({ activeCall: null })
|
||||
return
|
||||
}
|
||||
// Whichever kind is up: leaving a room's call withdraws the membership
|
||||
// that says this end is in it, as well as hanging up on everybody.
|
||||
if (this.matrixCalls.currentGroup) {
|
||||
@@ -3335,6 +3459,33 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
callTiles(): CallTile[] {
|
||||
// A call on a Stoat server: this end and everybody the media server sends.
|
||||
const stoat = this.stoatCall
|
||||
if (stoat) {
|
||||
this.levels.watch('self', stoat.sfu.local)
|
||||
const tiles: CallTile[] = [
|
||||
{
|
||||
id: 'self',
|
||||
label: this.ownNameIn(stoat.bufferId) || 'You',
|
||||
stream: stoat.sfu.local,
|
||||
hasVideo: !!stoat.sfu.local?.getVideoTracks().some((t) => t.readyState === 'live' && !t.muted),
|
||||
self: true,
|
||||
muted: !!this.state.activeCall?.muted,
|
||||
speaking: !this.state.activeCall?.muted && this.levels.speaking('self')
|
||||
}
|
||||
]
|
||||
for (const person of stoat.people) {
|
||||
this.levels.watch(person.key, person.stream)
|
||||
tiles.push({
|
||||
id: person.key,
|
||||
label: this.stoatCallName(stoat.bufferId, person.userId),
|
||||
stream: person.stream,
|
||||
hasVideo: person.hasVideo,
|
||||
speaking: this.levels.speaking(person.key)
|
||||
})
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
// A call between everybody in a room: one tile for this end and one per
|
||||
// person answered, which is what the fitted grid was built for.
|
||||
const group = this.matrixCalls.currentGroup
|
||||
@@ -3429,6 +3580,12 @@ export class ChatStore {
|
||||
if (!this.state.activeCall) return
|
||||
// Three kinds of call reach this button and the button does not care:
|
||||
// one to one, a mesh in a room, and a room call held on a media server.
|
||||
if (this.stoatCall) {
|
||||
void this.stoatCall.sfu.toggleMute().then((muted) => {
|
||||
if (this.state.activeCall) this.set({ activeCall: { ...this.state.activeCall, muted } })
|
||||
})
|
||||
return
|
||||
}
|
||||
const group = this.matrixCalls.currentGroup
|
||||
if (group?.sfu) {
|
||||
void group.sfu.toggleMute().then((muted) => {
|
||||
@@ -3461,6 +3618,14 @@ export class ChatStore {
|
||||
*/
|
||||
async toggleCamera(): Promise<void> {
|
||||
if (!this.state.activeCall) return
|
||||
if (this.stoatCall) {
|
||||
const on = await this.stoatCall.sfu.toggleCamera().catch((e: Error) => {
|
||||
this.toast('error', `Couldn't turn the camera on: ${e.message}`)
|
||||
return false
|
||||
})
|
||||
if (this.state.activeCall) this.set({ activeCall: { ...this.state.activeCall, cameraOn: on } })
|
||||
return
|
||||
}
|
||||
const want = !this.matrixCalls.cameraOn
|
||||
const on = await this.matrixCalls.setCamera(want).catch((e: Error) => {
|
||||
// The usual failure is a machine with no camera, or one already taken
|
||||
@@ -3494,8 +3659,9 @@ export class ChatStore {
|
||||
// On a media server the screen is opened here, exactly as for the other
|
||||
// two, and handed to LiveKit to publish.
|
||||
const group = this.matrixCalls.currentGroup
|
||||
if (group?.sfu) {
|
||||
const sfu = group.sfu
|
||||
const serverCall = this.stoatCall?.sfu ?? group?.sfu
|
||||
if (serverCall) {
|
||||
const sfu = serverCall
|
||||
try {
|
||||
let sharing: boolean
|
||||
if (sfu.sharingScreen) {
|
||||
@@ -4288,6 +4454,22 @@ export class ChatStore {
|
||||
void this.refreshAccounts()
|
||||
break
|
||||
|
||||
// Stoat says who has asked an account to be their friend, whenever that changes.
|
||||
case 'stoatFriendRequests': {
|
||||
this.set({ friendRequests: { ...this.state.friendRequests, [data.accountId as string]: (data.incoming ?? []) as FriendRequest[] } })
|
||||
break
|
||||
}
|
||||
|
||||
// Who is in a Stoat conversation's call, said as people come and go.
|
||||
case 'stoatVoice': {
|
||||
const members = (data.members ?? []) as StoatVoiceMember[]
|
||||
const next = { ...this.state.stoatVoice }
|
||||
if (members.length > 0) next[data.bufferId as string] = members
|
||||
else delete next[data.bufferId as string]
|
||||
this.set({ stoatVoice: next })
|
||||
break
|
||||
}
|
||||
|
||||
case 'steamFriendRequests': {
|
||||
const incoming = ((data.requests as { incoming?: SteamPerson[] } | undefined)?.incoming ?? []).map(personToRequest)
|
||||
this.set({ friendRequests: { ...this.state.friendRequests, [data.accountId as string]: incoming } })
|
||||
|
||||
@@ -44,6 +44,12 @@ export function useDmCategories(): void {
|
||||
for (const id of connectedTelegram.split(',').filter(Boolean)) store.loadTelegramFolders(id)
|
||||
}, [connectedTelegram, store])
|
||||
|
||||
// And who is in a Stoat account's calls.
|
||||
const connectedStoat = accounts.filter((a) => a.service === 'stoat' && a.state === 'connected').map((a) => a.id).join(',')
|
||||
useEffect(() => {
|
||||
for (const id of connectedStoat.split(',').filter(Boolean)) store.loadStoatVoice(id)
|
||||
}, [connectedStoat, store])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
// The account headings first, then the Steam groups on top of what they
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isDeepLink, isTelegramLink, parseDeepLink } from './deeplink'
|
||||
import { isDeepLink, isTelegramLink, parseDeepLink, stoatInviteCode } from './deeplink'
|
||||
|
||||
describe('a Telegram link', () => {
|
||||
it('is what the View in Telegram button of a post opens, and what a message says', () => {
|
||||
@@ -44,3 +44,17 @@ describe('a Telegram link', () => {
|
||||
expect(parseDeepLink('https://kick.com/xqc')?.service).toBe('kick')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a Stoat invite', () => {
|
||||
it('is read from the official addresses, and only those', () => {
|
||||
expect(stoatInviteCode('https://stoat.chat/invite/gJhyqXTt')).toBe('gJhyqXTt')
|
||||
expect(stoatInviteCode('https://app.stoat.chat/invite/gJhyqXTt/')).toBe('gJhyqXTt')
|
||||
expect(stoatInviteCode('https://rvlt.gg/Abc123')).toBe('Abc123')
|
||||
expect(stoatInviteCode('https://stoat.chat/')).toBeNull()
|
||||
expect(stoatInviteCode('https://example.com/invite/abc')).toBeNull()
|
||||
expect(stoatInviteCode('https://stoat.chat.example.com/invite/abc')).toBeNull()
|
||||
expect(stoatInviteCode('https://stoat.chat/invite/a b')).toBeNull()
|
||||
expect(parseDeepLink('https://stoat.chat/invite/gJhyqXTt')).toEqual({ service: 'stoat', code: 'gJhyqXTt', url: 'https://stoat.chat/invite/gJhyqXTt' })
|
||||
expect(isDeepLink('https://stoat.chat/invite/gJhyqXTt')).toBe(true)
|
||||
})
|
||||
})
|
||||
+32
-2
@@ -36,7 +36,14 @@ export interface TelegramLink {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type DeepLink = IrcLink | KickLink | TelegramLink
|
||||
/** An invitation to a Stoat server: its link in a message, which joins it where the account has one. */
|
||||
export interface StoatInvite {
|
||||
service: 'stoat'
|
||||
code: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type DeepLink = IrcLink | KickLink | TelegramLink | StoatInvite
|
||||
|
||||
/**
|
||||
* Schemes moho asks the desktop to send it.
|
||||
@@ -50,7 +57,28 @@ export const DEEP_LINK_SCHEMES = ['irc', 'ircs', 'tg']
|
||||
/** Whether a string is a link this client would act on. */
|
||||
export function isDeepLink(value: string): boolean {
|
||||
const lower = value.toLowerCase()
|
||||
return DEEP_LINK_SCHEMES.some((s) => lower.startsWith(`${s}:`)) ? isTelegramOrIrc(value) : !!kickHandle(value) || isTelegramLink(value)
|
||||
return DEEP_LINK_SCHEMES.some((s) => lower.startsWith(`${s}:`)) ? isTelegramOrIrc(value) : !!kickHandle(value) || isTelegramLink(value) || !!stoatInviteCode(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The code in a link to a Stoat invite (`stoat.chat/invite/Code`, the same on the web app or Revolt's old names, or the
|
||||
* short `rvlt.gg/Code`), or null. Only the official addresses: a self-hosted instance's own are joined by pasting them
|
||||
* into the join panel, since nothing here can tell its address from any other website's.
|
||||
*/
|
||||
export function stoatInviteCode(value: string): string | null {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value.trim())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!/^https?:$/.test(url.protocol)) return null
|
||||
const host = url.hostname.replace(/^www\./, '').toLowerCase()
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
let code: string | undefined
|
||||
if (['stoat.chat', 'app.stoat.chat', 'revolt.chat', 'app.revolt.chat'].includes(host) && parts.length === 2 && parts[0] === 'invite') code = parts[1]
|
||||
else if (['rvlt.gg', 'stt.gg'].includes(host) && parts.length === 1) code = parts[0]
|
||||
return code && /^[A-Za-z0-9_-]{1,32}$/.test(code) ? code : null
|
||||
}
|
||||
|
||||
/** A `tg:` link is only one of ours if it is a kind that opens a chat; the others (login, proxy, themes) are not. */
|
||||
@@ -143,6 +171,8 @@ export function parseDeepLink(value: string): DeepLink | null {
|
||||
const kick = kickHandle(value)
|
||||
if (kick) return { service: 'kick', handle: kick }
|
||||
if (isTelegramLink(value)) return { service: 'telegram', url: value.trim() }
|
||||
const invite = stoatInviteCode(value)
|
||||
if (invite) return { service: 'stoat', code: invite, url: value.trim() }
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'twitch' | 'telegram' | 'jabber' | 'slack'
|
||||
service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'steam' | 'twitch' | 'telegram' | 'stoat' | 'jabber' | 'slack'
|
||||
displayName: string
|
||||
/**
|
||||
* How the user is presenting: "online" | "idle". Distinct from
|
||||
|
||||
Reference in new issue
Block a user