Strict routing: a Tor or proxy account's media goes through its route (#257)
Each account row now has a three-position control in place of the Tor switch: Clearnet, Service tunnel (the protocol and the media the daemon fetches for it), and Strict. On Strict every remote URL the window would load for that account's buffers - pictures and video in messages, embeds, avatars, emoji, reactions, the link check - is rewritten to moho-routed://fetch/?u=<url>. The main process answers it from a session of its own whose proxy is the daemon's route, with hostnames resolved by the proxy. It fails closed: no route yet means the picture does not draw, never a direct fetch. Verified against a logging SOCKS proxy and a web server: a link posted into a strict IRC channel reached the server only through the proxy, and the window made no direct request. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 7976452332...a3897bab95
@@ -33,6 +33,7 @@ import { defaultDownloadDir, saveMedia } from './downloads'
|
||||
import type { Buffer as ChatBuffer } from '../shared/wire'
|
||||
import { log } from './log'
|
||||
import { applyPolicy, applyTunnel } from './tunnel'
|
||||
import { installRoutedHandler, resetRoute } from './routed'
|
||||
|
||||
registerMediaScheme()
|
||||
|
||||
@@ -965,6 +966,7 @@ app.whenReady().then(() => {
|
||||
// Rail icons somebody chose, copied here after being checked as images.
|
||||
allowRoot(path.join(app.getPath('userData'), 'group-icons'))
|
||||
installMediaHandler()
|
||||
installRoutedHandler((method, params) => client.request(method, params))
|
||||
|
||||
prefs = new Prefs()
|
||||
nobilis = new NobilisProcess()
|
||||
@@ -999,6 +1001,7 @@ app.whenReady().then(() => {
|
||||
// Before anything is fetched for the window: whether its own traffic goes
|
||||
// through Tor or the proxy is the daemon's setting, asked of it here.
|
||||
void applyTunnel((method, params) => client.request(method, params))
|
||||
resetRoute()
|
||||
// The daemon usually outlives this process, so its buffers were announced
|
||||
// long before this connection existed and no bufferListChange is coming
|
||||
// for them. Without asking outright, main knows of no conversations at
|
||||
@@ -1024,6 +1027,7 @@ app.whenReady().then(() => {
|
||||
void notifier.handle(frame.data)
|
||||
} else if (frame.event === 'netSettings') {
|
||||
void applyTunnel((method, params) => client.request(method, params))
|
||||
resetRoute()
|
||||
}
|
||||
send(IPC.event, frame)
|
||||
})
|
||||
|
||||
@@ -252,6 +252,13 @@ export function registerMediaScheme(): void {
|
||||
// this scheme cross-origin, and an <img> needs no permission where a
|
||||
// fetch() does. A Lottie sticker is JSON read by script.
|
||||
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true }
|
||||
},
|
||||
// Registered together because registration happens once: remote media
|
||||
// for a strictly routed account, fetched through its route - see
|
||||
// routed.ts.
|
||||
{
|
||||
scheme: 'moho-routed',
|
||||
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true }
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { protocol, session } from 'electron'
|
||||
import { log } from './log'
|
||||
|
||||
/**
|
||||
* Remote media for a strictly routed account, fetched through its route.
|
||||
*
|
||||
* An account's routing has three positions (#257). The first two only decide
|
||||
* how the daemon reaches the service; everything the window loads for itself
|
||||
* - pictures and video linked in a message, avatars, emoji, embeds, the link
|
||||
* check that decides whether a URL is a picture - is Chromium's, and goes out
|
||||
* directly from the user's own address. For an account on Tor that is the
|
||||
* leak routing it was meant to prevent: anybody in the channel can post a link
|
||||
* to a server of theirs and learn the reader's IP the moment it arrives.
|
||||
*
|
||||
* Strict closes it. The window rewrites every remote URL it loads for such an
|
||||
* account to `moho-routed://fetch/?u=<url>`, and this handler fetches it
|
||||
* through a session of its own, pointed at the daemon's route: moho's Tor, or
|
||||
* the proxy the user named. Hostnames go to the proxy unresolved, so the
|
||||
* lookup does not leak either.
|
||||
*
|
||||
* Fails closed. With no route yet - Tor still starting, a proxy not answering
|
||||
* - the answer is an error and the picture does not draw, rather than being
|
||||
* fetched directly.
|
||||
*/
|
||||
|
||||
export const ROUTED_SCHEME = 'moho-routed'
|
||||
|
||||
type Request = (method: string, params?: Record<string, unknown>) => Promise<unknown>
|
||||
|
||||
/** Only what a picture, a video or a link check needs to read. */
|
||||
const PASSED_HEADERS = [
|
||||
'content-type',
|
||||
'content-length',
|
||||
'content-range',
|
||||
'accept-ranges',
|
||||
'cache-control',
|
||||
'last-modified',
|
||||
'etag'
|
||||
]
|
||||
|
||||
let configured = false
|
||||
let configuring: Promise<boolean> | null = null
|
||||
let ask: Request | null = null
|
||||
|
||||
/** In memory only: nothing it is sent is kept, and it holds no cookies. */
|
||||
function routedSession(): Electron.Session {
|
||||
return session.fromPartition(ROUTED_SCHEME)
|
||||
}
|
||||
|
||||
/** Points the session at the route, once, and again after a change. */
|
||||
function ensureRoute(): Promise<boolean> {
|
||||
if (configured) return Promise.resolve(true)
|
||||
if (configuring) return configuring
|
||||
configuring = (async () => {
|
||||
try {
|
||||
if (!ask) return false
|
||||
const answer = (await ask('netRoute')) as { socks?: string }
|
||||
if (!answer?.socks) return false
|
||||
await routedSession().setProxy({ proxyRules: `socks5://${answer.socks}` })
|
||||
configured = true
|
||||
log.info(`routed: strict media through ${answer.socks}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
log.warn(`routed: no route for strict media, holding it back: ${(e as Error).message}`)
|
||||
return false
|
||||
} finally {
|
||||
configuring = null
|
||||
}
|
||||
})()
|
||||
return configuring
|
||||
}
|
||||
|
||||
/** The route may have moved - Tor restarted, a proxy changed. Asked afresh. */
|
||||
export function resetRoute(): void {
|
||||
configured = false
|
||||
}
|
||||
|
||||
export function installRoutedHandler(request: Request): void {
|
||||
ask = request
|
||||
protocol.handle(ROUTED_SCHEME, async (req) => {
|
||||
const target = new URL(req.url).searchParams.get('u') ?? ''
|
||||
// Web addresses only. Anything else is not a thing to fetch on the
|
||||
// network's behalf, and a local path has its own, guarded scheme.
|
||||
if (!/^https?:\/\//i.test(target)) return new Response('bad request', { status: 400 })
|
||||
if (!(await ensureRoute())) return new Response('no route', { status: 502 })
|
||||
try {
|
||||
const headers = new Headers()
|
||||
const range = req.headers.get('range')
|
||||
if (range) headers.set('range', range)
|
||||
const res = await routedSession().fetch(target, {
|
||||
method: req.method === 'HEAD' ? 'HEAD' : 'GET',
|
||||
headers,
|
||||
credentials: 'omit',
|
||||
redirect: 'follow'
|
||||
})
|
||||
const out = new Headers()
|
||||
for (const name of PASSED_HEADERS) {
|
||||
const value = res.headers.get(name)
|
||||
if (value) out.set(name, value)
|
||||
}
|
||||
// Readable by the page as well as drawable, as the media scheme is: the
|
||||
// link check reads the content type.
|
||||
out.set('Access-Control-Allow-Origin', '*')
|
||||
return new Response(req.method === 'HEAD' ? null : res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: out
|
||||
})
|
||||
} catch (e) {
|
||||
// A dead route looks like this too; ask again next time.
|
||||
configured = false
|
||||
log.warn(`routed: fetching through the route failed: ${(e as Error).message}`)
|
||||
return new Response('route failed', { status: 502 })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
<title>moho</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' data: blob: https: moho-media:; media-src 'self' blob: https: moho-media:; font-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' moho-media: https: wss: ws://localhost:* http://localhost:*"
|
||||
content="default-src 'self'; img-src 'self' data: blob: https: moho-media: moho-routed:; media-src 'self' blob: https: moho-media: moho-routed:; font-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' moho-media: moho-routed: https: wss: ws://localhost:* http://localhost:*"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8272,3 +8272,60 @@ video.lightbox-media {
|
||||
align-items: center;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* An account's routing: three positions on one track, the chosen one under
|
||||
a sliding thumb (#257). */
|
||||
.route-level-head {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.route-level {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
margin-top: var(--spacing-xs);
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.route-level-thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
left: 3px;
|
||||
width: calc((100% - 6px) / 3);
|
||||
border-radius: 999px;
|
||||
background: var(--primary-container);
|
||||
transform: translateX(calc(var(--route-index, 0) * 100%));
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.route-level-option {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--surface-variant-text);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.route-level-option.on {
|
||||
color: var(--primary-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.route-level-desc {
|
||||
margin: var(--spacing-xs) 0 0;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.route-level-thumb {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon, IconButton, MaskIcon } from './Icon'
|
||||
import { MatrixAccountTools } from './MatrixAccountTools'
|
||||
@@ -296,6 +297,7 @@ function GroupIcons({ accountId }: { accountId: string }): JSX.Element | null {
|
||||
}
|
||||
|
||||
function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
const media = useMediaUrl(account.id)
|
||||
const store = useStore()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const icon = serviceIcon(account.service)
|
||||
@@ -325,7 +327,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
<div className="account-card">
|
||||
<div className="account-card-head">
|
||||
{account.avatarUrl ? (
|
||||
<img className="account-avatar" src={resolveMediaUrl(account.avatarUrl)} alt="" />
|
||||
<img className="account-avatar" src={media(account.avatarUrl)} alt="" />
|
||||
) : icon.mark ? (
|
||||
<MaskIcon src={icon.mark} size={20} />
|
||||
) : (
|
||||
@@ -437,11 +439,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
)}
|
||||
|
||||
{account.service !== 'sneedchat' && (
|
||||
<TorSwitch
|
||||
service={account.service}
|
||||
on={account.useTor}
|
||||
onChange={(useTor) => call('setAccountRouted', { accountId: account.id, enabled: useTor })}
|
||||
/>
|
||||
<RouteLevel account={account} onChange={(level) => call('setAccountRoute', { accountId: account.id, level })} />
|
||||
)}
|
||||
|
||||
{account.service === 'discord' && <DiscordReauth account={account} />}
|
||||
@@ -450,11 +448,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
|
||||
|
||||
{account.service === 'sneedchat' && (
|
||||
<>
|
||||
<TorSwitch
|
||||
service="sneedchat"
|
||||
on={account.useTor}
|
||||
onChange={(useTor) => call('setAccountRouted', { accountId: account.id, enabled: useTor })}
|
||||
/>
|
||||
<RouteLevel account={account} onChange={(level) => call('setAccountRoute', { accountId: account.id, level })} />
|
||||
<SneedChatBrowserLogin accountId={account.id} useTor={account.useTor} />
|
||||
</>
|
||||
)}
|
||||
@@ -1186,6 +1180,62 @@ function KickForm({ onDone }: { onDone: () => void }): JSX.Element {
|
||||
* differs by service, and somebody turning it on should know before the
|
||||
* connection fails rather than after. Shown only when it is on.
|
||||
*/
|
||||
type Level = 'clearnet' | 'service' | 'strict'
|
||||
|
||||
const LEVELS: { id: Level; label: string }[] = [
|
||||
{ id: 'clearnet', label: 'Clearnet' },
|
||||
{ id: 'service', label: 'Service tunnel' },
|
||||
{ id: 'strict', label: 'Strict' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Where an account's traffic goes, in three positions (#257).
|
||||
*
|
||||
* The middle one is what a Tor switch used to mean: the connection to the
|
||||
* service, and the media the daemon fetches for it, go through the route -
|
||||
* but everything the window loads for itself (a linked picture, an avatar,
|
||||
* the check on a link) goes out directly, and anybody who can post a link
|
||||
* learns the address. Strict closes that: those go through the route too, and
|
||||
* nothing loads if the route is down.
|
||||
*/
|
||||
function RouteLevel({ account, onChange }: { account: Account; onChange: (level: Level) => void }): JSX.Element {
|
||||
const level: Level = account.routeLevel ?? (account.useTor ? 'service' : 'clearnet')
|
||||
const sneedchat = account.service === 'sneedchat'
|
||||
const via = sneedchat ? 'Tor' : 'Tor or the SOCKS5 proxy'
|
||||
const describe: Record<Level, string> = {
|
||||
clearnet: sneedchat
|
||||
? 'Reaches kiwifarms.st directly over the open internet. Tor is not started.'
|
||||
: 'Connects directly. Tor is not started.',
|
||||
service: `The connection, and the media moho fetches for it, go through ${via}. Pictures, links and avatars the window loads itself still come directly from your address.`,
|
||||
strict: `Everything for this account goes through ${via} - the connection, and every picture, link check, avatar and emoji its conversations load. If the route is down, they do not load.`
|
||||
}
|
||||
const index = LEVELS.findIndex((l) => l.id === level)
|
||||
return (
|
||||
<div className="tor-switch">
|
||||
<div className="route-level-head">
|
||||
<span>Routing</span>
|
||||
</div>
|
||||
<div className="route-level" role="radiogroup" aria-label="Routing" style={{ '--route-index': index } as React.CSSProperties}>
|
||||
<span className="route-level-thumb" aria-hidden="true" />
|
||||
{LEVELS.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={level === l.id}
|
||||
className={level === l.id ? 'route-level-option on' : 'route-level-option'}
|
||||
onClick={() => level !== l.id && onChange(l.id)}
|
||||
>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="small muted route-level-desc">{describe[level]}</p>
|
||||
{level !== 'clearnet' && <TorWarning service={account.service} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TorSwitch({
|
||||
service,
|
||||
on,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { HeaderPopover } from './HeaderPopover'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { classes, resolveMediaUrl } from '../lib/util'
|
||||
import { classes } from '../lib/util'
|
||||
import type { BufferEntry } from '../state/store'
|
||||
import type { DiscordFriend } from '../../../shared/wire'
|
||||
|
||||
@@ -24,6 +25,7 @@ const GROUP_DM_MAX_OTHERS = 9
|
||||
* their conversation appears to have forked.
|
||||
*/
|
||||
export function AddToConversation({ buffer }: { buffer: BufferEntry }): JSX.Element | null {
|
||||
const media = useMediaUrl(buffer.accountId)
|
||||
const store = useStore()
|
||||
const roster = useChat((s) => s.presenceByBuffer[buffer.id])
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -149,7 +151,7 @@ export function AddToConversation({ buffer }: { buffer: BufferEntry }): JSX.Elem
|
||||
size={16}
|
||||
/>
|
||||
{f.avatarUrl ? (
|
||||
<img className="friend-avatar" src={resolveMediaUrl(f.avatarUrl)} alt="" />
|
||||
<img className="friend-avatar" src={media(f.avatarUrl)} alt="" />
|
||||
) : (
|
||||
<span className="friend-avatar placeholder">
|
||||
{(f.globalName || f.username).slice(0, 1)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { resolveMediaUrl, nickColor } from '../lib/util'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { nickColor } from '../lib/util'
|
||||
import { presenceColor, presenceLabel } from '../lib/presence'
|
||||
|
||||
/**
|
||||
@@ -19,13 +20,18 @@ export function Avatar({
|
||||
name,
|
||||
url,
|
||||
size = 24,
|
||||
status
|
||||
status,
|
||||
accountId
|
||||
}: {
|
||||
name: string
|
||||
url?: string
|
||||
size?: number
|
||||
status?: string
|
||||
/** Whose picture: a strictly routed account's is fetched through its route
|
||||
* (#257). Taken from the surrounding conversation when not given. */
|
||||
accountId?: string
|
||||
}): JSX.Element {
|
||||
const media = useMediaUrl(accountId)
|
||||
// A picture that will not load falls back to the initial rather than to a
|
||||
// broken-image glyph. Not hypothetical: a room directory lists icons hosted
|
||||
// on servers that may refuse to hand them over, and a column of broken
|
||||
@@ -36,7 +42,7 @@ export function Avatar({
|
||||
<span className="avatar" style={{ width: size, height: size }}>
|
||||
{url && !broken ? (
|
||||
<img
|
||||
src={resolveMediaUrl(url)}
|
||||
src={media(url)}
|
||||
alt=""
|
||||
style={{ width: size, height: size }}
|
||||
// A member list or a long log is hundreds of these, most of them
|
||||
|
||||
@@ -17,7 +17,7 @@ export function BufferFace({ buffer }: { buffer: BufferEntry }): JSX.Element | n
|
||||
|
||||
return (
|
||||
<span className="header-face">
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={24} status={dmStatus(buffer, presence, buffers)} />
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={24} status={dmStatus(buffer, presence, buffers)} accountId={buffer.accountId} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { bufferLink } from '../lib/bufferlink'
|
||||
import { Icon, IconButton, MaskIcon } from './Icon'
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
bufferKindGlyph,
|
||||
serviceRoomGlyph,
|
||||
classes,
|
||||
resolveMediaUrl,
|
||||
serviceIcon
|
||||
} from '../lib/util'
|
||||
import type { Account, Member } from '../../../shared/wire'
|
||||
@@ -873,6 +873,9 @@ function BufferRow({
|
||||
}: BufferRowProps): JSX.Element {
|
||||
const { menu, open, close } = useContextMenu()
|
||||
const account = accounts.find((a) => a.id === buffer.accountId)
|
||||
// A DM's picture comes off the service's CDN; through the route for a
|
||||
// strictly routed account (#257).
|
||||
const media = useMediaUrl(buffer.accountId)
|
||||
// Whether this channel is on air. Selected down to the one boolean rather
|
||||
// than the map, so a viewer count ticking over in one channel does not
|
||||
// re-render every row in the list.
|
||||
@@ -901,9 +904,9 @@ function BufferRow({
|
||||
// A conversation with a person is headed by that person, whatever
|
||||
// protocol they are on: their picture where there is one, their initial
|
||||
// where there is not.
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} status={status} />
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} status={status} accountId={buffer.accountId} />
|
||||
) : buffer.avatarUrl ? (
|
||||
<img className="buffer-avatar" src={resolveMediaUrl(buffer.avatarUrl)} alt="" />
|
||||
<img className="buffer-avatar" src={media(buffer.avatarUrl)} alt="" />
|
||||
) : buffer.serviceRoom ? (
|
||||
// The server's own room. Marked here because the fact worth knowing is
|
||||
// that it is not one of the others - a notice about a quota is easy to
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
@@ -534,6 +535,7 @@ interface SoundboardSound {
|
||||
* need Nitro. Pressing one plays it to the whole channel, this end included.
|
||||
*/
|
||||
function Soundboard({ accountId }: { accountId: string }): JSX.Element {
|
||||
const media = useMediaUrl(accountId)
|
||||
const store = useStore()
|
||||
const [sounds, setSounds] = useState<SoundboardSound[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -581,7 +583,7 @@ function Soundboard({ accountId }: { accountId: string }): JSX.Element {
|
||||
onClick={() => play(s)}
|
||||
>
|
||||
{s.emojiId ? (
|
||||
<img src={`https://cdn.discordapp.com/emojis/${s.emojiId}.webp?size=32`} alt="" />
|
||||
<img src={media(`https://cdn.discordapp.com/emojis/${s.emojiId}.webp?size=32`)} alt="" />
|
||||
) : s.emojiName ? (
|
||||
<span className="soundboard-emoji">{s.emojiName}</span>
|
||||
) : (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Icon, IconButton, MaskIcon } from './Icon'
|
||||
import torMark from '../assets/tor.svg'
|
||||
@@ -172,6 +173,7 @@ export function Composer(): JSX.Element | null {
|
||||
const tunnelAll = useChat((s) => s.tunnelAll)
|
||||
const route = useChat((s) => s.route)
|
||||
const routed = tunnelAll || !!account?.useTor
|
||||
const media = useMediaUrl(account?.id)
|
||||
const hasStickers = service === 'matrix' || service === 'discord'
|
||||
/**
|
||||
* The account's stickers, asked for when the picker opens - from either
|
||||
@@ -332,7 +334,7 @@ export function Composer(): JSX.Element | null {
|
||||
if (preview) {
|
||||
const img = document.createElement('img')
|
||||
img.className = 'composer-emoji'
|
||||
img.src = resolveMediaUrl(preview.src)
|
||||
img.src = media(preview.src)
|
||||
img.alt = preview.label
|
||||
img.title = preview.label
|
||||
// What leaves the box when the message is sent. The picture is for the
|
||||
|
||||
@@ -149,6 +149,7 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
>
|
||||
{buffer.kind === 'dm' || buffer.avatarUrl ? (
|
||||
<Avatar
|
||||
accountId={buffer.accountId}
|
||||
name={buffer.name}
|
||||
url={buffer.avatarUrl}
|
||||
size={24}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
@@ -56,6 +57,7 @@ export function eventWhen(iso: string, now = new Date()): string {
|
||||
export function EventsPane(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const pane = useChat((s) => s.eventsPane)
|
||||
const media = useMediaUrl(pane?.accountId)
|
||||
const counts = useChat((s) => s.discordEventCounts)
|
||||
const [events, setEvents] = useState<DiscordEvent[] | null>(null)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; event: DiscordEvent } | null>(null)
|
||||
@@ -157,13 +159,13 @@ export function EventsPane(): JSX.Element | null {
|
||||
)}
|
||||
{events?.map((event) => (
|
||||
<div key={event.id} className={`event-card${event.live ? ' live' : ''}`}>
|
||||
{event.image && <img className="event-card-image" src={event.image} alt="" />}
|
||||
{event.image && <img className="event-card-image" src={media(event.image)} alt="" />}
|
||||
<div className="event-card-body">
|
||||
<div className="event-card-when">
|
||||
<Icon name={event.live ? 'radio_button_checked' : 'calendar_month'} size={16} />
|
||||
<span>{event.live ? 'Happening now' : eventWhen(event.start)}</span>
|
||||
<span className="events-pane-spacer" />
|
||||
{event.creatorName && <Avatar name={event.creatorName} url={event.creatorAvatar ?? undefined} size={20} />}
|
||||
{event.creatorName && <Avatar name={event.creatorName} url={event.creatorAvatar ?? undefined} size={20} accountId={pane.accountId} />}
|
||||
<span className="event-card-count" title={`${event.userCount} interested`}>
|
||||
<Icon name="group" size={14} />
|
||||
{event.userCount}
|
||||
@@ -536,7 +538,7 @@ function ShareEvent({
|
||||
const state = sent[t.userId]
|
||||
return (
|
||||
<div key={t.userId} className="share-row">
|
||||
<Avatar name={t.globalName || t.username} url={t.avatarUrl ?? undefined} size={32} />
|
||||
<Avatar name={t.globalName || t.username} url={t.avatarUrl ?? undefined} size={32} accountId={accountId} />
|
||||
<span className="share-row-name">
|
||||
<span className="ellipsis">{t.globalName || t.username}</span>
|
||||
<span className="small muted ellipsis">{t.username}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { resolveMediaUrl } from '../lib/util'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { Icon } from './Icon'
|
||||
import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format'
|
||||
import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache'
|
||||
@@ -212,6 +212,9 @@ export function EmojiPicker({
|
||||
onSticker,
|
||||
onClose
|
||||
}: Props): JSX.Element {
|
||||
// The account's emoji and stickers through its route when it is strictly
|
||||
// routed (#257) - Discord's come straight off its CDN otherwise.
|
||||
const media = useMediaUrl(accountId)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [tab, setTab] = useState<'emoji' | 'stickers'>(onSticker ? initialTab : 'emoji')
|
||||
const stickersOnly = tab === 'stickers'
|
||||
@@ -459,7 +462,7 @@ export function EmojiPicker({
|
||||
/** A sticker as a picture: an image, or a Lottie animation that plays on hover. */
|
||||
const stickerArt = (sticker: StickerEntry, size: number, play: 'hover' | 'never'): React.ReactNode =>
|
||||
sticker.url ? (
|
||||
<img src={resolveMediaUrl(sticker.url)} alt={sticker.name} loading="lazy" />
|
||||
<img src={media(sticker.url)} alt={sticker.name} loading="lazy" />
|
||||
) : sticker.lottie && sticker.id && accountId ? (
|
||||
<LottieSticker accountId={accountId} stickerId={sticker.id} size={size} play={play} label={sticker.name} />
|
||||
) : (
|
||||
@@ -502,7 +505,7 @@ export function EmojiPicker({
|
||||
id: src.id,
|
||||
name: src.name,
|
||||
icon: src.iconUrl ? (
|
||||
<img src={resolveMediaUrl(src.iconUrl)} alt="" />
|
||||
<img src={media(src.iconUrl)} alt="" />
|
||||
) : (
|
||||
<span className="emoji-jump-initial">{src.name.slice(0, 1).toUpperCase()}</span>
|
||||
)
|
||||
@@ -630,7 +633,7 @@ export function EmojiPicker({
|
||||
onClick={() => pick(r)}
|
||||
>
|
||||
{preview ? (
|
||||
<img src={resolveMediaUrl(preview.src)} alt={preview.label} />
|
||||
<img src={media(preview.src)} alt={preview.label} />
|
||||
) : (
|
||||
r
|
||||
)}
|
||||
@@ -663,7 +666,7 @@ export function EmojiPicker({
|
||||
// its own label and the section drew as a smear of overlapping
|
||||
// words instead of a table of faces.
|
||||
const art = e.url
|
||||
? resolveMediaUrl(drawableEmoteUrl(e.url))
|
||||
? media(drawableEmoteUrl(e.url))
|
||||
: src.service === 'discord'
|
||||
? discordEmojiUrl(e.id, 48)
|
||||
: emojiPreview(e.id, smilies)?.src
|
||||
@@ -680,7 +683,7 @@ export function EmojiPicker({
|
||||
title={why || e.name}
|
||||
onClick={() => pick(e.id)}
|
||||
>
|
||||
{art ? <img src={art} alt={e.name} loading="lazy" /> : e.name}
|
||||
{art ? <img src={media(art)} alt={e.name} loading="lazy" /> : e.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -747,7 +750,7 @@ export function EmojiPicker({
|
||||
// renders it and it goes out as this literal text.
|
||||
onClick={() => pick(emojiToken(e))}
|
||||
>
|
||||
<img src={emojiImage(e)} alt={e.name} />
|
||||
<img src={media(emojiImage(e))} alt={e.name} />
|
||||
</button>
|
||||
))}
|
||||
</Section>
|
||||
@@ -763,7 +766,7 @@ export function EmojiPicker({
|
||||
title={s.label}
|
||||
onClick={() => pick(s.aliases[0] || s.label)}
|
||||
>
|
||||
{s.url ? <img src={resolveMediaUrl(s.url)} alt={s.label} /> : s.aliases[0]}
|
||||
{s.url ? <img src={media(s.url)} alt={s.label} /> : s.aliases[0]}
|
||||
</button>
|
||||
))}
|
||||
</Section>
|
||||
@@ -790,7 +793,7 @@ export function EmojiPicker({
|
||||
{hovered ? (
|
||||
<>
|
||||
{hovered.src ? (
|
||||
<img src={hovered.src} alt="" />
|
||||
<img src={media(hovered.src)} alt="" />
|
||||
) : hovered.text ? (
|
||||
<span className="emoji-footer-glyph">{hovered.text}</span>
|
||||
) : null}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ForwardPicker({
|
||||
disabled={!!sending}
|
||||
onClick={() => send(buffer.id)}
|
||||
>
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} />
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} accountId={buffer.accountId} />
|
||||
<span className="ellipsis">{bufferDisplayName(buffer.name)}</span>
|
||||
<span className="small muted ellipsis">
|
||||
{account?.displayName ?? ''}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function IncomingCallPanel(): JSX.Element | null {
|
||||
const name = buffer ? bufferDisplayName(buffer.name) : 'Someone'
|
||||
return (
|
||||
<div key={call.bufferId} className="incoming-call">
|
||||
<Avatar name={name} url={buffer?.avatarUrl} size={44} />
|
||||
<Avatar name={name} url={buffer?.avatarUrl} size={44} accountId={buffer?.accountId} />
|
||||
<div className="incoming-call-who">
|
||||
<span className="ellipsis incoming-call-name">{name}</span>
|
||||
<span className="small muted ellipsis">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { ContextMenu } from './ContextMenu'
|
||||
import { RoomSearch } from './RoomSearch'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { classes, resolveMediaUrl } from '../lib/util'
|
||||
import { classes } from '../lib/util'
|
||||
import type { Account, DiscordFriend } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
@@ -658,6 +659,7 @@ function SneedchatRooms({ account }: { account: Account }): JSX.Element {
|
||||
* the friends list rather than a search box.
|
||||
*/
|
||||
function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
const media = useMediaUrl(account.id)
|
||||
const store = useStore()
|
||||
const [friends, setFriends] = useState<DiscordFriend[]>([])
|
||||
const [onlineOnly, setOnlineOnly] = useState(true)
|
||||
@@ -761,7 +763,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
{waiting.map((f) => (
|
||||
<div key={f.userId} className="friend-row request">
|
||||
{f.avatarUrl ? (
|
||||
<img className="friend-avatar" src={resolveMediaUrl(f.avatarUrl)} alt="" />
|
||||
<img className="friend-avatar" src={media(f.avatarUrl)} alt="" />
|
||||
) : (
|
||||
<span className="friend-avatar placeholder">{(f.globalName || f.username).slice(0, 1)}</span>
|
||||
)}
|
||||
@@ -885,7 +887,7 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
<Icon name={picking.includes(f.userId) ? 'check_circle' : 'radio_button_unchecked'} size={16} />
|
||||
)}
|
||||
{f.avatarUrl ? (
|
||||
<img className="friend-avatar" src={resolveMediaUrl(f.avatarUrl)} alt="" />
|
||||
<img className="friend-avatar" src={media(f.avatarUrl)} alt="" />
|
||||
) : (
|
||||
<span className="friend-avatar placeholder">{(f.globalName || f.username).slice(0, 1)}</span>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Lightbox } from './Lightbox'
|
||||
import { VoiceMessage } from './VoiceMessage'
|
||||
import { LottieSticker, lottieStickerId } from './LottieSticker'
|
||||
import { fullImageFor, knownFullImage } from '../lib/fullimage'
|
||||
import { resolveMediaUrl } from '../lib/util'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import type { MediaItem } from '../lib/format'
|
||||
import type { Attachment } from '../../../shared/wire'
|
||||
|
||||
@@ -90,6 +90,8 @@ export function MediaEmbed({
|
||||
onRefresh,
|
||||
onStale
|
||||
}: Props): JSX.Element | null {
|
||||
// Through the account's route when it is strictly routed (#257).
|
||||
const media = useMediaUrl()
|
||||
const [failed, setFailed] = useState(false)
|
||||
/**
|
||||
* The locally cached preview is gone, so fall back to the original.
|
||||
@@ -223,7 +225,7 @@ export function MediaEmbed({
|
||||
title={refreshError || 'Link expired — click to reload from Discord'}
|
||||
onClick={refresh}
|
||||
>
|
||||
<img src={resolveMediaUrl(preview)} alt={attachment?.filename || ''} loading="lazy" />
|
||||
<img src={media(preview)} alt={attachment?.filename || ''} loading="lazy" />
|
||||
<span className="expired-overlay small">
|
||||
<Icon name={refreshing ? 'hourglass_empty' : 'refresh'} size={18} />
|
||||
{refreshing ? 'Reloading…' : refreshError || 'Click to reload'}
|
||||
@@ -318,7 +320,7 @@ export function MediaEmbed({
|
||||
<img
|
||||
className="media-embed"
|
||||
style={ratio}
|
||||
src={resolveMediaUrl(showingPreview ? previewSrc : fullSrc)}
|
||||
src={media(showingPreview ? previewSrc : fullSrc)}
|
||||
alt={attachment?.filename || ''}
|
||||
title={refreshError || attachment?.filename}
|
||||
loading="lazy"
|
||||
@@ -349,7 +351,7 @@ export function MediaEmbed({
|
||||
<Lightbox
|
||||
source={{
|
||||
kind: 'image',
|
||||
src: resolveMediaUrl(expandedSrc),
|
||||
src: media(expandedSrc),
|
||||
externalUrl: openTarget,
|
||||
filename: attachment?.filename,
|
||||
width: attachment?.width,
|
||||
@@ -375,7 +377,7 @@ export function MediaEmbed({
|
||||
<video
|
||||
className="media-embed"
|
||||
style={ratio}
|
||||
src={resolveMediaUrl(fullSrc)}
|
||||
src={media(fullSrc)}
|
||||
controls
|
||||
// Enough to draw the first frame and know how long it is, without
|
||||
// pulling megabytes for every video in a scrolled-past backlog.
|
||||
@@ -397,7 +399,7 @@ export function MediaEmbed({
|
||||
<Lightbox
|
||||
source={{
|
||||
kind: 'video',
|
||||
src: resolveMediaUrl(fullSrc),
|
||||
src: media(fullSrc),
|
||||
externalUrl: openTarget,
|
||||
filename: attachment?.filename,
|
||||
loop,
|
||||
@@ -422,7 +424,7 @@ export function MediaEmbed({
|
||||
return (
|
||||
<div className="media-embed audio">
|
||||
<span className="small ellipsis">{attachment?.filename || 'audio'}</span>
|
||||
<audio src={resolveMediaUrl(fullSrc)} controls onError={() => setFailed(true)} />
|
||||
<audio src={media(fullSrc)} controls onError={() => setFailed(true)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -447,7 +449,7 @@ export function MediaEmbed({
|
||||
return (
|
||||
<button type="button" className="media-embed youtube" onClick={open} title={fullSrc}>
|
||||
<img
|
||||
src={`https://i.ytimg.com/vi/${item?.youtubeId}/hqdefault.jpg`}
|
||||
src={media(`https://i.ytimg.com/vi/${item?.youtubeId}/hqdefault.jpg`)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { accountOfBuffer } from '../lib/route'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { HeaderPopover } from './HeaderPopover'
|
||||
@@ -157,7 +158,7 @@ export function MentionsInbox(): JSX.Element {
|
||||
onClick={() => buffer && void jumpTo(buffer.id, m.id)}
|
||||
disabled={!buffer}
|
||||
>
|
||||
<Avatar name={m.from} url={m.avatarUrl} size={28} />
|
||||
<Avatar name={m.from} url={m.avatarUrl} size={28} accountId={accountOfBuffer(m.bufferId)} />
|
||||
<span className="mention-body">
|
||||
<span className="mention-head small">
|
||||
<span className="mention-from">{m.from}</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { accountOfBuffer } from '../lib/route'
|
||||
import { Icon } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
@@ -66,7 +67,7 @@ export function MentionsPage(): JSX.Element {
|
||||
}}
|
||||
disabled={!buffer}
|
||||
>
|
||||
<Avatar name={m.from} url={m.avatarUrl} size={32} />
|
||||
<Avatar name={m.from} url={m.avatarUrl} size={32} accountId={accountOfBuffer(m.bufferId)} />
|
||||
<span className="mention-body">
|
||||
<span className="mention-head small">
|
||||
<span className="mention-from">{m.from}</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CallView } from './CallView'
|
||||
import { CallStage, RoomCallBar } from './CallStage'
|
||||
import { StreamStage } from './StreamStage'
|
||||
import { ServiceBanner } from './ServiceBanner'
|
||||
import { RouteAccount, useStrictRoute } from '../lib/route'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName, isChatKind } from '../lib/util'
|
||||
import type { ChannelIndex } from '../lib/format'
|
||||
@@ -219,6 +220,7 @@ export function MessageList(): JSX.Element {
|
||||
|
||||
|
||||
const accountId = buffers.find((b) => b.id === bufferId)?.accountId || ''
|
||||
const strictRoute = useStrictRoute(accountId || undefined)
|
||||
const syncing = !!buffers.find((b) => b.id === bufferId)?.syncing
|
||||
const all = messagesByBuffer[bufferId] || []
|
||||
|
||||
@@ -644,7 +646,11 @@ export function MessageList(): JSX.Element {
|
||||
const dividerIndex = dividerTs > 0 ? messages.findIndex((m) => m.ts > dividerTs) : -1
|
||||
|
||||
return (
|
||||
<div className="messagelist">
|
||||
// Whose conversation this is, for everything drawn inside it: a strictly
|
||||
// routed account's media goes through its route (#257). The attribute is
|
||||
// for code outside React that loads pictures - the emote fallback.
|
||||
<RouteAccount.Provider value={accountId || undefined}>
|
||||
<div className="messagelist" data-route={strictRoute ? 'strict' : undefined}>
|
||||
{/* First, because it explains everything under it: a conversation that
|
||||
is not receiving looks like a quiet one otherwise. */}
|
||||
<ServiceBanner bufferId={bufferId} />
|
||||
@@ -783,6 +789,7 @@ export function MessageList(): JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</RouteAccount.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UploadMeter } from './UploadMeter'
|
||||
import { RichText } from '../lib/richtext'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
|
||||
import { useMediaUrl, useStrictRoute } from '../lib/route'
|
||||
import type { ChatMessage } from '../state/store'
|
||||
import type {
|
||||
CustomEmoji,
|
||||
@@ -47,8 +48,7 @@ import {
|
||||
isNotice,
|
||||
isReward,
|
||||
isWhisper,
|
||||
nickColor,
|
||||
resolveMediaUrl
|
||||
nickColor
|
||||
} from '../lib/util'
|
||||
|
||||
/**
|
||||
@@ -312,6 +312,10 @@ function MessageRowBody({
|
||||
inThread,
|
||||
shared
|
||||
}: Props): JSX.Element {
|
||||
// A strictly routed account's media and link checks go through its route
|
||||
// (#257); the conversation around this row says whose it is.
|
||||
const media = useMediaUrl()
|
||||
const strictRoute = useStrictRoute()
|
||||
const store = useStore()
|
||||
const { menu, open, close } = useContextMenu()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -387,7 +391,7 @@ function MessageRowBody({
|
||||
const media = extractMedia(normalized, {
|
||||
contentSniffing,
|
||||
sniffed,
|
||||
onNeedSniff: sniffUrl,
|
||||
onNeedSniff: (url) => sniffUrl(url, strictRoute),
|
||||
// Read from the un-normalised body: the pairing lives in the BBCode,
|
||||
// and normalising unwraps both tags into two unrelated bare URLs.
|
||||
thumbnails: thumbnailLinks(raw)
|
||||
@@ -424,7 +428,7 @@ function MessageRowBody({
|
||||
codeBlocks,
|
||||
quoteBlocks
|
||||
}
|
||||
}, [message.body, message.html, contentSniffing, sniffed, revealed, isSneedchat, smilieIndex, channels, service, ircColours])
|
||||
}, [message.body, message.html, contentSniffing, sniffed, revealed, isSneedchat, smilieIndex, channels, service, ircColours, strictRoute])
|
||||
|
||||
const entries: MenuEntry[] = [
|
||||
// First, above what to do about the message: the question a right click
|
||||
@@ -692,7 +696,7 @@ function MessageRowBody({
|
||||
{!grouped &&
|
||||
(message.avatarUrl ? (
|
||||
<img
|
||||
src={resolveMediaUrl(message.avatarUrl)}
|
||||
src={media(message.avatarUrl)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -895,7 +899,7 @@ function MessageRowBody({
|
||||
{embed.imageUrl && !embedMedia.claimed.has(i) && (
|
||||
<img
|
||||
className="rich-embed-image"
|
||||
src={resolveMediaUrl(embed.imageUrl)}
|
||||
src={media(embed.imageUrl)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onClick={() => embed.url && void window.moho.openExternal(embed.url)}
|
||||
@@ -1136,9 +1140,10 @@ function MessageRowBody({
|
||||
* the count.
|
||||
*/
|
||||
function ReactionEmoji({ emoji }: { emoji: string; animated?: boolean }): JSX.Element {
|
||||
const media = useMediaUrl()
|
||||
const custom = emoji.match(/^<?a?:?([A-Za-z0-9_~]{2,32}):(\d+)>?$/)
|
||||
if (!custom) return <span>{emoji}</span>
|
||||
return <img className="reaction-emoji" src={discordEmojiUrl(custom[2])} alt={custom[1]} />
|
||||
return <img className="reaction-emoji" src={media(discordEmojiUrl(custom[2]))} alt={custom[1]} />
|
||||
}
|
||||
|
||||
function moderationEntries(
|
||||
|
||||
@@ -46,7 +46,7 @@ export function ProfileCard(): JSX.Element | null {
|
||||
<div className="modal-scrim" onClick={() => store.closeProfile()}>
|
||||
<div className="profile-card" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="profile-head">
|
||||
<Avatar name={profile.name} url={profile.avatarUrl} size={40} status={profile.status} />
|
||||
<Avatar name={profile.name} url={profile.avatarUrl} size={40} status={profile.status} accountId={profile.accountId} />
|
||||
<div className="profile-title">
|
||||
<span className="profile-name ellipsis">{profile.name}</span>
|
||||
<span className="small muted">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useRef, useState } from 'react'
|
||||
import { ContextMenu, useContextMenu } from './ContextMenu'
|
||||
import { railConnecting } from '../lib/bufferlink'
|
||||
@@ -89,6 +90,7 @@ interface TileProps {
|
||||
*/
|
||||
function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: string }): JSX.Element {
|
||||
const service = serviceIcon(group.service)
|
||||
const media = useMediaUrl(group.accountId)
|
||||
// draggable={false} throughout: a picture is draggable on its own by
|
||||
// default, so taking hold of a tile by its icon dragged the icon rather
|
||||
// than the tile - which is a different gesture with different data behind
|
||||
@@ -100,7 +102,7 @@ function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: strin
|
||||
// usually has no picture and often no name, and initials taken off a raw
|
||||
// room id say nothing at all.
|
||||
if (group.kind === 'invite') return <Icon name="mark_email_unread" size={22} />
|
||||
if (group.iconUrl) return <img className="rail-icon" src={resolveMediaUrl(group.iconUrl)} alt="" draggable={false} />
|
||||
if (group.iconUrl) return <img className="rail-icon" src={media(group.iconUrl)} alt="" draggable={false} />
|
||||
if (group.kind === 'dms') return <Icon name="forum" size={22} />
|
||||
if (group.kind === 'account' && service.mark && service.colour) {
|
||||
// Artwork with colour worth keeping, shown as-is rather than flattened to
|
||||
@@ -320,7 +322,7 @@ function DmTile(props: { buffer: BufferEntry; active: boolean; onSelect: () => v
|
||||
>
|
||||
<span className={`rail-pill${active ? ' active' : ' unread'}`} />
|
||||
<span className="rail-face">
|
||||
<Avatar name={name} url={buffer.avatarUrl} size={40} />
|
||||
<Avatar name={name} url={buffer.avatarUrl} size={40} accountId={buffer.accountId} />
|
||||
</span>
|
||||
{/* A count rather than a dot: one message from someone and thirty of
|
||||
them are different situations, and this column is where that gets
|
||||
|
||||
@@ -74,7 +74,7 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element {
|
||||
onClick={open}
|
||||
title={detail ? `${name} — ${label(status)}\n${detail}` : `${name} — ${label(status)}`}
|
||||
>
|
||||
<Avatar name={name} url={account.avatarUrl} size={28} status={status} />
|
||||
<Avatar name={name} url={account.avatarUrl} size={28} status={status} accountId={account.id} />
|
||||
<span className="user-identity">
|
||||
<span className="ellipsis user-name">{name}</span>
|
||||
<span className="ellipsis small muted">{label(status)}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { resolveMediaUrl } from '../lib/util'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useStore } from '../state/hooks'
|
||||
import type { Attachment } from '../../../shared/wire'
|
||||
|
||||
@@ -66,7 +66,8 @@ export function VoiceMessage({ attachment }: { attachment: Attachment }): JSX.El
|
||||
*/
|
||||
const asked = useRef(false)
|
||||
|
||||
const src = resolveMediaUrl(attachment.path || attachment.url || '')
|
||||
const media = useMediaUrl()
|
||||
const src = media(attachment.path || attachment.url || '')
|
||||
// The sender's figure where there is one. An audio element only knows the
|
||||
// length once it has enough of the file to say, which is after the fetch
|
||||
// this display exists to let somebody avoid.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { routedUrl } from './routedurl'
|
||||
/**
|
||||
* Small local copies of Kick emotes.
|
||||
*
|
||||
@@ -128,7 +129,10 @@ export function recoverMissingEmotes(): () => void {
|
||||
const id = localEmoteId(img.src)
|
||||
if (!id) return
|
||||
local.delete(id)
|
||||
img.src = `https://files.kick.com/emotes/${id}/fullsize`
|
||||
const kick = `https://files.kick.com/emotes/${id}/fullsize`
|
||||
// Through the route when this is a strictly routed account's
|
||||
// conversation (#257) - the message list says so on itself.
|
||||
img.src = img.closest('[data-route="strict"]') ? routedUrl(kick) : kick
|
||||
requestLocalEmotes([id])
|
||||
}
|
||||
document.addEventListener('error', onError, true)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMediaUrl } from './route'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { MEDIA_SCHEME, resolveMediaUrl } from './util'
|
||||
import { MEDIA_SCHEME } from './util'
|
||||
import { isDeepLink } from '../../../shared/deeplink'
|
||||
|
||||
/**
|
||||
@@ -128,6 +129,9 @@ interface Handlers {
|
||||
onOpenLink?: RichTextProps['onOpenLink']
|
||||
/** Whether this markup is ours rather than the sender's - see safeImageSrc. */
|
||||
ownMarkup?: boolean
|
||||
/** Turns a picture's URL into the one to load - through the account's
|
||||
* route when it is strictly routed (#257). */
|
||||
media: (url: string) => string
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -149,8 +153,9 @@ export function RichText({
|
||||
// Which is a live surface rather than a theoretical one: `safeImageSrc`
|
||||
// below passes any https image through, so a formatted body can name a URL
|
||||
// its author controls and learn that the message was read - see #145.
|
||||
const media = useMediaUrl()
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
||||
return <>{walk(doc.body, { onRevealSpoiler, onOpenChannel, onOpenLink, ownMarkup }, 0)}</>
|
||||
return <>{walk(doc.body, { onRevealSpoiler, onOpenChannel, onOpenLink, ownMarkup, media }, 0)}</>
|
||||
}
|
||||
|
||||
function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
|
||||
@@ -186,7 +191,7 @@ function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
|
||||
|
||||
case 'IMG': {
|
||||
const src = el.getAttribute('src') || ''
|
||||
const resolved = safeImageSrc(src, !!ownMarkup)
|
||||
const resolved = safeImageSrc(src, !!ownMarkup, handlers.media)
|
||||
const alt = el.getAttribute('alt') || ''
|
||||
if (!resolved) {
|
||||
// Refused rather than broken: what it was called is worth keeping
|
||||
@@ -396,7 +401,7 @@ function classNameOf(el: Element): string | undefined {
|
||||
* both go through the guarded moho-media scheme. Remote https images are
|
||||
* allowed too (Discord CDN avatars and the like). Everything else is dropped.
|
||||
*/
|
||||
function safeImageSrc(src: string, remoteAllowed: boolean): string | null {
|
||||
function safeImageSrc(src: string, remoteAllowed: boolean, media: (url: string) => string): string | null {
|
||||
// A remote picture is a request to somebody else's server, made the moment
|
||||
// the message is drawn. Where this client wrote the markup that is fine and
|
||||
// wanted - a Discord emoji is a URL we built from an id. Where the *sender*
|
||||
@@ -404,7 +409,7 @@ function safeImageSrc(src: string, remoteAllowed: boolean): string | null {
|
||||
// from what address, and no chat message needs that. So the caller says
|
||||
// which kind of markup this is, and the untrusted kind gets no remote
|
||||
// images at all.
|
||||
if (/^https:\/\//i.test(src)) return remoteAllowed ? src : null
|
||||
if (/^https:\/\//i.test(src)) return remoteAllowed ? media(src) : null
|
||||
// Nothing local in markup the sender wrote - not a file:// URL, not a bare
|
||||
// path, and not a moho-media URL spelled out by hand, which would otherwise
|
||||
// walk straight past every check before the main process's own (#247).
|
||||
@@ -414,6 +419,6 @@ function safeImageSrc(src: string, remoteAllowed: boolean): string | null {
|
||||
// Already routed (a smilie url built by the store) - passing it through
|
||||
// resolveMediaUrl again would double-wrap it.
|
||||
if (src.startsWith(`${MEDIA_SCHEME}://`)) return src
|
||||
if (/^(file:\/\/|\/)/.test(src)) return resolveMediaUrl(src)
|
||||
if (/^(file:\/\/|\/)/.test(src)) return media(src)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, useCallback, useContext } from 'react'
|
||||
import { useChat } from '../state/hooks'
|
||||
import { resolveMediaUrl } from './util'
|
||||
import { routedUrl } from './routedurl'
|
||||
|
||||
/**
|
||||
* Remote media for a strictly routed account goes through its route (#257).
|
||||
*
|
||||
* An account on Tor or a SOCKS5 proxy routes its connection, but anything the
|
||||
* window loads for its conversations - a linked picture, an avatar, an emoji,
|
||||
* the check that asks a link what it is - would otherwise go out straight
|
||||
* from the user's own address, and anybody who can post a link learns it.
|
||||
* At the "strict" level every such URL is rewritten to the main process's
|
||||
* `moho-routed` scheme, which fetches it through the route and fails closed.
|
||||
*
|
||||
* Everything else is unchanged: local files still go through the media
|
||||
* scheme, and with everything tunnelled the window is already routed whole.
|
||||
*/
|
||||
|
||||
export { ROUTED_SCHEME, routedUrl } from './routedurl'
|
||||
|
||||
function isWeb(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url)
|
||||
}
|
||||
|
||||
/** The account whose conversation this is - set around a conversation, its
|
||||
* member list and its profile cards, so what is nested in them needs no
|
||||
* account passed down to it. */
|
||||
export const RouteAccount = createContext<string | undefined>(undefined)
|
||||
|
||||
/** The account a buffer belongs to: buffer ids are `<account>|<name>`. */
|
||||
export function accountOfBuffer(bufferId: string): string {
|
||||
const at = bufferId.indexOf('|')
|
||||
return at === -1 ? bufferId : bufferId.slice(0, at)
|
||||
}
|
||||
|
||||
/** Whether this account's media must go through its route. */
|
||||
export function useStrictRoute(accountId?: string): boolean {
|
||||
const fromContext = useContext(RouteAccount)
|
||||
const id = accountId ?? fromContext
|
||||
return useChat((s) => !s.tunnelAll && !!id && s.accounts.some((a) => a.id === id && a.routeLevel === 'strict'))
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to load for a picture, a video or a sound in this account's
|
||||
* conversation: a local file through the media scheme, a web URL through the
|
||||
* route when the account is strict, and anything else as it is.
|
||||
*/
|
||||
export function useMediaUrl(accountId?: string): (url: string) => string {
|
||||
const strict = useStrictRoute(accountId)
|
||||
return useCallback(
|
||||
(url: string) => {
|
||||
if (!url) return url
|
||||
if (strict && isWeb(url)) return routedUrl(url)
|
||||
return resolveMediaUrl(url)
|
||||
},
|
||||
[strict]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The main process's scheme for remote media fetched through an account's
|
||||
* route (#257) - see main/routed.ts and lib/route.tsx. Kept apart from the
|
||||
* React side so plain modules (the link check, the emote cache) can use it.
|
||||
*/
|
||||
export const ROUTED_SCHEME = 'moho-routed'
|
||||
|
||||
/** A web URL, fetched through the route instead of directly. */
|
||||
export function routedUrl(url: string): string {
|
||||
return `${ROUTED_SCHEME}://fetch/?u=${encodeURIComponent(url)}`
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import { routedUrl } from './routedurl'
|
||||
|
||||
/**
|
||||
* Content-Type probing for links that extension- and host-based detection
|
||||
@@ -40,7 +41,12 @@ function notify(): void {
|
||||
for (const l of listeners) l()
|
||||
}
|
||||
|
||||
export function sniffUrl(url: string): void {
|
||||
/**
|
||||
* `viaRoute` for a strictly routed account's link (#257): the check is a
|
||||
* request to whatever was linked, sent the moment the message arrives, so for
|
||||
* such an account it goes through the route like everything else.
|
||||
*/
|
||||
export function sniffUrl(url: string, viaRoute = false): void {
|
||||
if (resolved[url] !== undefined || inFlight.has(url)) return
|
||||
inFlight.add(url)
|
||||
|
||||
@@ -59,7 +65,7 @@ export function sniffUrl(url: string): void {
|
||||
|
||||
// HEAD rather than GET: the Content-Type header is the whole answer, and a
|
||||
// GET would pull the entire body of something that may not even be media.
|
||||
fetch(url, { method: 'HEAD', signal: controller.signal })
|
||||
fetch(viaRoute ? routedUrl(url) : url, { method: 'HEAD', signal: controller.signal })
|
||||
.then((res) => {
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
if (contentType.startsWith('image/')) finish('image')
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface Account {
|
||||
torMode?: string
|
||||
torProxy?: string
|
||||
useTor: boolean
|
||||
/**
|
||||
* Where this account's traffic goes (#257): "clearnet"; "service", the
|
||||
* connection and the media the daemon fetches for it; or "strict", that
|
||||
* and everything the window loads for its conversations.
|
||||
*/
|
||||
routeLevel?: 'clearnet' | 'service' | 'strict'
|
||||
hasKeyBackup: boolean
|
||||
/**
|
||||
* Matrix only: a media server for room calls, where the homeserver names
|
||||
|
||||
Reference in New Issue
Block a user