diff --git a/nobilis b/nobilis index 9648ae5..39a65bd 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 9648ae56c88fc3b2e9107d72f25e11c9f39c262a +Subproject commit 39a65bd0df1786f894a0115341aa5c65e0dcdd22 diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 8c7caf9..63835c3 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -2281,6 +2281,18 @@ export class ChatStore { return } + // A Stoat invite joins the server, in the first Stoat account there is; with none, it is a web page for the browser. + if (link.service === 'stoat') { + const account = this.state.accounts.find((a) => a.service === 'stoat' && a.state === 'connected') ?? this.state.accounts.find((a) => a.service === 'stoat') + if (!account) { + void window.moho.openExternal(link.url) + return + } + this.set({ activePanel: '' }) + void this.joinAndOpen(account.id, link.url) + return + } + // A Telegram link goes to wherever it points: the chat, joined if the account is not in it, and the post. if (link.service === 'telegram') { void this.openTelegramLink(link.url) diff --git a/src/shared/deeplink.test.ts b/src/shared/deeplink.test.ts index 1c3e127..092500a 100644 --- a/src/shared/deeplink.test.ts +++ b/src/shared/deeplink.test.ts @@ -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) + }) +}) diff --git a/src/shared/deeplink.ts b/src/shared/deeplink.ts index a7dd6d9..f259a80 100644 --- a/src/shared/deeplink.ts +++ b/src/shared/deeplink.ts @@ -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 {