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:
+1
-1
Submodule nobilis updated: ce6bc94315...4c7f16a01e
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]) : []),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user