Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7c4e8767e | ||
|
|
96ea7e7623 | ||
|
|
1731674530 | ||
|
|
e708e428d9 | ||
|
|
df0edf1cf8 | ||
|
|
1d674edfe6 | ||
|
|
0e1e6f38ed | ||
|
|
120fb10134 | ||
|
|
66a680167b | ||
|
|
f4e8136ff3 | ||
|
|
3aeae44130 |
No files matched your search
@@ -1,23 +0,0 @@
|
||||
# On a merge, .gitmodules keeps the answer this branch already has.
|
||||
#
|
||||
# master names the daemon at github.com because master is what a stranger
|
||||
# clones, and development names it at the Gitea instance where the work
|
||||
# happens. Without this, a merge that touched .gitmodules on both sides would
|
||||
# stop and ask which host is right - and the answer is always "the one the
|
||||
# branch being merged into already said".
|
||||
#
|
||||
# `merge=ours` needs a driver by that name, which is one line per clone and
|
||||
# not something a repository can carry for you:
|
||||
#
|
||||
# git config merge.ours.driver true
|
||||
#
|
||||
# Without it git falls back to an ordinary merge, which conflicts rather than
|
||||
# doing the wrong thing quietly - so a clone that has not run it is no worse
|
||||
# off than before this file existed.
|
||||
#
|
||||
# The edge, because it is a real one: this keeps the *whole file*, not just the
|
||||
# URL. If development ever adds a second submodule, master takes none of that
|
||||
# entry while still taking the gitlink the merge brings with it - a submodule
|
||||
# with nothing in .gitmodules to say where it comes from. Adding a submodule is
|
||||
# therefore a thing to re-do by hand on master, and the only such thing.
|
||||
/.gitmodules merge=ours
|
||||
+18
-2
@@ -236,8 +236,24 @@ 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), searching and pins,
|
||||
mutes kept in the account's own settings, the account's own profile and presence, and voice calls on LiveKit
|
||||
through the same client Matrix uses (a call begun in a direct message rings the other, and one begun there rings
|
||||
here). A server's owner or moderators get its settings: channels and their headings, roles and permissions by
|
||||
name, who may do what in one channel, webhooks and the audit log. 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`,
|
||||
`probe-stoat-manage.mjs`).
|
||||
|
||||
### Notifications are cards
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Maintainer: Salastil <[email protected]>
|
||||
pkgname=moho-git
|
||||
pkgver=r1.268751e
|
||||
pkgrel=1
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Matrix, Kick, Sneedchat and Steam"
|
||||
pkgdesc="Unified desktop chat client for IRC, Discord, Sneedchat and Matrix"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Moho-Chat/moho"
|
||||
license=('GPL3')
|
||||
|
||||
+1
-1
Submodule nobilis updated: 9382bc04ea...152b832961.
@@ -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,264 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* moho against the local self-hosted Stoat, signed in as alice (who owns the test server), with bob as the other person:
|
||||
* mutes kept on the account, searching and pins, the account's own profile and status, looking after a server (channels,
|
||||
* headings, roles, permissions, webhooks, the audit log), blocking, a bridge's name marked, and a custom emoji reaction.
|
||||
*
|
||||
* ~/.local/share/moho-dev/stoat-docker.sh start && cd /1tb/stoat-test/selfhosted && DOCKER_HOST=unix:///run/docker-stoat.sock docker compose up -d
|
||||
* npx electron-vite build && node scripts/probe-stoat-manage.mjs <output directory>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
import { accounts, as, upload, API } from './lib/stoat-local.mjs'
|
||||
|
||||
const out = process.argv[2] ?? '.'
|
||||
fs.mkdirSync(out, { recursive: true })
|
||||
const acc = accounts()
|
||||
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 (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
|
||||
}
|
||||
const S = acc.server.id
|
||||
const channelId = acc.server.channels
|
||||
try {
|
||||
await sleep(2500)
|
||||
// Whatever an earlier run left behind.
|
||||
const left = await as('alice', 'GET', `/servers/${S}?include_channels=true`)
|
||||
for (const c of left.channels ?? []) if (/^probe-/.test(c.name)) await as('alice', 'DELETE', `/channels/${c._id}`).catch(() => {})
|
||||
for (const [id, r] of Object.entries(left.roles ?? {})) if (/^Role /.test(r.name)) await as('alice', 'DELETE', `/servers/${S}/roles/${id}`).catch(() => {})
|
||||
await as('alice', 'PATCH', `/servers/${S}`, { categories: (left.categories ?? []).filter((c) => !/^Probe /.test(c.title)) })
|
||||
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}`
|
||||
check('alice connects', !!(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 general = `${account}|Test Hall/#General`
|
||||
const random = `${account}|Test Hall/#random`
|
||||
await until(async () => (await rpc('listBuffers')).some((b) => b.id === general))
|
||||
const muted = async (id) => !!(await rpc("listBuffers")).find((b) => b.id === id)?.serverMuted
|
||||
|
||||
// --- Mutes, kept on the account.
|
||||
await as('alice', 'POST', '/sync/settings/set', { notifications: JSON.stringify({}) })
|
||||
await rpc('setStoatChannelMuted', { bufferId: random, muted: true })
|
||||
check('muting a conversation silences it', !!(await until(async () => (await muted(random)) === true)))
|
||||
const saved = JSON.parse((await as('alice', 'POST', '/sync/settings/fetch', { keys: ['notifications'] })).notifications[1])
|
||||
check('and is kept in the account’s own settings', Object.keys(saved.channel_mutes ?? {}).includes(channelId.random), JSON.stringify(saved))
|
||||
await as('alice', 'POST', '/sync/settings/set', { notifications: JSON.stringify({}) })
|
||||
check('a mute taken off in another app is heard here', !!(await until(async () => (await muted(random)) === false)))
|
||||
|
||||
// --- Search and pins.
|
||||
const sent = await as('bob', 'POST', `/channels/${channelId.General}/messages`, { content: 'the quokka crosses the zebra needle' })
|
||||
await sleep(800)
|
||||
const found = await until(async () => {
|
||||
const r = await rpc('searchStoatMessages', { bufferId: general, query: 'quokka' })
|
||||
return r.results?.find((m) => m.id === sent._id)
|
||||
})
|
||||
check('a word searched for is found on Stoat’s side', !!found, JSON.stringify(found && [found.from, found.body]))
|
||||
await rpc('setPinned', { bufferId: general, messageId: sent._id, pinned: true })
|
||||
const pins = await until(async () => {
|
||||
const r = await rpc('listPinned', { bufferId: general })
|
||||
return r.pinned?.some((m) => m.id === sent._id) ? r.pinned : null
|
||||
})
|
||||
check('a message pinned from here is in the channel’s pins', !!pins)
|
||||
const ctx = await rpc('loadMessageContext', { bufferId: general, messageId: sent._id })
|
||||
check('and the part of the channel around it can be read', ctx.ts > 1_700_000_000 && ctx.messages?.some((m) => m.id === sent._id), JSON.stringify(ctx.ts))
|
||||
await rpc('setPinned', { bufferId: general, messageId: sent._id, pinned: false })
|
||||
check('the pin comes off', !!(await until(async () => (await rpc('listPinned', { bufferId: general })).pinned?.length === 0)))
|
||||
// The window is told of a pin made in another app, and does not ask for ever.
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
|
||||
await as('bob', 'POST', `/channels/${channelId.General}/messages/${sent._id}/pin`, {}).catch(async () => {})
|
||||
await as('alice', 'POST', `/channels/${channelId.General}/messages/${sent._id}/pin`, {})
|
||||
const told = await until(async () => (await ev(`window.__mohoShots.state().pinnedMessages[${JSON.stringify(general)}]`))?.includes(sent._id))
|
||||
check('a pin made elsewhere reaches the window', !!told)
|
||||
await as('alice', 'DELETE', `/channels/${channelId.General}/messages/${sent._id}/pin`)
|
||||
|
||||
// --- The account’s own profile and presence.
|
||||
const before = await rpc('stoatOwnProfile', { accountId: account })
|
||||
await rpc('setStoatProfile', { accountId: account, displayName: 'Alice A', pronouns: 'she, her', bio: 'hello from moho', statusText: 'probing' })
|
||||
const mine = await as('bob', 'GET', `/users/${acc.alice.id}`)
|
||||
check('a changed profile is what others see', mine.display_name === 'Alice A' && mine.status?.text === 'probing' && JSON.stringify(mine.pronouns).includes('she'), JSON.stringify([mine.display_name, mine.status, mine.pronouns]))
|
||||
const bio = await as('bob', 'GET', `/users/${acc.alice.id}/profile`)
|
||||
check('and its about text', bio.content === 'hello from moho')
|
||||
await rpc('setAccountStatus', { accountId: account, status: 'dnd' })
|
||||
check('do not disturb is Stoat’s Busy', !!(await until(async () => (await as('bob', 'GET', `/users/${acc.alice.id}`)).status?.presence === 'Busy')))
|
||||
await rpc('setAccountStatus', { accountId: account, status: 'invisible' })
|
||||
check('and invisible is what Stoat shows as offline, though connected', !!(await until(async () => (await as('bob', 'GET', `/users/${acc.alice.id}`)).online === false)))
|
||||
await rpc('setAccountStatus', { accountId: account, status: 'online' })
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
|
||||
const pic = path.join(out, 'me.png')
|
||||
fs.writeFileSync(pic, png)
|
||||
const answer = await rpc('setStoatProfileAvatar', { accountId: account, path: pic })
|
||||
const withPic = await as('bob', 'GET', `/users/${acc.alice.id}`)
|
||||
check('a picture chosen is put on the account', !!answer.avatarUrl && !!withPic.avatar, JSON.stringify([answer, withPic.avatar]))
|
||||
await rpc('setStoatProfileAvatar', { accountId: account, path: '' })
|
||||
check('and taken off', !!(await until(async () => !(await as('bob', 'GET', `/users/${acc.alice.id}`)).avatar)))
|
||||
await rpc('setStoatProfile', { accountId: account, displayName: before.displayName, pronouns: before.pronouns, bio: before.bio, statusText: before.statusText })
|
||||
|
||||
// --- Looking after the server.
|
||||
const o = await rpc('stoatManage', { bufferId: general, action: 'overview' })
|
||||
check('the server is described to its owner with everything they may do', o.name === 'Test Hall' && o.can.owner && o.can.channels && o.can.roles && o.can.audit && o.channels.length >= 4 && o.permissions.length > 20, JSON.stringify(o.can))
|
||||
const tag = Date.now().toString(36).slice(-4)
|
||||
await rpc('stoatManage', { bufferId: general, action: 'createChannel', name: `probe-${tag}`, voice: false })
|
||||
const made = await until(async () => (await rpc('stoatManage', { bufferId: general, action: 'overview' })).channels.find((c) => c.name === `probe-${tag}`))
|
||||
check('a channel can be made', !!made)
|
||||
await rpc('stoatManage', { bufferId: general, action: 'editChannel', channelId: made.id, description: 'a topic', nsfw: true, slowmode: 5 })
|
||||
const edited = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).channels.find((c) => c.id === made.id)
|
||||
check('and edited: topic, age restriction, slowmode', edited.description === 'a topic' && edited.nsfw && edited.slowmode === 5, JSON.stringify([edited.description, edited.nsfw, edited.slowmode]))
|
||||
const after = (await rpc('listBuffers')).find((b) => b.remoteId === made.id)
|
||||
check('the new channel is a conversation in moho, with its topic', !!after, JSON.stringify(after && [after.name, after.topic]))
|
||||
const heading = `H${tag}`.toUpperCase()
|
||||
const current = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).categories
|
||||
await rpc('stoatManage', { bufferId: general, action: 'setCategories', categories: [...current.map((c) => ({ ...c, channels: c.channels.filter((x) => x !== made.id) })), { id: heading, title: `Probe ${tag}`, channels: [made.id] }] })
|
||||
const filed = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).channels.find((c) => c.id === made.id)
|
||||
check('a heading can be made with the channel under it', filed.category === heading)
|
||||
await rpc('stoatManage', { bufferId: general, action: 'createRole', name: `Role ${tag}` })
|
||||
const role = await until(async () => (await rpc('stoatManage', { bufferId: general, action: 'overview' })).roles.find((r) => r.name === `Role ${tag}`))
|
||||
check('a role can be made', !!role)
|
||||
await rpc('stoatManage', { bufferId: general, action: 'editRole', roleId: role.id, colour: '#ff8800', hoist: true })
|
||||
await rpc('stoatManage', { bufferId: general, action: 'setRolePermissions', roleId: role.id, allow: ['SendMessage', 'ViewAuditLogs'], deny: ['React'] })
|
||||
const r2 = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).roles.find((r) => r.id === role.id)
|
||||
check('with a colour, listed apart, and what it allows and denies (a permission high above 32 bits too)', r2.colour === '#ff8800' && r2.hoist && r2.allow.includes('SendMessage') && r2.allow.includes('ViewAuditLogs') && r2.deny.includes('React'), JSON.stringify(r2))
|
||||
await rpc('stoatManage', { bufferId: general, action: 'setChannelPermissions', channelId: made.id, roleId: role.id, allow: ['SendMessage'], deny: [] })
|
||||
await rpc('stoatManage', { bufferId: general, action: 'setChannelPermissions', channelId: made.id, roleId: '', allow: [], deny: ['UploadFiles'] })
|
||||
const c2 = (await rpc('stoatManage', { bufferId: general, action: 'overview' })).channels.find((c) => c.id === made.id)
|
||||
check('who may do what in one channel can be set, for a role and for everybody', c2.roles.some((x) => x.roleId === role.id && x.allow.includes('SendMessage')) && c2.default.deny.includes('UploadFiles'), JSON.stringify([c2.roles, c2.default]))
|
||||
await rpc('stoatManage', { bufferId: general, action: 'reorderRoles', order: (await rpc('stoatManage', { bufferId: general, action: 'overview' })).roles.map((r) => r.id).reverse() })
|
||||
const hooked = await rpc('stoatManage', { bufferId: general, action: 'createWebhook', channelId: made.id, name: 'probe hook' })
|
||||
const hooks = await rpc('stoatManage', { bufferId: general, action: 'webhooks', channelId: made.id })
|
||||
check('a webhook can be made and listed with its address', hooks.webhooks.length === 1 && /\/webhooks\/[A-Z0-9]{26}\/.+/.test(hooks.webhooks[0].url ?? ''), JSON.stringify(hooks.webhooks.map((w) => w.name)))
|
||||
// This version of the server (0.15) has no route to delete one; they go with their channel. The answer is Stoat’s own.
|
||||
const gone = await rpc('stoatManage', { bufferId: general, action: 'deleteWebhook', webhookId: hooks.webhooks[0].id, token: hooks.webhooks[0].token }).then(() => 'deleted', (e) => e.message)
|
||||
console.log(' note deleting a webhook:', String(gone).slice(0, 80))
|
||||
const log = await rpc('stoatManage', { bufferId: general, action: 'auditLog' })
|
||||
check('the audit log says what was done, in words', log.entries.some((e) => /made the channel probe-/.test(e.text)) && log.entries.some((e) => /made the role Role /.test(e.text)), JSON.stringify(log.entries.slice(0, 4).map((e) => e.text)))
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
|
||||
await rpc('stoatManage', { bufferId: general, action: 'deleteChannel', channelId: made.id })
|
||||
await rpc('stoatManage', { bufferId: general, action: 'deleteRole', roleId: role.id })
|
||||
await rpc('stoatManage', { bufferId: general, action: 'setCategories', categories: current })
|
||||
check('the channel and role are deleted again, and the conversation goes', !!(await until(async () => !(await rpc('listBuffers')).some((b) => b.remoteId === made.id))))
|
||||
// A refusal is Stoat’s, in words: bob cannot manage anything.
|
||||
const bobsView = await as('bob', 'GET', `/servers/${S}`)
|
||||
check('the server is one bob is in', bobsView._id === S)
|
||||
|
||||
// --- Blocking, through the ignore the member menu has.
|
||||
await rpc('setIgnored', { accountId: account, target: acc.bob.id, ignored: true })
|
||||
check('ignoring a person on Stoat blocks them on the account', !!(await until(async () => (await as('alice', 'GET', `/users/${acc.bob.id}`)).relationship === 'Blocked')))
|
||||
await rpc('setIgnored', { accountId: account, target: acc.bob.id, ignored: false })
|
||||
await as('alice', 'PUT', `/users/${acc.bob.id}/friend`).catch(() => {})
|
||||
await as('bob', 'PUT', `/users/${acc.alice.id}/friend`).catch(() => {})
|
||||
|
||||
// --- A bridge’s name is marked, and a custom emoji is a picture in a reaction.
|
||||
const relayed = await as('alice', 'POST', `/channels/${channelId.General}/messages`, { content: 'from the bridge', masquerade: { name: 'Relay Ada' } })
|
||||
const marked = await until(async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.id === relayed._id && m.badges?.some((b) => b.type === 'via')))
|
||||
check('a message sent under another name is marked as such', !!marked && marked.from === 'Relay Ada', JSON.stringify(marked && [marked.from, marked.badges]))
|
||||
const emojis = await as('alice', 'GET', `/servers/${S}/emojis`)
|
||||
if (emojis.length) {
|
||||
await as('bob', 'PUT', `/channels/${channelId.General}/messages/${relayed._id}/reactions/${emojis[0]._id}`)
|
||||
const pill = await until(async () => (await rpc('getBacklog', { bufferId: general })).find((m) => m.id === relayed._id)?.reactions?.find((r) => r.emoji.startsWith('[stoat:emoji:')))
|
||||
check('a custom emoji reaction is held as its picture', !!pill, JSON.stringify(pill))
|
||||
await ev(`window.__mohoShots.store.refreshBuffers(); window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
|
||||
await sleep(1200)
|
||||
const drawn = await ev(`!!document.querySelector('.reaction-pill img.reaction-emoji')`)
|
||||
check('and drawn in the pill', drawn)
|
||||
await shot('stoat-reactions.png')
|
||||
if (pill) {
|
||||
await rpc('toggleReaction', { bufferId: general, messageId: relayed._id, emoji: pill.emoji, add: true })
|
||||
check('and reacting with it from its pill reaches Stoat', !!(await until(async () => (await as('bob', 'GET', `/channels/${channelId.General}/messages/${relayed._id}`)).reactions?.[emojis[0]._id]?.includes(acc.alice.id))))
|
||||
}
|
||||
}
|
||||
await as('alice', 'DELETE', `/channels/${channelId.General}/messages/${relayed._id}`).catch(() => {})
|
||||
|
||||
// --- A picture sent as a spoiler is covered until it is clicked.
|
||||
const hiddenFile = await upload('bob', 'SPOILER_dot.png', png, 'image/png')
|
||||
const covered = await as('bob', 'POST', `/channels/${channelId.General}/messages`, { content: 'a hidden one', attachments: [hiddenFile] })
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
|
||||
const cover = await until(async () => ev(`!!document.querySelector('.spoiler-cover')`), 20000)
|
||||
check('a spoilered picture from somebody else is covered in the log', !!cover)
|
||||
await shot('stoat-spoiler.png')
|
||||
await as('bob', 'DELETE', `/channels/${channelId.General}/messages/${covered._id}`).catch(() => {})
|
||||
|
||||
// --- Pictures actually load in the window: a person's avatar in the member list, and the server's icon on the rail.
|
||||
const loaded = (selector) => ev(`[...document.querySelectorAll(${JSON.stringify(selector)})].some((i) => i.complete && i.naturalWidth > 0)`)
|
||||
const avatarId = await upload('bob', 'bob.png', png, 'image/png', 'avatars')
|
||||
await as('bob', 'PATCH', '/users/@me', { avatar: avatarId })
|
||||
await rpc('requestMemberList', { bufferId: general })
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
|
||||
check('a person’s avatar loads in the member list', !!(await until(async () => loaded('.nick-row img'), 20000)))
|
||||
await as('bob', 'PATCH', '/users/@me', { remove: ['Avatar'] })
|
||||
const iconPath = path.join(out, 'icon.png')
|
||||
fs.writeFileSync(iconPath, png)
|
||||
await rpc('stoatManage', { bufferId: general, action: 'editServer', icon: iconPath })
|
||||
check('and the server’s icon loads on the rail', !!(await until(async () => loaded('.rail-tile[aria-label="Test Hall"] img.rail-icon'), 20000)))
|
||||
await shot('stoat-pictures.png')
|
||||
await rpc('stoatManage', { bufferId: general, action: 'editServer', icon: '' })
|
||||
|
||||
// --- The window: the server’s settings opened from its tile on the rail, against the live local server.
|
||||
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(general)})`)
|
||||
await sleep(800)
|
||||
await ev(`document.querySelector('.rail-tile[aria-label="Test Hall"]').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 200 }))`)
|
||||
await sleep(400)
|
||||
const opened = await ev(`(() => { const b = [...document.querySelectorAll('.context-menu-item')].find((x) => /Server settings/.test(x.textContent)); if (b) b.click(); return !!b })()`)
|
||||
check('the rail tile’s menu offers the server’s settings', opened)
|
||||
const modal = (await until(async () => { const t = await ev(`document.querySelector('.stoat-server')?.innerText ?? ''`); return /Overview/.test(t) ? t : null }, 30000)) ?? ''
|
||||
check('which open on the server’s name, with what the owner may do', /Test Hall/.test(modal) && /Overview/.test(modal) && /Roles/.test(modal) && /Audit log/.test(modal), modal.slice(0, 80).replace(/\n/g, ' | '))
|
||||
await shot('stoat-settings-overview.png')
|
||||
const tab = async (name) => { await ev(`[...document.querySelectorAll('.room-settings-tab')].find((t) => t.textContent === ${JSON.stringify(name)}).click()`); await sleep(900) }
|
||||
await tab('Channels')
|
||||
const channelsText = await ev(`document.querySelector('.stoat-server .room-settings-body').innerText`)
|
||||
check('the channels tab lists them', /General/.test(channelsText) && /Voice Room/.test(channelsText), channelsText.slice(0, 80).replace(/\n/g, ' | '))
|
||||
await ev(`[...document.querySelectorAll('.stoat-server button')].find((b) => b.textContent === 'Who may').click()`)
|
||||
await sleep(600)
|
||||
await shot('stoat-settings-channels.png')
|
||||
await tab('Roles')
|
||||
await ev(`[...document.querySelectorAll('.stoat-server button')].filter((b) => b.textContent === 'Edit')[0]?.click()`)
|
||||
await sleep(600)
|
||||
await shot('stoat-settings-roles.png')
|
||||
await tab('Webhooks')
|
||||
await shot('stoat-settings-webhooks.png')
|
||||
await tab('Audit log')
|
||||
await sleep(600)
|
||||
const audit = await ev(`document.querySelector('.stoat-server .room-settings-body').innerText`)
|
||||
check('the audit log tab reads in words', /Alice|alice/.test(audit) && /made|changed|deleted/.test(audit), audit.slice(0, 100).replace(/\n/g, ' | '))
|
||||
await shot('stoat-settings-audit.png')
|
||||
await ev(`document.querySelector('.modal-scrim').click()`)
|
||||
// The account’s own profile, under its card in the Accounts pane.
|
||||
await ev(`window.__mohoShots.store.setActivePanel('accounts')`)
|
||||
await sleep(1200)
|
||||
await ev(`[...document.querySelectorAll('span, i')].filter((e) => e.children.length === 0 && e.textContent.trim() === 'expand_more').at(-1)?.click()`)
|
||||
await sleep(1500)
|
||||
const hasTools = await ev(`!!document.querySelector('.account-tools input[type=text]') || /Your profile on Stoat/.test(document.body.innerText)`)
|
||||
check('the Accounts pane shows the account’s own profile to edit', hasTools)
|
||||
await shot('stoat-account.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,156 @@
|
||||
#!/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.ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(a.room)})`)
|
||||
const listed = await until(async () => (await a.ev(`[...document.querySelectorAll('.bufferlist .voice-member, .voice-member')].map((e) => e.textContent.trim()).join(',')`)).includes('bob'))
|
||||
check('the channel list names who is in the voice room under it', !!listed)
|
||||
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()`)
|
||||
|
||||
// A call begun in a direct message rings the other person's window, who answers it.
|
||||
const dmA = (await a.rpc('openDirectMessage', { accountId: a.account, userId: acc.bob.id })).bufferId
|
||||
const dmB = (await b.rpc('openDirectMessage', { accountId: b.account, userId: acc.alice.id })).bufferId
|
||||
await a.ev(`window.__mohoShots.store.joinStoatCall(${JSON.stringify(dmA)}, false)`)
|
||||
const rung = await until(async () => (await b.ev(`window.__mohoShots.state().incomingCalls.some((c) => c.bufferId === ${JSON.stringify(dmB)} && c.ringing)`)) === true)
|
||||
check('a call alice begins in her direct message with bob rings bob’s window', !!rung)
|
||||
await b.shot('stoat-ring-bob.png')
|
||||
await b.ev(`window.__mohoShots.store.acceptCall(${JSON.stringify(dmB)})`)
|
||||
check('and bob answers it', !!(await until(async () => (await b.ev(`window.__mohoShots.state().activeCall?.phase`)) === 'connected')))
|
||||
check('and it stops ringing', (await b.ev(`window.__mohoShots.state().incomingCalls.some((c) => c.ringing)`)) === false)
|
||||
const together = await until(async () => (await a.ev(`(window.__mohoShots.state().stoatVoice[${JSON.stringify(dmA)}] ?? []).map((m) => m.name).sort().join(',')`)) === 'alice,bob')
|
||||
check('with both in it', !!together)
|
||||
await b.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
|
||||
await a.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
|
||||
// A ring declined stops, and the call carries on without them.
|
||||
await a.ev(`window.__mohoShots.store.joinStoatCall(${JSON.stringify(dmA)}, false)`)
|
||||
await until(async () => (await b.ev(`window.__mohoShots.state().incomingCalls.some((c) => c.ringing)`)) === true)
|
||||
await b.ev(`window.__mohoShots.store.declineCall(${JSON.stringify(dmB)})`)
|
||||
check('a ring turned down stops', (await b.ev(`window.__mohoShots.state().incomingCalls.some((c) => c.ringing)`)) === false)
|
||||
await a.ev(`window.__mohoShots.store.hangUpMatrixCall()`)
|
||||
const quiet = await until(async () => (await b.ev(`window.__mohoShots.state().incomingCalls.length`)) === 0)
|
||||
check('and nothing rings once the caller has gone', !!quiet)
|
||||
} 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
|
||||
}
|
||||
@@ -124,6 +124,8 @@ const DAEMON_MEDIA_DIRS = [
|
||||
'discord-sounds',
|
||||
'telegram-media',
|
||||
'telegram-avatars',
|
||||
'stoat-media',
|
||||
'stoat-avatars',
|
||||
'transient'
|
||||
]
|
||||
|
||||
|
||||
@@ -11356,6 +11356,22 @@ html[data-reduce-motion] *::after {
|
||||
box-shadow: var(--shadow-3);
|
||||
}
|
||||
|
||||
.stoat-server {
|
||||
width: 680px;
|
||||
}
|
||||
|
||||
/* A choice sits at the row's right-hand end and leaves the words their room; a row's buttons are the small kind. */
|
||||
.stoat-server .field-row > select.text-field {
|
||||
flex: 0 0 9rem;
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
.stoat-server .steam-group-row .button,
|
||||
.stoat-server .steam-group-row .confirm-button .button {
|
||||
padding: 2px 10px;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.steam-group-body {
|
||||
padding-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -3,6 +3,7 @@ import { SwitchRow } from './Switch'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon, IconButton, ServiceMark } from './Icon'
|
||||
import { MatrixAccountTools } from './MatrixAccountTools'
|
||||
import { StoatAccountTools } from './StoatAccountTools'
|
||||
import { SteamAccountTools } from './SteamAccountTools'
|
||||
import { TwitchSignIn } from './TwitchSignIn'
|
||||
import { IgnoreList } from './IgnoreList'
|
||||
@@ -25,7 +26,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 +140,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
|
||||
@@ -476,6 +478,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
{account.service === 'matrix' && <MatrixAccountTools account={account} />}
|
||||
|
||||
{account.service === 'steam' && <SteamAccountTools account={account} />}
|
||||
{account.service === 'stoat' && <StoatAccountTools account={account} />}
|
||||
{account.service === 'twitch' && <TwitchAccountTools account={account} />}
|
||||
|
||||
{account.service === 'sneedchat' && (
|
||||
@@ -1755,6 +1758,230 @@ 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>
|
||||
<p className="small muted">
|
||||
What a third-party client does here: it signs in once and keeps that session, shown in Stoat's own list of sessions
|
||||
as “moho (your system)”, and it never signs in again to get over a dropped connection. Ending the session in Stoat signs moho out.
|
||||
It keeps to the instance's limits, tells you when one applies, and does not make accounts, run bots or answer captchas.
|
||||
</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.
|
||||
*
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { bufferLink } from '../lib/bufferlink'
|
||||
import { Icon, IconButton, ServiceMark } from './Icon'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { bufferMenuEntries } from '../lib/buffermenu'
|
||||
import { UserFooter } from './UserFooter'
|
||||
import { VoiceChannels } from './VoiceChannels'
|
||||
import { StoatVoiceWho } from './StoatVoiceWho'
|
||||
import { SteamVoiceRooms } from './SteamVoiceRooms'
|
||||
import { VoicePanel } from './VoicePanel'
|
||||
import { presenceClass, presenceLabel } from '../lib/presence'
|
||||
@@ -343,7 +344,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' : service === 'telegram' ? 'setTelegramChatMuted' : null
|
||||
return service === 'matrix' ? 'setMatrixRoomMuted' : service === 'discord' ? 'setDiscordChannelMuted' : service === 'steam' ? 'steamSetFriendMuted' : service === 'telegram' ? 'setTelegramChatMuted' : service === 'stoat' ? 'setStoatChannelMuted' : null
|
||||
}
|
||||
const isEffectivelyMuted = (buffer: BufferEntry): boolean =>
|
||||
isMutedBuffer(buffer, muted, mutedGroups)
|
||||
@@ -381,8 +382,8 @@ export function BufferList(): JSX.Element {
|
||||
}
|
||||
|
||||
const renderRow = (b: BufferEntry): JSX.Element => (
|
||||
<Fragment key={b.id}>
|
||||
<BufferRow
|
||||
key={b.id}
|
||||
buffer={b}
|
||||
active={b.id === activeBufferId}
|
||||
muted={isEffectivelyMuted(b)}
|
||||
@@ -458,6 +459,9 @@ export function BufferList(): JSX.Element {
|
||||
onDragEnd={endDrag}
|
||||
filed={telegramPage ? !!viewAssignment[b.id] : !!assignment[b.id] && myCategories.some((c) => c.id === assignment[b.id])}
|
||||
/>
|
||||
{/* Who is in a Stoat voice channel, under it. */}
|
||||
{b.accountId.startsWith('stoat:') && b.nameIcon === 'volume_up' && <StoatVoiceWho bufferId={b.id} accountId={b.accountId} />}
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
// What is in flight lives in a ref as well as in state, for the reason the
|
||||
@@ -1151,7 +1155,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.
|
||||
*
|
||||
|
||||
@@ -141,6 +141,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
const [stickerPicker, setStickerPicker] = useState(false)
|
||||
const [gifPicker, setGifPicker] = useState(false)
|
||||
const [discordTyping] = usePref<boolean>('discord.sendTyping', false)
|
||||
const [stoatTyping] = usePref<boolean>('stoat.sendTyping', true)
|
||||
/**
|
||||
* 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
|
||||
@@ -276,7 +277,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.
|
||||
@@ -462,6 +463,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
// 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
|
||||
if (service === 'stoat' && !stoatTyping) return
|
||||
const now = Date.now()
|
||||
if (now - typingSentAt.current < TYPING_REPEAT_MS) return
|
||||
typingSentAt.current = now
|
||||
@@ -1302,7 +1304,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),
|
||||
@@ -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' || account?.service === 'telegram') {
|
||||
if (account?.service === 'matrix' || account?.service === 'steam' || account?.service === 'telegram' || account?.service === 'stoat') {
|
||||
void window.moho
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : account.service === 'telegram' ? 'setTelegramChatMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: !isMuted(buffer.id) })
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : account.service === 'telegram' ? 'setTelegramChatMuted' : account.service === 'stoat' ? 'setStoatChannelMuted' : '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 === 'telegram'
|
||||
account?.service === 'matrix' || account?.service === 'steam' || account?.service === 'telegram' || account?.service === 'stoat'
|
||||
? () =>
|
||||
void window.moho
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : account.service === 'telegram' ? 'setTelegramChatMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.rpc(account.service === 'steam' ? 'steamSetFriendMuted' : account.service === 'telegram' ? 'setTelegramChatMuted' : account.service === 'stoat' ? 'setStoatChannelMuted' : 'setMatrixRoomMuted', { bufferId: buffer.id, muted: false })
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
: undefined,
|
||||
onToggleAutojoin:
|
||||
|
||||
@@ -616,6 +616,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
const filterMenu = suggestions(query)
|
||||
const isMatrix = buffer.accountId.startsWith('matrix:')
|
||||
const isTelegram = buffer.accountId.startsWith('telegram:')
|
||||
const isStoat = buffer.accountId.startsWith('stoat:')
|
||||
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
|
||||
@@ -683,11 +684,11 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
if (filtered) setResults([])
|
||||
})
|
||||
}
|
||||
if (!isMatrix && !isTelegram) return
|
||||
if (!isMatrix && !isTelegram && !isStoat) return
|
||||
// Then the server, which has what this client never downloaded
|
||||
// - including everything said before this account joined.
|
||||
return window.moho
|
||||
.rpc<MatrixSearch>(isTelegram ? 'searchTelegramMessages' : 'searchMatrixMessages', {
|
||||
.rpc<MatrixSearch>(isTelegram ? 'searchTelegramMessages' : isStoat ? 'searchStoatMessages' : 'searchMatrixMessages', {
|
||||
bufferId: buffer.id,
|
||||
query: text,
|
||||
scope,
|
||||
@@ -712,7 +713,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
.finally(() => setSearching(false))
|
||||
}, 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, buffer.id, store, isMatrix, isTelegram, isDiscord, scope])
|
||||
}, [query, buffer.id, store, isMatrix, isTelegram, isStoat, 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
|
||||
@@ -953,6 +954,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'}
|
||||
@@ -981,7 +994,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.accountId.startsWith('telegram:')) && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
|
||||
{(isMatrix || isDiscord || buffer.accountId.startsWith('telegram:') || isStoat) && 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. */}
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -672,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' || service === 'telegram') && !isSystem
|
||||
...((service === 'matrix' || service === 'discord' || service === 'telegram' || service === 'stoat') && !isSystem
|
||||
? ([
|
||||
{
|
||||
label: pinned ? 'Unpin from this conversation' : 'Pin to this conversation',
|
||||
@@ -1810,6 +1810,9 @@ function ReactionEmoji({ emoji }: { emoji: string }): JSX.Element {
|
||||
// Steam's, held as a token naming the emoticon or sticker.
|
||||
const steam = steamTokenOf(emoji)
|
||||
if (steam) return <img className="reaction-emoji" src={steamPictureUrl(steam.kind, steam.name)} alt={steam.name} />
|
||||
// Stoat's, which the daemon holds as the picture's own token (see format.ts) so the pill is drawn with it.
|
||||
const stoat = emoji.match(/^\[stoat:emoji:([A-Za-z0-9_]{1,32}):(file:\/\/[^\]\s]*\/stoat-media\/[A-Za-z0-9._-]+)\]$/)
|
||||
if (stoat) return <img className="reaction-emoji" src={media(stoat[2])} alt={stoat[1]} />
|
||||
const custom = emoji.match(/^<?a?:?([A-Za-z0-9_~]{2,32}):(\d+)>?$/)
|
||||
if (!custom) return <span>{emoji}</span>
|
||||
return <img className="reaction-emoji" src={media(discordEmojiUrl(custom[2]))} alt={custom[1]} />
|
||||
|
||||
@@ -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]) || {}
|
||||
|
||||
@@ -339,6 +339,16 @@ export function NickList(): JSX.Element {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUnfriend={
|
||||
account?.service === 'stoat' && member.relationship === 'Friend' && member.userId
|
||||
? () => {
|
||||
void window.moho
|
||||
.rpc('answerStoatFriendRequest', { accountId: account.id, userId: member.userId, accept: false })
|
||||
.then(() => store.toast('info', `${member.nick} is no longer a friend`))
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onToggleBlock={toggleBlocked}
|
||||
onIgnore={(ignored) => {
|
||||
if (!account) return
|
||||
@@ -407,6 +417,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 +460,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,
|
||||
@@ -546,6 +564,8 @@ interface MemberRowProps {
|
||||
onProfile?: () => void
|
||||
/** Matrix only: tell the homeserver's moderators about this person. */
|
||||
onReport?: (reason: string) => void
|
||||
/** Stoat only: stop being friends with somebody who is one. */
|
||||
onUnfriend?: () => void
|
||||
onCall: () => void
|
||||
}
|
||||
|
||||
@@ -572,6 +592,7 @@ function MemberRow({
|
||||
onCall,
|
||||
onProfile,
|
||||
onReport,
|
||||
onUnfriend,
|
||||
onRemoveFromGroup
|
||||
}: MemberRowProps): JSX.Element {
|
||||
const { menu, open, close } = useContextMenu()
|
||||
@@ -655,7 +676,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) => ({
|
||||
@@ -686,6 +707,7 @@ function MemberRow({
|
||||
// somebody does for themselves; kicking and banning need power here and
|
||||
// reach only this room. This is the one that reaches whoever can act
|
||||
// across all of them, and anybody may send it.
|
||||
...(onUnfriend ? ([{ separator: true }, { label: 'Remove friend', icon: 'person_remove', danger: true, onClick: onUnfriend }] as MenuEntry[]) : []),
|
||||
...(onReport
|
||||
? ([
|
||||
{ separator: true },
|
||||
|
||||
@@ -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:') || account.startsWith('telegram:')
|
||||
const hasPins = account.startsWith('discord:') || account.startsWith('matrix:') || account.startsWith('telegram:') || account.startsWith('stoat:')
|
||||
|
||||
useEffect(() => {
|
||||
setAt(0)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RAIL_TIP, hideRailTip, showRailTip, type RailTipDetail } from '../lib/r
|
||||
import { bufferDisplayName, classes, nickColor, resolveMediaUrl, serviceIcon } from '../lib/util'
|
||||
import { SpaceRooms } from './SpaceRooms'
|
||||
import { SteamGroupSettings } from './SteamGroupSettings'
|
||||
import { StoatServerSettings } from './StoatServerSettings'
|
||||
import { steamGroupOfRail } from '../lib/steamchat'
|
||||
import {
|
||||
isFixedEntry,
|
||||
@@ -75,6 +76,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,10 +291,19 @@ function RailTile(props: TileProps): JSX.Element {
|
||||
} as const
|
||||
]
|
||||
: []),
|
||||
...(props.onInvite
|
||||
? [
|
||||
{
|
||||
label: 'Copy invite link',
|
||||
icon: 'link',
|
||||
onClick: props.onInvite
|
||||
} as const
|
||||
]
|
||||
: []),
|
||||
...(props.onSettings
|
||||
? [
|
||||
{
|
||||
label: 'Group settings',
|
||||
label: props.group.service === 'stoat' ? 'Server settings' : 'Group settings',
|
||||
icon: 'settings',
|
||||
onClick: props.onSettings
|
||||
} as const
|
||||
@@ -423,6 +435,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
const [browsing, setBrowsing] = useState<RailGroup | null>(null)
|
||||
/** The Steam chat group whose settings are open, if any. */
|
||||
const [configuring, setConfiguring] = useState<RailGroup | null>(null)
|
||||
const [stoatSettings, setStoatSettings] = useState<RailGroup | null>(null)
|
||||
/** Hovering the space below the column, which means "put it last". */
|
||||
const [tailTarget, setTailTarget] = useState(false)
|
||||
|
||||
@@ -575,7 +588,19 @@ export function ServerRail(): JSX.Element | null {
|
||||
g.kind === 'guild' || g.kind === 'space' ? () => setLeaving(g) : undefined
|
||||
}
|
||||
onBrowse={g.kind === 'space' ? () => setBrowsing(g) : undefined}
|
||||
onSettings={steamGroupOfRail(g.id) ? () => setConfiguring(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) : g.service === 'stoat' && g.kind === 'guild' ? () => setStoatSettings(g) : undefined}
|
||||
unread={t?.unread ?? 0}
|
||||
highlight={t?.highlight ?? false}
|
||||
mentions={t?.mentions ?? 0}
|
||||
@@ -749,6 +774,7 @@ export function ServerRail(): JSX.Element | null {
|
||||
/>
|
||||
)}
|
||||
|
||||
{stoatSettings && buffers.find((b) => b.groupId === stoatSettings.id) && <StoatServerSettings bufferId={buffers.find((b) => b.groupId === stoatSettings.id)!.id} onClose={() => setStoatSettings(null)} />}
|
||||
{configuring && <SteamGroupSettings accountId={configuring.accountId} railId={configuring.id} onClose={() => setConfiguring(null)} />}
|
||||
{browsing && (
|
||||
<SpaceRooms
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { resolveMediaUrl } from '../lib/util'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
|
||||
/** What the account looks like to everybody on Stoat. */
|
||||
interface OwnProfile {
|
||||
handle: string
|
||||
displayName: string
|
||||
pronouns: string
|
||||
bio: string
|
||||
statusText: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's own profile on Stoat, under its card in the Accounts pane. Separate from the local name moho calls the
|
||||
* account by: this is what every server and conversation shows. Only what was changed is sent.
|
||||
*/
|
||||
export function StoatAccountTools({ account }: { account: Account }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const [profile, setProfile] = useState<OwnProfile | null>(null)
|
||||
const [form, setForm] = useState({ displayName: '', pronouns: '', bio: '', statusText: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = (): void => {
|
||||
void window.moho
|
||||
.rpc<OwnProfile>('stoatOwnProfile', { accountId: account.id })
|
||||
.then((p) => {
|
||||
setProfile(p)
|
||||
setForm({ displayName: p.displayName, pronouns: p.pronouns, bio: p.bio, statusText: p.statusText })
|
||||
})
|
||||
// An account still connecting has no answer yet; the panel shows nothing in its place.
|
||||
.catch(() => setProfile(null))
|
||||
}
|
||||
// Asked again once the account is up, since the first open can be before it is.
|
||||
useEffect(load, [account.id, account.state])
|
||||
|
||||
if (!profile) return null
|
||||
|
||||
const changed = (Object.keys(form) as (keyof typeof form)[]).filter((k) => form[k].trim() !== profile[k].trim())
|
||||
const save = (): void => {
|
||||
setBusy(true)
|
||||
const body: Record<string, string> = {}
|
||||
for (const k of changed) body[k] = form[k]
|
||||
void window.moho
|
||||
.rpc('setStoatProfile', { accountId: account.id, ...body })
|
||||
.then(() => {
|
||||
store.toast('info', 'Profile changed')
|
||||
load()
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
const picture = (path: string): void => {
|
||||
setBusy(true)
|
||||
void window.moho
|
||||
.rpc('setStoatProfileAvatar', { accountId: account.id, path })
|
||||
.then(() => {
|
||||
store.toast('info', path ? 'Picture changed' : 'Picture taken off')
|
||||
load()
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', e.message))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
const field = (key: keyof typeof form, label: string, placeholder = '', area = false): JSX.Element => (
|
||||
<label className="field">
|
||||
<span className="small muted">{label}</span>
|
||||
{area ? (
|
||||
<textarea className="text-field" rows={3} placeholder={placeholder} value={form[key]} disabled={busy} onChange={(e) => setForm({ ...form, [key]: e.target.value })} />
|
||||
) : (
|
||||
<input className="text-field" type="text" placeholder={placeholder} value={form[key]} disabled={busy} onChange={(e) => setForm({ ...form, [key]: e.target.value })} />
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="account-tools">
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>Your profile on Stoat ({profile.handle})</div>
|
||||
<div className="small muted">Changing these changes them for everyone who can see you.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="device-row">
|
||||
{profile.avatarUrl ? <img className="account-avatar" src={resolveMediaUrl(profile.avatarUrl)} alt="" /> : <Icon name="account_circle" size={18} />}
|
||||
<div className="setting-text" />
|
||||
<button type="button" className="button subtle" disabled={busy} onClick={() => void window.moho.pickFile().then((path) => path && picture(path))}>
|
||||
Change picture
|
||||
</button>
|
||||
{profile.avatarUrl && (
|
||||
<button type="button" className="button subtle" disabled={busy} onClick={() => picture('')}>
|
||||
Take it off
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="field-row">
|
||||
{field('displayName', 'Display name', 'Your username is shown if empty')}
|
||||
{field('pronouns', 'Pronouns', 'she, her')}
|
||||
</div>
|
||||
{field('statusText', 'Beside your status', 'What you are up to')}
|
||||
{field('bio', 'About you', 'Shown on your profile card', true)}
|
||||
<div className="field-row">
|
||||
<button type="button" className="button primary" disabled={busy || changed.length === 0} onClick={save}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Modal } from './Modal'
|
||||
import { Icon } from './Icon'
|
||||
import { Switch, SwitchRow } from './Switch'
|
||||
import { ConfirmButton } from './ConfirmButton'
|
||||
import { useStore } from '../state/hooks'
|
||||
import { resolveMediaUrl } from '../lib/util'
|
||||
|
||||
/**
|
||||
* Asks the daemon; or, in a window the screenshot harness started, takes the answer it left in `__rpcFixture`, since
|
||||
* the harness has no Stoat to ask.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
interface Permission {
|
||||
key: string
|
||||
label: string
|
||||
group: string
|
||||
/** Means something in one channel, not only server-wide. */
|
||||
channel: boolean
|
||||
}
|
||||
interface Category {
|
||||
id: string
|
||||
title: string
|
||||
channels: string[]
|
||||
}
|
||||
interface Override {
|
||||
allow: string[]
|
||||
deny: string[]
|
||||
}
|
||||
interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
voice: boolean
|
||||
nsfw: boolean
|
||||
slowmode: number
|
||||
category: string | null
|
||||
roles: ({ roleId: string } & Override)[]
|
||||
default: Override
|
||||
}
|
||||
interface Role extends Override {
|
||||
id: string
|
||||
name: string
|
||||
colour?: string | null
|
||||
hoist: boolean
|
||||
rank: number
|
||||
}
|
||||
interface Overview {
|
||||
serverId: string
|
||||
name: string
|
||||
description: string
|
||||
iconUrl?: string | null
|
||||
can: { owner: boolean; server: boolean; channels: boolean; roles: boolean; permissions: boolean; webhooks: boolean; audit: boolean }
|
||||
categories: Category[]
|
||||
channels: Channel[]
|
||||
roles: Role[]
|
||||
defaultPermissions: string[]
|
||||
permissions: Permission[]
|
||||
limits: { channels: number; roles: number }
|
||||
}
|
||||
interface Webhook {
|
||||
id: string
|
||||
name: string
|
||||
token?: string | null
|
||||
url?: string | null
|
||||
}
|
||||
interface AuditEntry {
|
||||
id: string
|
||||
text: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
type Tab = 'overview' | 'channels' | 'roles' | 'webhooks' | 'audit'
|
||||
type Act = <T>(action: string, extra?: Record<string, unknown>) => Promise<T | undefined>
|
||||
type Stance = 'inherit' | 'allow' | 'deny'
|
||||
|
||||
const SLOWMODES: [number, string][] = [
|
||||
[0, 'Off'],
|
||||
[5, '5 seconds'],
|
||||
[10, '10 seconds'],
|
||||
[30, '30 seconds'],
|
||||
[60, '1 minute'],
|
||||
[300, '5 minutes'],
|
||||
[900, '15 minutes'],
|
||||
[3600, '1 hour'],
|
||||
[21600, '6 hours']
|
||||
]
|
||||
|
||||
/**
|
||||
* Looking after a Stoat server: its name and picture, channels and the headings they sit under, roles and what each may
|
||||
* do, who may do what in one channel, webhooks, and what moderators did.
|
||||
*
|
||||
* Read from the daemon each time it is opened and after every change, so what is shown is what Stoat holds. What the
|
||||
* account may not change is not offered; Stoat checks again and refuses in words if it was wrong about that.
|
||||
*/
|
||||
export function StoatServerSettings({ bufferId, onClose }: { bufferId: string; onClose: () => void }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [server, setServer] = useState<Overview | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('overview')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setServer(await ask<Overview>('stoatManage', { bufferId, action: 'overview' }))
|
||||
}, [bufferId])
|
||||
useEffect(() => {
|
||||
void reload().catch((e: Error) => {
|
||||
store.toast('error', e.message)
|
||||
onClose()
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reload])
|
||||
|
||||
/** One change, then the server read again; the error is Stoat's own. */
|
||||
const act: Act = useCallback(
|
||||
async <T,>(action: string, extra: Record<string, unknown> = {}): Promise<T | undefined> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const done = await ask<T>('stoatManage', { bufferId, action, ...extra })
|
||||
if (action !== 'webhooks' && action !== 'auditLog') await reload()
|
||||
return done
|
||||
} catch (e) {
|
||||
store.toast('error', (e as Error).message)
|
||||
return undefined
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[bufferId, reload, store]
|
||||
)
|
||||
|
||||
if (!server) {
|
||||
return (
|
||||
<Modal title="Server" icon="dns" onClose={onClose} className="steam-group stoat-server">
|
||||
<p className="small muted">
|
||||
<span className="spinner" /> Asking Stoat…
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
const tabs: [Tab, string][] = [
|
||||
['overview', 'Overview'],
|
||||
['channels', 'Channels'],
|
||||
['roles', 'Roles'],
|
||||
['webhooks', 'Webhooks'],
|
||||
['audit', 'Audit log']
|
||||
]
|
||||
return (
|
||||
<Modal title={server.name || 'Server'} icon="dns" onClose={onClose} className="steam-group stoat-server">
|
||||
<div className="room-settings-tabs" role="tablist">
|
||||
{tabs
|
||||
.filter(([id]) => id !== 'audit' || server.can.audit)
|
||||
.map(([id, label]) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} className={`room-settings-tab${tab === id ? ' active' : ''}`} onClick={() => setTab(id)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="room-settings-body steam-group-body">
|
||||
{tab === 'overview' && <Overview server={server} busy={busy} act={act} />}
|
||||
{tab === 'channels' && <Channels server={server} busy={busy} act={act} />}
|
||||
{tab === 'roles' && <Roles server={server} busy={busy} act={act} />}
|
||||
{tab === 'webhooks' && <Webhooks server={server} busy={busy} act={act} />}
|
||||
{tab === 'audit' && <Audit act={act} />}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Overview({ server, busy, act }: { server: Overview; busy: boolean; act: Act }): JSX.Element {
|
||||
const [name, setName] = useState(server.name)
|
||||
const [description, setDescription] = useState(server.description)
|
||||
const may = server.can.server
|
||||
return (
|
||||
<>
|
||||
<div className="field">
|
||||
<span className="small muted">Picture</span>
|
||||
<div className="field-row steam-group-row">
|
||||
{server.iconUrl ? <img className="account-avatar" src={resolveMediaUrl(server.iconUrl)} alt="" /> : <Icon name="dns" size={18} />}
|
||||
<span className="grow small muted">{may ? 'Shown on the rail.' : 'Only those who may manage the server can change it.'}</span>
|
||||
<button type="button" className="button subtle small" disabled={!may || busy} onClick={() => void window.moho.pickFile(['jpg', 'jpeg', 'png', 'gif', 'webp']).then((path) => path && void act('editServer', { icon: path }))}>
|
||||
Change picture
|
||||
</button>
|
||||
{server.iconUrl && (
|
||||
<button type="button" className="button subtle small" disabled={!may || busy} onClick={() => void act('editServer', { icon: '' })}>
|
||||
Take it off
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="small muted">Name</span>
|
||||
<div className="field-row">
|
||||
<input className="text-field" value={name} disabled={!may || busy} onChange={(e) => setName(e.target.value)} />
|
||||
<button type="button" className="button" disabled={!may || busy || !name.trim() || name === server.name} onClick={() => void act('editServer', { name })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="small muted">Description</span>
|
||||
<textarea className="text-field" rows={3} value={description} disabled={!may || busy} onChange={(e) => setDescription(e.target.value)} />
|
||||
<div className="field-row">
|
||||
<button type="button" className="button" disabled={!may || busy || description === server.description} onClick={() => void act('editServer', { description })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="small muted">
|
||||
{server.can.owner ? 'You own this server.' : 'What is offered here is what your roles allow.'} {server.channels.length} of at most {server.limits.channels} channels, {server.roles.length} of at most {server.limits.roles} roles.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** What a set of allows and denies says about one permission. */
|
||||
function stanceOf(o: Override, key: string): Stance {
|
||||
return o.allow.includes(key) ? 'allow' : o.deny.includes(key) ? 'deny' : 'inherit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Every permission with its choice: inherit what is below, allow, or deny. Grouped as Stoat's own app groups them.
|
||||
* `onlyAllow` is for the server's default, which can only grant.
|
||||
*/
|
||||
function PermissionEditor({
|
||||
permissions,
|
||||
value,
|
||||
onChange,
|
||||
channel,
|
||||
onlyAllow,
|
||||
disabled
|
||||
}: {
|
||||
permissions: Permission[]
|
||||
value: Override
|
||||
onChange: (next: Override) => void
|
||||
channel: boolean
|
||||
onlyAllow?: boolean
|
||||
disabled?: boolean
|
||||
}): JSX.Element {
|
||||
const shown = permissions.filter((p) => !channel || p.channel)
|
||||
const groups = [...new Set(shown.map((p) => p.group))]
|
||||
const set = (key: string, stance: Stance): void => {
|
||||
const allow = value.allow.filter((k) => k !== key)
|
||||
const deny = value.deny.filter((k) => k !== key)
|
||||
if (stance === 'allow') allow.push(key)
|
||||
if (stance === 'deny') deny.push(key)
|
||||
onChange({ allow, deny })
|
||||
}
|
||||
return (
|
||||
<div className="steam-group-perms">
|
||||
{groups.map((g) => (
|
||||
<div key={g} className="field">
|
||||
<span className="small muted">{g}</span>
|
||||
{shown
|
||||
.filter((p) => p.group === g)
|
||||
.map((p) => (
|
||||
<div key={p.key} className="field-row steam-group-row">
|
||||
<span className="grow ellipsis">{p.label}</span>
|
||||
<select className="text-field small" aria-label={p.label} disabled={disabled} value={stanceOf(value, p.key)} onChange={(e) => set(p.key, e.target.value as Stance)}>
|
||||
<option value="inherit">{onlyAllow ? 'No' : 'Inherit'}</option>
|
||||
<option value="allow">Allow</option>
|
||||
{!onlyAllow && <option value="deny">Deny</option>}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Channels({ server, busy, act }: { server: Overview; busy: boolean; act: Act }): JSX.Element {
|
||||
const [open, setOpen] = useState<{ id: string; what: 'edit' | 'permissions' } | null>(null)
|
||||
const [fresh, setFresh] = useState('')
|
||||
const [voice, setVoice] = useState(false)
|
||||
const [heading, setHeading] = useState('')
|
||||
const may = server.can.channels
|
||||
const byId = useMemo(() => new Map(server.channels.map((c) => [c.id, c])), [server.channels])
|
||||
|
||||
const sections: { id: string | null; title: string; channels: Channel[] }[] = [
|
||||
...server.categories.map((c) => ({ id: c.id as string | null, title: c.title, channels: c.channels.map((x) => byId.get(x)).filter((x): x is Channel => !!x) })),
|
||||
{ id: null, title: server.categories.length ? 'No heading' : 'Channels', channels: server.channels.filter((c) => !c.category) }
|
||||
]
|
||||
const write = (next: Category[]): void => void act('setCategories', { categories: next })
|
||||
/** The headings with one channel moved under another (or, with `to` null, out of them all). */
|
||||
const moved = (channel: string, to: string | null): Category[] => server.categories.map((c) => ({ ...c, channels: [...c.channels.filter((x) => x !== channel), ...(c.id === to ? [channel] : [])] }))
|
||||
const shifted = (category: Category, channel: string, by: number): Category[] =>
|
||||
server.categories.map((c) => {
|
||||
if (c.id !== category.id) return c
|
||||
const list = [...c.channels]
|
||||
const at = list.indexOf(channel)
|
||||
const to = at + by
|
||||
if (at < 0 || to < 0 || to >= list.length) return c
|
||||
;[list[at], list[to]] = [list[to], list[at]]
|
||||
return { ...c, channels: list }
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.map((s) => (
|
||||
<div key={s.id ?? 'none'} className="field">
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow small muted">{s.title}</span>
|
||||
{may && s.id && <HeadingTools category={server.categories.find((c) => c.id === s.id)!} categories={server.categories} write={write} busy={busy} />}
|
||||
</div>
|
||||
{s.channels.map((c) => {
|
||||
const category = server.categories.find((x) => x.id === c.category)
|
||||
return (
|
||||
<div key={c.id} className="steam-group-person">
|
||||
<div className="field-row steam-group-row">
|
||||
<Icon name={c.voice ? 'volume_up' : 'tag'} size={16} />
|
||||
<span className="grow ellipsis">{c.name}</span>
|
||||
{c.nsfw && <span className="small muted">18+</span>}
|
||||
{c.slowmode > 0 && <span className="small muted">slow {SLOWMODES.find(([s]) => s === c.slowmode)?.[1] ?? `${c.slowmode}s`}</span>}
|
||||
{may && category && (
|
||||
<>
|
||||
<button type="button" className="button subtle small" aria-label={`Move ${c.name} up`} disabled={busy} onClick={() => write(shifted(category, c.id, -1))}>
|
||||
↑
|
||||
</button>
|
||||
<button type="button" className="button subtle small" aria-label={`Move ${c.name} down`} disabled={busy} onClick={() => write(shifted(category, c.id, 1))}>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{may && (
|
||||
<button type="button" className="button subtle small" onClick={() => setOpen(open?.id === c.id && open.what === 'edit' ? null : { id: c.id, what: 'edit' })}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{server.can.permissions && (
|
||||
<button type="button" className="button subtle small" onClick={() => setOpen(open?.id === c.id && open.what === 'permissions' ? null : { id: c.id, what: 'permissions' })}>
|
||||
Who may
|
||||
</button>
|
||||
)}
|
||||
{may && <ConfirmButton label="Delete" question={`Delete ${c.name} and what was said in it?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteChannel', { channelId: c.id })} />}
|
||||
</div>
|
||||
{open?.id === c.id && open.what === 'edit' && <ChannelEdit channel={c} categories={server.categories} busy={busy} act={act} onMove={(to) => write(moved(c.id, to))} />}
|
||||
{open?.id === c.id && open.what === 'permissions' && <ChannelPermissions channel={c} server={server} busy={busy} act={act} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{may && (
|
||||
<>
|
||||
<div className="field-row">
|
||||
<input className="text-field" placeholder="A new channel" value={fresh} onChange={(e) => setFresh(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && void act('createChannel', { name: fresh, voice }).then(() => setFresh(''))} />
|
||||
<button type="button" className="button" disabled={busy || !fresh.trim()} onClick={() => void act('createChannel', { name: fresh, voice }).then(() => setFresh(''))}>
|
||||
Add channel
|
||||
</button>
|
||||
</div>
|
||||
<SwitchRow title="A voice channel" description="People in it can talk, as well as write" checked={voice} onChange={setVoice} />
|
||||
<div className="field-row">
|
||||
<input className="text-field" placeholder="A new heading" maxLength={32} value={heading} onChange={(e) => setHeading(e.target.value)} />
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy || !heading.trim()}
|
||||
onClick={() => {
|
||||
write([...server.categories, { id: Math.random().toString(36).slice(2, 10).toUpperCase(), title: heading.trim(), channels: [] }])
|
||||
setHeading('')
|
||||
}}
|
||||
>
|
||||
Add heading
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renaming a heading, moving it, and taking it away (its channels stay, under no heading). */
|
||||
function HeadingTools({ category, categories, write, busy }: { category: Category; categories: Category[]; write: (next: Category[]) => void; busy: boolean }): JSX.Element {
|
||||
const [name, setName] = useState<string | null>(null)
|
||||
const at = categories.findIndex((c) => c.id === category.id)
|
||||
const swap = (by: number): Category[] => {
|
||||
const list = [...categories]
|
||||
;[list[at], list[at + by]] = [list[at + by], list[at]]
|
||||
return list
|
||||
}
|
||||
if (name !== null) {
|
||||
return (
|
||||
<>
|
||||
<input className="text-field small" autoFocus maxLength={32} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button
|
||||
type="button"
|
||||
className="button small"
|
||||
disabled={busy || !name.trim()}
|
||||
onClick={() => {
|
||||
write(categories.map((c) => (c.id === category.id ? { ...c, title: name.trim() } : c)))
|
||||
setName(null)
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="button subtle small" onClick={() => setName(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="button subtle small" aria-label={`Move ${category.title} up`} disabled={busy || at === 0} onClick={() => write(swap(-1))}>
|
||||
↑
|
||||
</button>
|
||||
<button type="button" className="button subtle small" aria-label={`Move ${category.title} down`} disabled={busy || at === categories.length - 1} onClick={() => write(swap(1))}>
|
||||
↓
|
||||
</button>
|
||||
<button type="button" className="button subtle small" onClick={() => setName(category.title)}>
|
||||
Rename
|
||||
</button>
|
||||
<ConfirmButton label="Remove" question={`Remove the heading ${category.title}? Its channels stay.`} confirmLabel="Remove" danger onConfirm={() => write(categories.filter((c) => c.id !== category.id))} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelEdit({ channel, categories, busy, act, onMove }: { channel: Channel; categories: Category[]; busy: boolean; act: Act; onMove: (to: string | null) => void }): JSX.Element {
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [description, setDescription] = useState(channel.description)
|
||||
const [nsfw, setNsfw] = useState(channel.nsfw)
|
||||
const [slowmode, setSlowmode] = useState(channel.slowmode)
|
||||
const changed = name !== channel.name || description !== channel.description || nsfw !== channel.nsfw || slowmode !== channel.slowmode
|
||||
return (
|
||||
<div className="steam-group-perms">
|
||||
<input className="text-field" aria-label="Name" value={name} disabled={busy} onChange={(e) => setName(e.target.value)} />
|
||||
<input className="text-field" aria-label="Topic" placeholder="Topic" value={description} disabled={busy} onChange={(e) => setDescription(e.target.value)} />
|
||||
<SwitchRow title="Age-restricted" description="Asks people to confirm they are of age before they look" checked={nsfw} onChange={setNsfw} />
|
||||
{!channel.voice && (
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow">Slowmode</span>
|
||||
<select className="text-field small" aria-label="Slowmode" value={slowmode} onChange={(e) => setSlowmode(Number(e.target.value))}>
|
||||
{SLOWMODES.map(([s, label]) => (
|
||||
<option key={s} value={s}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{categories.length > 0 && (
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow">Under</span>
|
||||
<select className="text-field small" aria-label="Heading" value={channel.category ?? ''} onChange={(e) => onMove(e.target.value || null)}>
|
||||
<option value="">No heading</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-row">
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy || !changed || !name.trim()}
|
||||
onClick={() => void act('editChannel', { channelId: channel.id, name, description, nsfw, slowmode: channel.voice ? undefined : slowmode })}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who may do what in one channel: everybody's default, and each role's. */
|
||||
function ChannelPermissions({ channel, server, busy, act }: { channel: Channel; server: Overview; busy: boolean; act: Act }): JSX.Element {
|
||||
const [who, setWho] = useState('')
|
||||
const held = who === '' ? channel.default : (channel.roles.find((r) => r.roleId === who) ?? { allow: [], deny: [] })
|
||||
const [value, setValue] = useState<Override>({ allow: held.allow, deny: held.deny })
|
||||
useEffect(() => setValue({ allow: held.allow, deny: held.deny }), [who, held.allow.join(), held.deny.join()]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return (
|
||||
<div className="steam-group-perms">
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow">For</span>
|
||||
<select className="text-field small" aria-label="For" value={who} onChange={(e) => setWho(e.target.value)}>
|
||||
<option value="">Everybody</option>
|
||||
{server.roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<PermissionEditor permissions={server.permissions} value={value} onChange={setValue} channel disabled={busy} />
|
||||
<div className="field-row">
|
||||
<button type="button" className="button" disabled={busy} onClick={() => void act('setChannelPermissions', { channelId: channel.id, roleId: who, allow: value.allow, deny: value.deny })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Roles({ server, busy, act }: { server: Overview; busy: boolean; act: Act }): JSX.Element {
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [fresh, setFresh] = useState('')
|
||||
const may = server.can.roles
|
||||
const order = server.roles.map((r) => r.id)
|
||||
const shifted = (id: string, by: number): string[] => {
|
||||
const list = [...order]
|
||||
const at = list.indexOf(id)
|
||||
const to = at + by
|
||||
if (at < 0 || to < 0 || to >= list.length) return list
|
||||
;[list[at], list[to]] = [list[to], list[at]]
|
||||
return list
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<p className="small muted">The most powerful role is first. A role can only be given, changed or moved by someone with a role above it.</p>
|
||||
<div className="steam-group-person">
|
||||
<div className="field-row steam-group-row">
|
||||
<Icon name="groups" size={16} />
|
||||
<span className="grow ellipsis">Everybody</span>
|
||||
{server.can.permissions && (
|
||||
<button type="button" className="button subtle small" onClick={() => setOpen(open === '' ? null : '')}>
|
||||
What everybody may do
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open === '' && <RoleEditor server={server} role={null} busy={busy} act={act} />}
|
||||
</div>
|
||||
{server.roles.map((r) => (
|
||||
<div key={r.id} className="steam-group-person">
|
||||
<div className="field-row steam-group-row">
|
||||
<span style={{ width: 12, height: 12, borderRadius: 6, background: r.colour || 'var(--outline-medium)', display: 'inline-block' }} />
|
||||
<span className="grow ellipsis">{r.name}</span>
|
||||
{r.hoist && <span className="small muted">listed apart</span>}
|
||||
{may && (
|
||||
<>
|
||||
<button type="button" className="button subtle small" aria-label={`Move ${r.name} up`} disabled={busy} onClick={() => void act('reorderRoles', { order: shifted(r.id, -1) })}>
|
||||
↑
|
||||
</button>
|
||||
<button type="button" className="button subtle small" aria-label={`Move ${r.name} down`} disabled={busy} onClick={() => void act('reorderRoles', { order: shifted(r.id, 1) })}>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(may || server.can.permissions) && (
|
||||
<button type="button" className="button subtle small" onClick={() => setOpen(open === r.id ? null : r.id)}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{may && <ConfirmButton label="Delete" question={`Delete the role ${r.name}?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteRole', { roleId: r.id })} />}
|
||||
</div>
|
||||
{open === r.id && <RoleEditor server={server} role={r} busy={busy} act={act} />}
|
||||
</div>
|
||||
))}
|
||||
{may && (
|
||||
<div className="field-row">
|
||||
<input className="text-field" placeholder="A new role" value={fresh} onChange={(e) => setFresh(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && void act('createRole', { name: fresh }).then(() => setFresh(''))} />
|
||||
<button type="button" className="button" disabled={busy || !fresh.trim()} onClick={() => void act('createRole', { name: fresh }).then(() => setFresh(''))}>
|
||||
Add role
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** One role's name, colour and what it allows and denies; with no role, what everybody may do. */
|
||||
function RoleEditor({ server, role, busy, act }: { server: Overview; role: Role | null; busy: boolean; act: Act }): JSX.Element {
|
||||
const [name, setName] = useState(role?.name ?? '')
|
||||
const [colour, setColour] = useState(role?.colour ?? '')
|
||||
const [hoist, setHoist] = useState(role?.hoist ?? false)
|
||||
const [value, setValue] = useState<Override>(role ? { allow: role.allow, deny: role.deny } : { allow: server.defaultPermissions, deny: [] })
|
||||
const mayLook = role ? server.can.roles : false
|
||||
return (
|
||||
<div className="steam-group-perms">
|
||||
{role && (
|
||||
<>
|
||||
<input className="text-field" aria-label="Name" value={name} disabled={busy || !mayLook} onChange={(e) => setName(e.target.value)} />
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow">Colour</span>
|
||||
<input type="color" aria-label="Colour" value={/^#[0-9a-f]{6}$/i.test(colour) ? colour : '#808080'} disabled={busy || !mayLook} onChange={(e) => setColour(e.target.value)} />
|
||||
<button type="button" className="button subtle small" disabled={busy || !mayLook || !colour} onClick={() => setColour('')}>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow">Listed apart in the member list</span>
|
||||
<Switch checked={hoist} onChange={setHoist} label="Listed apart" disabled={busy || !mayLook} />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy || !mayLook || !name.trim() || (name === role.name && colour === (role.colour ?? '') && hoist === role.hoist)}
|
||||
onClick={() => void act('editRole', { roleId: role.id, name, colour, hoist })}
|
||||
>
|
||||
Save the role
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<PermissionEditor permissions={server.permissions} value={value} onChange={setValue} channel={false} onlyAllow={!role} disabled={busy || !server.can.permissions} />
|
||||
{server.can.permissions && (
|
||||
<div className="field-row">
|
||||
<button type="button" className="button" disabled={busy} onClick={() => void act('setRolePermissions', { roleId: role?.id ?? '', allow: value.allow, deny: value.deny })}>
|
||||
Save what it may do
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Webhooks({ server, busy, act }: { server: Overview; busy: boolean; act: Act }): JSX.Element {
|
||||
const store = useStore()
|
||||
const text = server.channels.filter((c) => !c.voice)
|
||||
const [channel, setChannel] = useState(text[0]?.id ?? '')
|
||||
const [list, setList] = useState<Webhook[] | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const load = useCallback(() => {
|
||||
if (!channel) return
|
||||
void act<{ webhooks: Webhook[] }>('webhooks', { channelId: channel }).then((r) => setList(r?.webhooks ?? []))
|
||||
}, [channel, act])
|
||||
useEffect(load, [load])
|
||||
if (!server.can.webhooks) return <p className="small muted">Only someone who may manage webhooks can see these.</p>
|
||||
return (
|
||||
<>
|
||||
<p className="small muted">A webhook lets another program post in a channel. Its address is a secret: whoever has it can speak there.</p>
|
||||
<div className="field-row steam-group-row">
|
||||
<span className="grow">Channel</span>
|
||||
<select className="text-field small" aria-label="Channel" value={channel} onChange={(e) => setChannel(e.target.value)}>
|
||||
{text.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{list === null && <p className="small muted">Asking Stoat…</p>}
|
||||
{list?.length === 0 && <p className="small muted">None in this channel.</p>}
|
||||
{list?.map((w) => (
|
||||
<div key={w.id} className="field-row steam-group-row">
|
||||
<Icon name="webhook" size={16} />
|
||||
<span className="grow ellipsis">{w.name}</span>
|
||||
{w.url && (
|
||||
<button type="button" className="button subtle small" onClick={() => void window.moho.copyText(w.url!).then(() => store.toast('info', 'Webhook address copied'))}>
|
||||
Copy address
|
||||
</button>
|
||||
)}
|
||||
<ConfirmButton label="Delete" question={`Delete the webhook ${w.name}?`} confirmLabel="Delete" danger onConfirm={() => void act('deleteWebhook', { webhookId: w.id, token: w.token ?? '' }).then(load)} />
|
||||
</div>
|
||||
))}
|
||||
<div className="field-row">
|
||||
<input className="text-field" placeholder="A new webhook" maxLength={32} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy || !name.trim() || !channel}
|
||||
onClick={() =>
|
||||
void act('createWebhook', { channelId: channel, name }).then(() => {
|
||||
setName('')
|
||||
load()
|
||||
})
|
||||
}
|
||||
>
|
||||
Add webhook
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Audit({ act }: { act: Act }): JSX.Element {
|
||||
const [entries, setEntries] = useState<AuditEntry[] | null>(null)
|
||||
const [more, setMore] = useState(true)
|
||||
const load = (before?: string): void => {
|
||||
void act<{ entries: AuditEntry[] }>('auditLog', before ? { before } : {}).then((r) => {
|
||||
const got = r?.entries ?? []
|
||||
setEntries((held) => [...(before ? (held ?? []) : []), ...got])
|
||||
setMore(got.length >= 50)
|
||||
})
|
||||
}
|
||||
useEffect(() => load(), []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (entries === null) return <p className="small muted">Asking Stoat…</p>
|
||||
if (entries.length === 0) return <p className="small muted">Nothing has been recorded.</p>
|
||||
return (
|
||||
<>
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="field-row steam-group-row">
|
||||
<span className="grow">{e.text}</span>
|
||||
<span className="small muted">{new Date(e.ts * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
{more && (
|
||||
<div className="field-row">
|
||||
<button type="button" className="button subtle" onClick={() => load(entries[entries.length - 1].id)}>
|
||||
Older
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Avatar } from './Avatar'
|
||||
import { Icon } from './Icon'
|
||||
import { useChat } from '../state/hooks'
|
||||
|
||||
/**
|
||||
* Who is in a Stoat voice channel, listed under it in the channel list as a Discord server's are: what anybody
|
||||
* decides on before joining. What the daemon last said, which it says as people come, go, mute and turn a camera on.
|
||||
*/
|
||||
export function StoatVoiceWho({ bufferId, accountId }: { bufferId: string; accountId: string }): JSX.Element | null {
|
||||
const members = useChat((s) => s.stoatVoice[bufferId]) ?? []
|
||||
if (members.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
{members.map((m) => (
|
||||
<div key={m.userId} className="voice-member small">
|
||||
<span className="voice-member-face">
|
||||
<Avatar name={m.name} url={m.avatarUrl ?? undefined} size={20} accountId={accountId} />
|
||||
</span>
|
||||
<span className="ellipsis">{m.name}</span>
|
||||
{m.screen && (
|
||||
<span className="voice-state" title={`${m.name} is sharing a screen`}>
|
||||
<Icon name="screen_share" size={13} />
|
||||
</span>
|
||||
)}
|
||||
{m.camera && (
|
||||
<span className="voice-state" title={`${m.name} has a camera on`}>
|
||||
<Icon name="videocam" size={13} />
|
||||
</span>
|
||||
)}
|
||||
{m.muted && (
|
||||
<span className="voice-state" title={`${m.name} is muted`}>
|
||||
<Icon name="mic_off" size={13} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -130,6 +130,7 @@ const CATEGORIES: { id: string; label: string; group: string; icon?: string; ser
|
||||
{ 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: 'stoat', label: 'Stoat', group: 'Networks', service: 'stoat' },
|
||||
{ id: 'about', label: 'About', group: 'About' }
|
||||
]
|
||||
|
||||
@@ -252,6 +253,8 @@ function Page({ id }: { id: string }): JSX.Element | null {
|
||||
return <TwitchSettings />
|
||||
case 'telegram':
|
||||
return <TelegramSettings />
|
||||
case 'stoat':
|
||||
return <StoatSettings />
|
||||
case 'about':
|
||||
return <AboutSettings />
|
||||
default:
|
||||
@@ -1330,6 +1333,57 @@ function TelegramSettings(): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoat (formerly Revolt). What is built is in Gitea issue #32; this says how the parts that are not obvious behave.
|
||||
*/
|
||||
function StoatSettings(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Stoat"
|
||||
description="An account names its instance (the official one, or one you or a friend host) and signs in once with email and password, or with a session token pasted from another app. moho keeps the session, never the password, and does not make accounts or answer captchas: do that in Stoat's own app first. Add an account from the Accounts pane."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Typing"
|
||||
description="Stoat's own apps tell the others when you are typing, and so does moho. Turn it off here if you would rather not."
|
||||
>
|
||||
<ToggleSetting settingKey="stoat.sendTyping" label="Tell others when I am typing" defaultValue={true} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Muting and notifications"
|
||||
description="Muting a conversation from its menu is kept in your Stoat account's own settings, so it holds in the app on your phone as well, and one you muted there is muted here. A server you muted there silences its channels here too."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Presence"
|
||||
description="Online, Idle, Do not disturb (Stoat's Busy) and Invisible from the status menu are Stoat's own presences for the account. The line you write beside your status is set under the account in the Accounts pane, with your display name, pronouns, about text and picture."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Calls"
|
||||
description="Voice channels and direct messages use Stoat's media servers: microphone, camera and a shared screen. Starting a call in a direct message or a group rings the others on their phones, and one that begins there while you are away rings here. While an account is new (the instance says for how long) Stoat holds sound and video to lower limits, and moho says so as the call begins."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Servers you look after"
|
||||
description="Where your permissions allow it, a server's menu opens its settings: name, picture and description, channels and their headings, roles and what each may do, who may do what in one channel, webhooks, and the record of what moderators did. moho shows only what you may do, and Stoat checks again and says why in words if it refuses."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Limits"
|
||||
description="The instance says how long a message may be, how many files, how large each, how many servers, roles, channels and reactions, and how many friend requests may wait. moho keeps to them before it asks, and says which one it is when one applies."
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="What moho does not do"
|
||||
description="Making or deleting accounts, changing the email or password, bots and their invites, the server discovery list, web push, and Stoat's plugin API are not part of moho. Stoat has no stickers, so there is no sticker button."
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TwitchSettings(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|telegram-media|telegram-avatars)[\\/]/
|
||||
const RESTORABLE = /[\\/]nobilis[\\/](matrix-media|sneedchat-attachments|sneedchat-avatars|discord-thumbnails|discord-icons|telegram-media|telegram-avatars|stoat-media|stoat-avatars)[\\/]/
|
||||
|
||||
/** Paths being fetched, so a picture drawn twice asks once. */
|
||||
const inFlight = new Map<string, Promise<string | null>>()
|
||||
|
||||
@@ -6,6 +6,9 @@ describe('reactionLabel', () => {
|
||||
expect(reactionLabel('<:pepe_sad:12345>')).toBe(':pepe_sad:')
|
||||
expect(reactionLabel('<a:party_parrot:999>')).toBe(':party_parrot:')
|
||||
})
|
||||
it('names a Stoat emoji from its picture token', () => {
|
||||
expect(reactionLabel('[stoat:emoji:party:file:///home/me/.cache/nobilis/stoat-media/abc.png]')).toBe(':party:')
|
||||
})
|
||||
it('looks a Unicode one up, and shows it as itself when there is no name', () => {
|
||||
const names = new Map([['🔥', 'fire']])
|
||||
expect(reactionLabel('🔥', names)).toBe(':fire:')
|
||||
|
||||
@@ -12,6 +12,8 @@ import { steamTokenOf } from './format'
|
||||
export function reactionLabel(emoji: string, unicodeNames: ReadonlyMap<string, string> = new Map()): string {
|
||||
const custom = emoji.match(/^<a?:([A-Za-z0-9_~]{2,32}):\d+>$/)
|
||||
if (custom) return `:${custom[1]}:`
|
||||
const stoat = emoji.match(/^\[stoat:emoji:([A-Za-z0-9_]{1,32}):file:\/\/[^\]\s]*\]$/)
|
||||
if (stoat) return `:${stoat[1]}:`
|
||||
const steam = steamTokenOf(emoji)
|
||||
if (steam) return `:${steam.name}:`
|
||||
const name = unicodeNames.get(emoji)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -2148,6 +2176,12 @@ export class ChatStore {
|
||||
// Optimistic: the ringing stops the instant it is answered, rather than a
|
||||
// round trip later with the ringtone still going.
|
||||
this.setRinging({ ...this.callFor(bufferId), ringing: false })
|
||||
// A Stoat call is answered by joining it on its media server.
|
||||
if (this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('stoat:')) {
|
||||
await this.selectBuffer(bufferId, true)
|
||||
await this.joinStoatCall(bufferId, false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.moho.rpc('acceptCall', { bufferId })
|
||||
await this.selectBuffer(bufferId, true)
|
||||
@@ -2160,7 +2194,7 @@ export class ChatStore {
|
||||
async declineCall(bufferId: string): Promise<void> {
|
||||
this.setRinging({ ...this.callFor(bufferId), ringing: false })
|
||||
try {
|
||||
await window.moho.rpc('declineCall', { bufferId })
|
||||
await window.moho.rpc(this.state.buffers.find((b) => b.id === bufferId)?.accountId.startsWith('stoat:') ? 'declineStoatCall' : 'declineCall', { bufferId })
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't decline: ${(e as Error).message}`)
|
||||
}
|
||||
@@ -2190,6 +2224,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 +2287,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 +2903,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 +3239,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 +3275,79 @@ 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; note?: string | null }>('joinStoatCall', { bufferId })
|
||||
// What a new account is held to is said once, as the call starts.
|
||||
if (way.note) this.toast('info', way.note)
|
||||
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 +3467,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 +3588,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 +3626,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 +3667,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) {
|
||||
@@ -4118,8 +4292,13 @@ export class ChatStore {
|
||||
[data.bufferId as string]: (data.pinned as string[]) || []
|
||||
}
|
||||
})
|
||||
// The pins changed under rows already read: read them again.
|
||||
if (this.state.pinnedRows[data.bufferId as string]) void this.loadPins(data.bufferId as string).catch(() => {})
|
||||
// The pins changed under rows already read: read them again. Only when they did - the daemon says the pins
|
||||
// each time it is asked for them, and reading again on every telling would ask for ever.
|
||||
{
|
||||
const held = this.state.pinnedRows[data.bufferId as string]
|
||||
const now = (data.pinned as string[]) || []
|
||||
if (held && (held.length !== now.length || held.some((m) => !now.includes(m.id)))) void this.loadPins(data.bufferId as string).catch(() => {})
|
||||
}
|
||||
break
|
||||
|
||||
case 'pinnedMessage': {
|
||||
@@ -4288,6 +4467,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 {
|
||||
|
||||
+3
-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
|
||||
@@ -728,6 +728,8 @@ export interface Member {
|
||||
bot?: boolean
|
||||
/** The roles this person holds here, by name. Discord only. */
|
||||
roles?: string[]
|
||||
/** What they are to this account where the service keeps friends: "Friend", "Blocked", "None"... Stoat only. */
|
||||
relationship?: string
|
||||
powerLevel?: number
|
||||
/**
|
||||
* The protocol's own presence word - "online", "idle", "dnd", "offline".
|
||||
|
||||
Reference in new issue
Block a user