A profile card for every service, and IRC's own formatting

The WHOIS card became a profile: right-click somebody in the member list,
right-click their message, or use the button in the hover toolbar, and
the service is asked who they are. Which service decides what comes back -
IRC has an idle time and no account age, Discord has the day the account
was made and the day they joined this server, Matrix has a power level
and a last-seen - so the card draws the facts it was given rather than a
form with holes in it.

The answer arrives as an event because it is a request over a network,
and the card opens on what is already known so a menu item never appears
to do nothing.

Tab completes a name from the roster, cycles on repeated presses, and
follows a name at the start of a line with ": ". There was no completion
at all, on a protocol where people are called `[Fish]tank_`.

And IRC's own formatting codes are rendered rather than shown as control
characters: bold, italic, underline, reverse, and both the numbered and
hex colour forms, in mIRC's own palette. A setting strips them instead,
which is the switch HexChat has for the same reason. The sanitiser learnt
four more style properties to allow this, each against its own pattern -
a style attribute on somebody else's message is a large surface.
This commit is contained in:
2026-09-03 11:46:07 -04:00
parent 4fd2e0efe0
commit 0d57bd0c09
13 changed files with 662 additions and 7 deletions
+1 -1
Submodule nobilis updated: ce6bc94315...4c7f16a01e
+4
View File
@@ -10,6 +10,7 @@ import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
import { ThreadPanel } from './components/ThreadPanel'
import { ProfileCard } from './components/ProfileCard'
import { ConversationTools } from './components/ConversationTools'
import { AccountsPanel } from './components/AccountsPanel'
import { SettingsPanel } from './components/settings/SettingsPanel'
@@ -189,6 +190,9 @@ export default function App(): JSX.Element {
)}
</div>
{/* Over everything: an answer to something just asked. */}
<ProfileCard />
{/* Over everything: a call arrives while you are looking elsewhere. */}
<IncomingCallPanel />
{/* Over everything for the same reason: a file is offered while you are
+97
View File
@@ -4382,3 +4382,100 @@ video.lightbox-media {
}
}
}
/* --- profile -------------------------------------------------------------
An answer to a question, not a document: narrow, high in the window, and
gone on Escape. */
.profile-card {
width: 420px;
max-width: calc(100vw - var(--spacing-xl));
margin-top: 12vh;
align-self: flex-start;
display: flex;
flex-direction: column;
gap: var(--spacing-s);
padding: var(--spacing-m);
background: var(--surface-container);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.profile-head {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.profile-title {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.profile-name {
font-size: var(--font-size-large);
font-weight: 600;
}
.profile-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xxs);
}
.profile-role {
padding: 1px var(--spacing-xs);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
color: var(--surface-variant-text);
}
/* Label and value on one line, labels aligned, so the card is read down its
left edge rather than hunted through. */
.profile-rows {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.profile-row {
display: grid;
grid-template-columns: 7em 1fr;
gap: var(--spacing-s);
align-items: baseline;
}
.profile-row dd {
min-width: 0;
}
.profile-channels {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
/* Wraps, because a well-connected nick is in thirty channels and a row that
scrolled sideways would hide most of them. */
.profile-channel-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xxs);
max-height: 8em;
overflow-y: auto;
}
.profile-channel {
padding: 1px var(--spacing-xs);
border: 1px solid var(--outline-light);
border-radius: var(--corner-radius-small);
color: var(--surface-variant-text);
}
.profile-waiting {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
+65
View File
@@ -4,6 +4,7 @@ import { EmojiPicker } from './EmojiPicker'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
import { emojiPreview } from '../lib/format'
import { bufferDisplayName, fileNameOf, isImageFile, resolveMediaUrl } from '../lib/util'
import { completeNick, cyclePrefix, type Completion } from '../lib/completion'
interface StagedAttachment {
id: number
@@ -71,7 +72,19 @@ export function Composer(): JSX.Element | null {
const [pickerOpen, setPickerOpen] = useState(false)
const seqRef = useRef(0)
const typingSentAt = useRef(0)
/**
* Who is in this conversation, for completion. Most recent speakers first
* would be better still; the roster's own order is what is in hand, and a
* name that matches is far more use than no completion at all.
*/
const nicks = useChat((s) => s.presenceByBuffer)[buffer?.id ?? '']?.map((m) => m.nick) ?? []
const inputRef = useRef<HTMLDivElement>(null)
/**
* Where a completion cycle has got to, or null. A ref rather than state:
* changing it must not redraw the box somebody is typing in.
*/
const cycle = useRef<{ last: Completion; attempt: number } | null>(null)
const emojiButtonRef = useRef<HTMLButtonElement>(null)
const smilies = useChat((s) => s.smilies)
@@ -203,6 +216,48 @@ export function Composer(): JSX.Element | null {
})
}
/**
* Replaces the word before the caret with a roster name.
*
* Works on the text node the caret is in rather than on the component's
* `text` state, because the box is contenteditable and may hold emoji
* images: the state is the whole line, and what has to be replaced is a few
* characters inside one node of it.
*/
const completeAtCaret = (): void => {
const selection = window.getSelection()
const node = selection?.anchorNode
if (!selection || !node || node.nodeType !== Node.TEXT_NODE) return
const offset = selection.anchorOffset
const before = (node.textContent ?? '').slice(0, offset)
// A second Tab continues from the same prefix - the text now ends in the
// last completion, not in what was typed.
const continuing = cyclePrefix(before, cycle.current?.last ?? null)
const source = continuing ?? before
const attempt = continuing === null ? 0 : (cycle.current?.attempt ?? 0) + 1
const completion = completeNick(source, nicks, attempt)
if (!completion) return
const range = document.createRange()
const start = (continuing === null ? offset : offset - (cycle.current?.last.insert.length ?? 0)) - completion.replace
if (start < 0) return
range.setStart(node, start)
range.setEnd(node, offset)
range.deleteContents()
const inserted = document.createTextNode(completion.insert)
range.insertNode(inserted)
range.setStartAfter(inserted)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
cycle.current = { last: completion, attempt }
if (inputRef.current) setText(composerText(inputRef.current))
}
return (
<div className="composer">
<div className="divider-h" />
@@ -290,6 +345,16 @@ export function Composer(): JSX.Element | null {
if (e.key === 'Enter') {
e.preventDefault()
if (!e.shiftKey) submit()
return
}
// Tab completes the name being typed, and completes it again on
// the next press. Every IRC client does this and nothing here
// did: on a network where people are called `[Fish]tank_` or
// `nick|away`, typing a name exactly is most of the work of
// saying anything to anyone.
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault()
completeAtCaret()
}
}}
onPaste={onPaste}
+32 -3
View File
@@ -5,7 +5,7 @@ import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { MediaEmbed } from './MediaEmbed'
import { EmojiPicker } from './EmojiPicker'
import { RichText } from '../lib/richtext'
import { useChat, useStore } from '../state/hooks'
import { useChat, usePref, useStore } from '../state/hooks'
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
import type { ChatMessage } from '../state/store'
import type { MessageBadge, MessageReader } from '../../../shared/wire'
@@ -163,6 +163,10 @@ export function MessageRow({
// be a menu entry that reselects the buffer you are reading.
const inDirectMessage = useChat((s) => s.buffers).find((b) => b.id === bufferId)?.kind === 'dm'
const sniffed = useSniffedTypes()
// Colour is how IRC has always been written, and it is also how a bot
// shouts. Somebody who wants the words without the decoration says so here
// and the codes are removed instead of drawn.
const [ircColours] = usePref<boolean>('irc.renderColours', true)
const isSockchat = service === 'sockchat'
// Discord, Sneedchat and Matrix support editing and deleting your own
@@ -223,16 +227,31 @@ export function MessageRow({
revealedSpoilers: revealed,
isSockchat,
smilies: smilieIndex,
channels
channels,
// Only IRC's own traffic is drawn with IRC's codes. Elsewhere they
// are stripped, which is what the formatter does when not told.
ircFormatting: service === 'irc' && ircColours ? 'render' : 'strip'
}),
media,
codeBlocks,
quoteBlocks
}
}, [message.body, message.html, contentSniffing, sniffed, revealed, isSockchat, smilieIndex, channels])
}, [message.body, message.html, contentSniffing, sniffed, revealed, isSockchat, smilieIndex, channels, service, ircColours])
const entries: MenuEntry[] = [
{ label: 'Reply', icon: 'reply', onClick: () => reply() },
// From the line itself, because the person somebody wants to look up is
// almost always the one who just said something - and finding them in a
// member list of four hundred to do it is work nobody should have to do.
...(message.from && !isSystem && !message.isOwn
? ([
{
label: `Profile of ${message.from}`,
icon: 'person_search',
onClick: () => store.showProfile(bufferId, message.from, message.senderId)
}
] as MenuEntry[])
: []),
// Beside Reply because it is the same gesture aimed somewhere else, and
// only where the service has whispers at all.
...(service === 'sockchat' && !message.isOwn && message.from && !isSystem
@@ -702,6 +721,16 @@ export function MessageRow({
<button type="button" className="toolbar-button" title="Reply" onClick={reply}>
<Icon name="reply" size={15} />
</button>
{message.from && !isSystem && !message.isOwn && (
<button
type="button"
className="toolbar-button"
title={`Who is ${message.from}?`}
onClick={() => store.showProfile(bufferId, message.from, message.senderId)}
>
<Icon name="person_search" size={15} />
</button>
)}
<button type="button" className="toolbar-button" title="More" onClick={open}>
<Icon name="more_horiz" size={15} />
</button>
+8 -1
View File
@@ -294,6 +294,9 @@ export function NickList(): JSX.Element {
.catch((e: Error) => store.toast('error', `Couldn't call: ${e.message}`))
}}
onWhisper={() => store.startWhisper(member.nick)}
onProfile={
buffer ? () => store.showProfile(buffer.id, member.nick, member.userId) : undefined
}
onSendFile={() => account && void store.sendFileTo(account.id, member.nick)}
onOpenDm={() => {
if (!account) return
@@ -336,6 +339,8 @@ interface MemberRowProps {
onSendFile: () => void
/** Only where the protocol supports it and the member is identifiable. */
canCall: boolean
/** Ask the service who this is. Every service answers something. */
onProfile?: () => void
onCall: () => void
}
@@ -356,7 +361,8 @@ function MemberRow({
onWhisper,
onSendFile,
canCall,
onCall
onCall,
onProfile
}: MemberRowProps): JSX.Element {
const { menu, open, close } = useContextMenu()
@@ -364,6 +370,7 @@ function MemberRow({
// work; the server is still the actual authority and re-checks regardless.
const entries: MenuEntry[] = [
{ label: 'Mention', icon: 'alternate_email', onClick: onMention },
...(onProfile ? ([{ label: 'Profile', icon: 'person_search', onClick: onProfile }] as MenuEntry[]) : []),
...(canCall ? ([{ label: 'Call', icon: 'call', onClick: onCall }] as MenuEntry[]) : []),
...(canOpenDm ? ([{ label: 'Open DM', icon: 'chat', onClick: onOpenDm }] as MenuEntry[]) : []),
...(canWhisper ? ([{ label: 'Whisper', icon: 'lock', onClick: onWhisper }] as MenuEntry[]) : []),
+133
View File
@@ -0,0 +1,133 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Avatar } from './Avatar'
import { Icon, IconButton } from './Icon'
import { useChat, useStore } from '../state/hooks'
import { formatFullTime } from '../lib/util'
import { presenceColor, presenceLabel } from '../lib/presence'
/**
* Who somebody is, whichever service they belong to.
*
* A card rather than lines in the conversation, which is how IRC clients have
* always shown a WHOIS and is the one thing worth changing about it: this is
* an answer to a question somebody asked a second ago, not something that was
* said, and putting it in the log means scrolling past it forever afterwards.
*
* Every row is conditional because every service answers a different subset -
* IRC has an idle time and no account age, Discord has the day the account was
* made and no idleness, Matrix has a power level. A card with empty rows would
* say the service failed to answer rather than that it does not keep that.
*/
export function ProfileCard(): JSX.Element | null {
const profile = useChat((s) => s.profile)
const store = useStore()
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') store.closeProfile()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [store])
if (!profile) return null
const rows: [string, string][] = []
if (profile.handle && profile.handle !== profile.name) rows.push(['Address', profile.handle])
if (profile.away) rows.push(['Away', profile.away])
if (profile.createdTs) rows.push(['Account made', formatFullTime(profile.createdTs)])
if (profile.joinedTs) rows.push([joinedLabel(profile.service), formatFullTime(profile.joinedTs)])
if (profile.lastActiveTs) rows.push(['Last seen', formatFullTime(profile.lastActiveTs)])
if (profile.idleSeconds !== undefined) rows.push(['Idle', describeIdle(profile.idleSeconds)])
for (const extra of profile.extra ?? []) rows.push([extra.label, extra.value])
return createPortal(
<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} />
<div className="profile-title">
<span className="profile-name ellipsis">{profile.name}</span>
<span className="small muted">
{profile.status ? presenceLabel(profile.status) : profile.service}
{profile.isModerator ? ' · moderator here' : ''}
</span>
</div>
<IconButton name="close" title="Close" onClick={() => store.closeProfile()} />
</div>
{/* Their standing, said plainly. A role list is the service's own
words for it - "Operator in #channel", "Admin", a subscriber
badge - so it is shown as given rather than translated. */}
{!!profile.roles?.length && (
<div className="profile-roles">
{profile.roles.map((role) => (
<span
key={role}
className="profile-role small"
style={profile.isModerator ? { borderColor: presenceColor('dnd') } : undefined}
>
{role}
</span>
))}
</div>
)}
<dl className="profile-rows">
{rows.map(([label, value]) => (
<div key={label + value} className="profile-row">
<dt className="small muted">{label}</dt>
<dd className="ellipsis" title={value}>
{value}
</dd>
</div>
))}
</dl>
{/* Shared channels are the useful half of an IRC lookup, and on a
well-connected nick there are dozens - so they wrap. */}
{!!profile.channels?.length && (
<div className="profile-channels">
<span className="small muted">Channels</span>
<div className="profile-channel-list">
{profile.channels.map((c) => (
<span key={c} className="profile-channel small">
{c}
</span>
))}
</div>
</div>
)}
{profile.pending && (
<p className="small muted profile-waiting">
<span className="spinner" /> Asking {profile.service}…
</p>
)}
{!profile.pending && rows.length === 0 && !profile.roles?.length && !profile.channels?.length && (
<p className="small muted">
<Icon name="info" size={14} /> {profile.service} keeps nothing else about them.
</p>
)}
</div>
</div>,
document.body
)
}
/** What "joined" means on each service, in its own terms. */
function joinedLabel(service: string): string {
if (service === 'irc') return 'Connected'
if (service === 'discord') return 'Joined server'
return 'Joined'
}
/** Idle time in the units a person would say it in. */
function describeIdle(seconds: number): string {
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`
}
@@ -318,6 +318,12 @@ function IrcSettings(): JSX.Element {
/>
<ToggleSetting settingKey="irc.showNickChanges" label="Show nick changes" description='"X is now known as Y"' defaultValue={true} />
<ToggleSetting settingKey="irc.showTopicChanges" label="Show topic changes" defaultValue={true} />
<ToggleSetting
settingKey="irc.renderColours"
label="Show colours and formatting"
description="IRC's own bold, italic, underline and colour codes. Off removes them rather than showing the control characters."
defaultValue={true}
/>
<ToggleSetting
settingKey="irc.showModeChanges"
label="Show mode changes"
+59
View File
@@ -0,0 +1,59 @@
/**
* Completing a name from what has been typed so far.
*
* Kept apart from the composer because the rule is the interesting part and
* the DOM is not: what counts as the word being completed, what happens when
* several people match, and what a completed name is followed by.
*/
export interface Completion {
/** How many characters before the caret to replace. */
replace: number
/** What to put there, including whatever should follow the name. */
insert: string
/** The bare name chosen, so a caller can remember where it got to. */
nick: string
}
/**
* The next completion for the word ending at the end of `before`.
*
* `attempt` cycles: pressing Tab again offers the next match rather than the
* same one, which is the behaviour every IRC client has and the reason this
* takes an index at all.
*
* A name completed at the very start of the line is followed by ": ", because
* that is how somebody is addressed; anywhere else it is a plain space, since
* mid-sentence a colon is punctuation nobody wants.
*
* Matching is case-insensitive but the completion keeps the roster's own
* capitalisation - `jd` completes to `JDog`, not `jdog`, because a nick's
* case is part of it and getting it wrong is how a highlight fails to fire.
*/
export function completeNick(before: string, nicks: string[], attempt: number): Completion | null {
const word = before.match(/(\S+)$/)?.[1]
if (!word) return null
const lower = word.toLowerCase()
const matches = nicks.filter((n) => n.toLowerCase().startsWith(lower))
if (matches.length === 0) return null
const nick = matches[attempt % matches.length]
const atStart = before.length === word.length
return { replace: word.length, insert: nick + (atStart ? ': ' : ' '), nick }
}
/**
* The word a cycling completion should keep matching against.
*
* After the first Tab the text before the caret ends in a completed name, not
* in what was typed - so a second Tab has to remember the original prefix or
* it would start completing the completion. Returns null when the text is not
* the result of the completion it was given, which is how typing anything
* cancels the cycle.
*/
export function cyclePrefix(before: string, last: Completion | null): string | null {
if (!last) return null
const ending = last.insert
return before.endsWith(ending) ? before.slice(0, -ending.length) : null
}
+137
View File
@@ -175,6 +175,14 @@ export interface FormatOptions {
smilies?: SmilieIndex | null
/** Resolves Discord's `<#id>` channel links; without it they stay literal. */
channels?: ChannelIndex | null
/**
* What to do with IRC's own formatting codes: render them, or strip them.
*
* Absent means strip, which is what every non-IRC protocol wants - a stray
* \x02 in a Discord message is a control character somebody pasted, not
* bold.
*/
ircFormatting?: 'render' | 'strip'
}
/**
@@ -182,9 +190,138 @@ export interface FormatOptions {
* numbered in the order they appear here, so the same message always maps the
* same index to the same run.
*/
/** mIRC's sixteen colours, in their numbered order. */
const MIRC_COLOURS = [
'#ffffff', '#000000', '#00007f', '#009300', '#ff0000', '#7f0000', '#9c009c', '#fc7f00',
'#ffff00', '#00fc00', '#009393', '#00ffff', '#0000fc', '#ff00ff', '#7f7f7f', '#d2d2d2'
]
/** Every code IRC uses to mark up a line. */
const IRC_CODES = /[\u0002\u001d\u001f\u0016\u000f\u0003\u0004]/
/**
* Turns IRC's formatting codes into markup, or removes them.
*
* These are the oldest formatting in chat and moho showed them as control
* characters: `\x02` bold, `\x1d` italic, `\x1f` underline, `\x16` reverse,
* `\x0f` reset, and `\x03fg,bg` colour. On a channel where anyone uses colour
* - which is every channel with a bot in it - the text arrived with invisible
* junk in the middle of it.
*
* Rendered with inline styles rather than classes because the colours are
* numbered by the protocol, not chosen by this client; the palette is mIRC's
* own, which is what every other client draws.
*/
export function ircFormat(text: string, mode: 'render' | 'strip'): string {
if (!IRC_CODES.test(text)) return text
if (mode === 'strip') {
return text
// Colour: the code plus up to "99,99" of digits.
.replace(/\u0003\d{0,2}(,\d{1,2})?/g, '')
.replace(/\u0004[0-9a-fA-F]{6}(,[0-9a-fA-F]{6})?/g, '')
.replace(/[\u0002\u001d\u001f\u0016\u000f]/g, '')
}
let out = ''
let open = 0
let bold = false
let italic = false
let underline = false
let colour: string | null = null
let background: string | null = null
const restyle = (): void => {
// One span per change rather than nesting each attribute: the codes
// toggle independently and in any order, so a stack would have to be
// unwound out of order - which is not a thing HTML can do.
while (open > 0) {
out += '</span>'
open--
}
const styles: string[] = []
if (bold) styles.push('font-weight:600')
if (italic) styles.push('font-style:italic')
if (underline) styles.push('text-decoration:underline')
if (colour) styles.push(`color:${colour}`)
if (background) styles.push(`background:${background}`)
if (styles.length) {
out += `<span style="${styles.join(';')}">`
open++
}
}
for (let i = 0; i < text.length; i++) {
const ch = text[i]
switch (ch) {
case '\u0002':
bold = !bold
restyle()
break
case '\u001d':
italic = !italic
restyle()
break
case '\u001f':
underline = !underline
restyle()
break
case '\u0016':
// Reverse video: swap the two, which is what it means and is far
// more legible than trying to invert whatever theme is in use.
[colour, background] = [background ?? MIRC_COLOURS[0], colour ?? MIRC_COLOURS[1]]
restyle()
break
case '\u000f':
bold = italic = underline = false
colour = background = null
restyle()
break
case '\u0003': {
const match = /^(\d{1,2})?(?:,(\d{1,2}))?/.exec(text.slice(i + 1))
const [whole, fg, bg] = match ?? ['', undefined, undefined]
// A bare \x03 with no digits turns colour off, which is how a line
// ends a coloured run without resetting bold along with it.
colour = fg === undefined ? null : MIRC_COLOURS[Number(fg) % 16]
if (bg !== undefined) background = MIRC_COLOURS[Number(bg) % 16]
if (fg === undefined) background = null
i += whole.length
restyle()
break
}
// Hex colour, the newer extension: \x04RRGGBB.
case '\u0004': {
const match = /^([0-9a-fA-F]{6})(?:,([0-9a-fA-F]{6}))?/.exec(text.slice(i + 1))
if (!match) {
colour = background = null
} else {
colour = `#${match[1]}`
if (match[2]) background = `#${match[2]}`
i += match[0].length
}
restyle()
break
}
default:
out += ch
}
}
while (open > 0) {
out += '</span>'
open--
}
return out
}
export function formatMessage(text: string, opts: FormatOptions = {}): string {
if (!text) return text
const revealed = opts.revealedSpoilers || {}
// First, because the codes are invisible characters sitting anywhere in the
// line: a colour code inside a URL or between a pair of asterisks would
// otherwise break the pass that reads it. Stripped rather than rendered for
// every protocol that does not use them, where a control character is
// something somebody pasted rather than formatting.
text = ircFormat(text, opts.ircFormatting === 'render' ? 'render' : 'strip')
// Stash already-formed markup so later passes don't re-touch its contents,
// then restore at the end. Wrapped in a word marker rather than bare digits:
+36 -2
View File
@@ -69,6 +69,37 @@ const ALLOWED_CLASSES = new Set([
/** Hex or a plain CSS colour keyword - nothing that could carry a url() or expression. */
const SAFE_COLOR = /^#[0-9a-f]{3,8}$|^[a-z]{3,20}$/i
const SAFE_WEIGHT = /^(bold|[1-9]00)$/i
const SAFE_FONT_STYLE = /^italic$/i
const SAFE_DECORATION = /^underline$/i
/**
* The handful of style properties a message is allowed to set on a span.
*
* Named individually rather than passed through: this is markup derived from
* what somebody said, and a style attribute is a large surface - `position`,
* `content` and a URL in a `background` are all ways to make a message do
* something to the page around it.
*/
function safeSpanStyle(attr: string | null): React.CSSProperties | undefined {
if (!attr) return undefined
const read = (prop: string): string | undefined =>
attr.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, 'i'))?.[1]?.trim()
const style: React.CSSProperties = {}
const color = read('color')
if (color && SAFE_COLOR.test(color)) style.color = color
const background = read('background')
if (background && SAFE_COLOR.test(background)) style.background = background
const weight = read('font-weight')
if (weight && SAFE_WEIGHT.test(weight)) style.fontWeight = weight
const fontStyle = read('font-style')
if (fontStyle && SAFE_FONT_STYLE.test(fontStyle)) style.fontStyle = 'italic'
const decoration = read('text-decoration')
if (decoration && SAFE_DECORATION.test(decoration)) style.textDecoration = 'underline'
return Object.keys(style).length ? style : undefined
}
export interface RichTextProps {
html: string
@@ -227,8 +258,11 @@ function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
}
case 'SPAN': {
const color = el.getAttribute('style')?.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i)?.[1]?.trim()
const style = color && SAFE_COLOR.test(color) ? { color } : undefined
// IRC's formatting codes arrive as inline styles on a span (see
// format.ts's ircFormat), so the four properties they can produce are
// read back here - each against its own pattern, because a style
// attribute from a message is somebody else's text.
const style = safeSpanStyle(el.getAttribute('style'))
out.push(
<span key={key} className={className} style={style}>
{kids()}
+42
View File
@@ -11,6 +11,7 @@ import type {
Message,
NobilisEvent,
MessageReader,
Profile,
Reaction,
RoomPermissions,
SockchatSmilie,
@@ -223,6 +224,14 @@ export interface ChatState {
/** Per-buffer "New messages" divider timestamp, snapshotted on open. */
dividerTsByBuffer: Record<string, number>
matrixPermissions: Record<string, RoomPermissions>
/**
* The profile being shown, or null.
*
* One at a time, and held here rather than in the card: the answer arrives
* from the daemon as an event some time after the question was asked, so
* whatever draws it cannot be the thing that asked.
*/
profile: Profile | null
replyingTo: { id: string; from: string; body: string } | null
toasts: Toast[]
@@ -285,6 +294,7 @@ const INITIAL: ChatState = {
readersByBuffer: {},
openThread: null,
matrixPermissions: {},
profile: null,
replyingTo: null,
toasts: [],
smilies: [],
@@ -1165,6 +1175,25 @@ export class ChatStore {
})
}
/**
* Asks who somebody is, and opens the card on what is known meanwhile.
*
* The card opens before the answer arrives because the answer is a request
* over a network - to a homeserver, to Discord, to an IRC server that may
* take a second to reply - and a menu item that appears to do nothing for
* a second is a menu item people press twice.
*/
showProfile(bufferId: string, nick: string, userId?: string): void {
void window.moho
.rpc<Profile>('requestProfile', { bufferId, nick, ...(userId ? { userId } : {}) })
.then((opening) => this.set({ profile: opening }))
.catch((e: Error) => this.toast('error', e.message))
}
closeProfile(): void {
this.set({ profile: null })
}
private handleEvent(frame: NobilisEvent): void {
const { event, data } = frame
switch (event) {
@@ -1305,6 +1334,19 @@ export class ChatStore {
this.set({ voicePrefs: data as VoicePrefs })
break
// Somebody looked up. Shown as a card rather than as lines in the
// buffer: it is an answer to a question, not part of the conversation.
//
// Ignored when it is not about whoever is on screen - two lookups in
// quick succession would otherwise have the slower one win.
case 'profile': {
const answer = data as unknown as Profile
const open = this.state.profile
if (open && open.name.toLowerCase() !== answer.name.toLowerCase() && !answer.handle?.toLowerCase().includes(open.name.toLowerCase())) break
this.set({ profile: answer })
break
}
case 'presenceChange':
this.set({
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members }
+42
View File
@@ -377,6 +377,48 @@ export interface Message {
}
/** presenceChange's member shape. `userId`/`powerLevel` are Matrix-only. */
/**
* What is known about a person, from whichever service they belong to.
*
* Every field is optional because every service answers a different subset:
* IRC knows an idle time and no account age, Discord knows the day the
* account was made and nothing about idleness, Matrix knows a power level.
* The card draws what it was given rather than a fixed form with holes in it.
*/
export interface Profile {
service: string
accountId: string
/** The service's own id, where it has one distinct from the name. */
id?: string
/** What to call them - the display name if there is one, else the handle. */
name: string
/** The handle underneath the display name, when they differ. */
handle?: string
avatarUrl?: string
/** Unix seconds. When the account itself came into being. */
createdTs?: number
/** Unix seconds. When they joined this server, guild or room. */
joinedTs?: number
/** Unix seconds. When they were last seen doing anything. */
lastActiveTs?: number
/** Seconds since they last spoke or typed, where the service counts that. */
idleSeconds?: number
/** Their standing here, in the service's own words - "Moderator", "Op". */
roles?: string[]
/** Whether that standing includes moderating this place. */
isModerator?: boolean
/** The away message, for a service with away. */
away?: string
/** Presence word - "online", "idle", "dnd", "offline". */
status?: string
/** Rooms or channels shared with them, where the service will say. */
channels?: string[]
/** Anything else worth showing, already phrased for a person to read. */
extra?: { label: string; value: string }[]
/** Set while the service is still being asked; the card fills in. */
pending?: boolean
}
export interface Member {
nick: string
prefix?: string