A page for every mention, across every service
Issue #10. Sits with the other gathered pages at the head of the column, and is always offered rather than appearing once something arrives - an inbox you need mail to find is not one anybody would look in. Each row names who, where and when, and opens that conversation scrolled to the message itself, which is the only reason to click one. A muted conversation is left out. It was told not to ask for attention, and a page whose whole purpose is asking for attention would be the one place that instruction got ignored. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 2f9c2bc7ff...e0c66ca0c9
@@ -3,6 +3,8 @@ import { TitleBar } from './components/TitleBar'
|
||||
import { BufferList } from './components/BufferList'
|
||||
import { ServerRail } from './components/ServerRail'
|
||||
import { MessageList } from './components/MessageList'
|
||||
import { MentionsPage } from './components/MentionsPage'
|
||||
import { MENTIONS_GROUP_ID } from './lib/groups'
|
||||
import { Composer } from './components/Composer'
|
||||
import { NickList } from './components/NickList'
|
||||
import { ConversationTools } from './components/ConversationTools'
|
||||
@@ -26,6 +28,7 @@ export default function App(): JSX.Element {
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const activePanel = useChat((s) => s.activePanel)
|
||||
const activeBufferId = useChat((s) => s.activeBufferId)
|
||||
const activeGroupId = useChat((s) => s.activeGroupId)
|
||||
const joinPanelAccountId = useChat((s) => s.joinPanelAccountId)
|
||||
const buffer = useActiveBuffer()
|
||||
const joinAccount = accounts.find((a) => a.id === joinPanelAccountId)
|
||||
@@ -56,7 +59,9 @@ export default function App(): JSX.Element {
|
||||
? 'Settings'
|
||||
: activePanel === 'join'
|
||||
? `Join · ${joinAccount?.displayName ?? ''}`
|
||||
: buffer
|
||||
: activeGroupId === MENTIONS_GROUP_ID
|
||||
? 'Mentions'
|
||||
: buffer
|
||||
? bufferDisplayName(buffer.name)
|
||||
: ''
|
||||
|
||||
@@ -111,10 +116,17 @@ export default function App(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="main-body">
|
||||
<Body activePanel={activePanel} hasAccounts={accounts.length > 0} hasBuffer={!!activeBufferId} />
|
||||
<Body
|
||||
activePanel={activePanel}
|
||||
hasAccounts={accounts.length > 0}
|
||||
hasBuffer={!!activeBufferId}
|
||||
activeGroupId={activeGroupId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activePanel === '' && activeBufferId !== '' && <Composer />}
|
||||
{activePanel === '' && activeBufferId !== '' && activeGroupId !== MENTIONS_GROUP_ID && (
|
||||
<Composer />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showNickList && (
|
||||
@@ -156,17 +168,22 @@ function BufferFace({ buffer }: { buffer: BufferEntry }): JSX.Element | null {
|
||||
function Body({
|
||||
activePanel,
|
||||
hasAccounts,
|
||||
hasBuffer
|
||||
hasBuffer,
|
||||
activeGroupId
|
||||
}: {
|
||||
activePanel: string
|
||||
hasAccounts: boolean
|
||||
hasBuffer: boolean
|
||||
activeGroupId: string
|
||||
}): JSX.Element {
|
||||
if (activePanel === 'settings') return <SettingsPanel />
|
||||
// With no accounts at all, the accounts panel is the only useful thing to
|
||||
// show - there is nothing to chat in yet.
|
||||
if (activePanel === 'accounts' || !hasAccounts) return <AccountsPanel />
|
||||
if (activePanel === 'join') return <JoinPanel />
|
||||
// The mentions page replaces the log rather than sitting beside it: it is a
|
||||
// list of places to go, and every row leads into a conversation.
|
||||
if (activeGroupId === MENTIONS_GROUP_ID) return <MentionsPage />
|
||||
if (hasBuffer) return <MessageList />
|
||||
return <Placeholder icon="forum" text="Select or join a channel" />
|
||||
}
|
||||
|
||||
@@ -914,6 +914,70 @@ img.composer-emoji {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* --- mentions ---------------------------------------------------------- */
|
||||
|
||||
.mentions-page {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-s) 0;
|
||||
}
|
||||
|
||||
.mentions-page.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
.mention-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-s);
|
||||
width: 100%;
|
||||
padding: var(--spacing-s) var(--spacing-m);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mention-row:hover {
|
||||
background: var(--outline-light);
|
||||
}
|
||||
|
||||
.mention-row[disabled] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.mention-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mention-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-s);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mention-from {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Pushed to the far edge: the when is the least of the three things on this
|
||||
line, and putting it last keeps the who and the where together. */
|
||||
.mention-when {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.mention-text {
|
||||
color: var(--surface-variant-text);
|
||||
}
|
||||
|
||||
/* --- incoming calls ---------------------------------------------------- */
|
||||
|
||||
/* Bottom left, above the account plaque, which is where Discord puts it and
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { useChat, usePref, useStore } from '../state/hooks'
|
||||
import { isMutedBuffer } from '../lib/groups'
|
||||
import { bufferDisplayName, formatFullTime, serviceLabel } from '../lib/util'
|
||||
import type { Message } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* Everything that mentioned you, from every service, newest first.
|
||||
*
|
||||
* The point of gathering these is that a mention worth answering is usually in
|
||||
* a channel nobody has open - so it is asked of the daemon rather than
|
||||
* assembled from what this window happens to have loaded, which is only ever
|
||||
* the conversations already being read.
|
||||
*
|
||||
* What counts as a mention is decided where the message arrives, by the
|
||||
* backend that can actually tell: Discord answers it from its own resolved
|
||||
* mentions array, and IRC from the nickname, because that is all IRC has.
|
||||
*/
|
||||
export function MentionsPage(): JSX.Element {
|
||||
const store = useStore()
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const [muted] = usePref<string[]>('mutedBuffers', [])
|
||||
const [mutedGroups] = usePref<string[]>('mutedGroups', [])
|
||||
const [rows, setMentions] = useState<Message[] | null>(null)
|
||||
|
||||
// A muted conversation was told not to ask for attention. Collecting its
|
||||
// mentions into a page whose entire purpose is asking for attention would
|
||||
// be the one place that instruction is ignored.
|
||||
const mentions =
|
||||
rows?.filter((m) => {
|
||||
const buffer = buffers.find((b) => b.id === m.bufferId)
|
||||
return !buffer || !isMutedBuffer(buffer, muted, mutedGroups)
|
||||
}) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<Message[]>('getMentions', { limit: 100 })
|
||||
.then((rows) => live && setMentions(rows))
|
||||
.catch(() => live && setMentions([]))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (mentions === null) {
|
||||
return <div className="mentions-page muted">Looking…</div>
|
||||
}
|
||||
if (mentions.length === 0) {
|
||||
return (
|
||||
<div className="mentions-page empty muted">
|
||||
<Icon name="alternate_email" size={32} />
|
||||
<p>Nothing has mentioned you yet.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mentions-page">
|
||||
{mentions.map((m) => {
|
||||
const buffer = buffers.find((b) => b.id === m.bufferId)
|
||||
const account = accounts.find((a) => a.id === buffer?.accountId)
|
||||
// A mention whose conversation this client has since forgotten still
|
||||
// shows: the message is the point, and refusing to draw it because the
|
||||
// buffer list has moved on would hide the very thing being collected.
|
||||
const where = buffer ? bufferDisplayName(buffer.name) : 'a closed conversation'
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className="mention-row"
|
||||
// Opens the conversation and scrolls to the message itself, which
|
||||
// is the only reason to click one of these.
|
||||
onClick={() => {
|
||||
if (!buffer) return
|
||||
void store.selectBuffer(buffer.id, true).then(() => store.jumpToMessage(buffer.id, m.id).then(() => store.setJumpTarget(m.id)))
|
||||
}}
|
||||
disabled={!buffer}
|
||||
>
|
||||
<Avatar name={m.from} url={m.avatarUrl} size={32} />
|
||||
<span className="mention-body">
|
||||
<span className="mention-head small">
|
||||
<span className="mention-from">{m.from}</span>
|
||||
<span className="muted">
|
||||
in {where}
|
||||
{account ? ` · ${serviceLabel(account.service)}` : ''}
|
||||
</span>
|
||||
<span className="muted mention-when">{formatFullTime(m.ts)}</span>
|
||||
</span>
|
||||
<span className="mention-text ellipsis">{m.body}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
countsTowardRail,
|
||||
dmGroup,
|
||||
DM_GROUP_ID,
|
||||
mentionsGroup,
|
||||
isDirectMessage,
|
||||
type RailGroup
|
||||
} from '../lib/groups'
|
||||
@@ -84,6 +85,7 @@ function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: strin
|
||||
const service = serviceIcon(group.service)
|
||||
if (customIcon) return <img className="rail-icon" src={resolveMediaUrl(customIcon)} alt="" />
|
||||
if (group.kind === 'pinned') return <Icon name="push_pin" size={22} />
|
||||
if (group.kind === 'mentions') return <Icon name="alternate_email" size={22} />
|
||||
if (group.iconUrl) return <img className="rail-icon" src={resolveMediaUrl(group.iconUrl)} alt="" />
|
||||
if (group.kind === 'dms') return <Icon name="forum" size={22} />
|
||||
if (group.kind === 'account' && service.mark && service.colour) {
|
||||
@@ -345,6 +347,9 @@ export function ServerRail(): JSX.Element | null {
|
||||
const extras: RailGroup[] = []
|
||||
// Only worth a tile once there is something on it.
|
||||
if (all.some(isDirectMessage)) extras.push(dmGroup())
|
||||
// Always offered rather than only once something has arrived: an inbox you
|
||||
// have to already have mail to find is not one you would look in.
|
||||
extras.push(mentionsGroup())
|
||||
if (pinned.length > 0) extras.push(pinnedGroup())
|
||||
const ordered = orderedGroups([...shown, ...extras], railOrder)
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { BufferEntry } from '../state/store'
|
||||
* widening the wire type, which should only describe what the daemon
|
||||
* actually sends.
|
||||
*/
|
||||
export type RailGroup = Omit<BufferGroup, 'kind'> & { kind: BufferGroup['kind'] | 'pinned' }
|
||||
export type RailGroup = Omit<BufferGroup, 'kind'> & {
|
||||
kind: BufferGroup['kind'] | 'pinned' | 'mentions'
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail entry collecting pinned buffers from every service.
|
||||
@@ -18,6 +20,8 @@ export type RailGroup = Omit<BufferGroup, 'kind'> & { kind: BufferGroup['kind']
|
||||
* exactly like any other entry.
|
||||
*/
|
||||
export const PINNED_GROUP_ID = '~pinned'
|
||||
/** The page gathering every mention, across every service. */
|
||||
export const MENTIONS_GROUP_ID = '~mentions'
|
||||
|
||||
/**
|
||||
* The rail entry collecting direct messages from every service.
|
||||
@@ -58,6 +62,17 @@ export function pinnedGroup(): RailGroup {
|
||||
}
|
||||
}
|
||||
|
||||
export function mentionsGroup(): RailGroup {
|
||||
return {
|
||||
id: MENTIONS_GROUP_ID,
|
||||
accountId: '',
|
||||
service: '',
|
||||
kind: 'mentions',
|
||||
name: 'Mentions',
|
||||
position: -950
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail entries worth drawing.
|
||||
*
|
||||
@@ -93,7 +108,7 @@ export function visibleGroups(groups: BufferGroup[], buffers: BufferEntry[]): Bu
|
||||
|
||||
/** Entries the user cannot drag, and which always lead the rail. */
|
||||
export function isFixedEntry(group: RailGroup): boolean {
|
||||
return group.kind === 'dms' || group.kind === 'pinned'
|
||||
return group.kind === 'dms' || group.kind === 'pinned' || group.kind === 'mentions'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +125,8 @@ export function orderedGroups(groups: RailGroup[], order: string[]): RailGroup[]
|
||||
// Direct messages, then pinned. Stated as a rule rather than left to the
|
||||
// position numbers, which are the backend's business and were never meant
|
||||
// to encode this.
|
||||
const leadRank = (g: RailGroup): number => (g.kind === 'dms' ? 0 : 1)
|
||||
const leadRank = (g: RailGroup): number =>
|
||||
g.kind === 'dms' ? 0 : g.kind === 'mentions' ? 1 : 2
|
||||
const lead = groups
|
||||
.filter(isFixedEntry)
|
||||
.sort((a, b) => leadRank(a) - leadRank(b) || a.position - b.position)
|
||||
|
||||
Reference in New Issue
Block a user