First-run fixes from a clean install on Windows (#246)

Walking a new user's first minutes on a fresh Windows install:

- A new account's rail entry and first conversations showed nowhere until
  a restart. An account's own group is listed on request, never announced,
  so a conversation arrived naming a group the window did not know. The
  window now asks for the rail again when that happens, when an unknown
  account appears, and after removing one (whose tile also stayed).
- Kick's "Just watch" and adding an IRC network left the user on the
  accounts page looking at "select a server on the left" with nothing on
  the left. Both now go to the account's join page.
- Joining from the join page stayed on the form; joinAndOpen opens the
  conversation once it arrives (or at once if already joined), for Kick
  handles in any form and IRC channels and nicks.
- A signed-out Kick viewer was offered a message box. It now says it is
  watching signed out, with a Sign in button.
- The welcome text named "nobilis", which a new user has never heard of.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-03 21:55:43 -04:00
co-authored by Claude Opus 5.5
parent 810c9a73ce
commit e20722425c
6 changed files with 120 additions and 18 deletions
+1 -1
Submodule nobilis updated: 2f5918ec6e...8bcba8bd23
+12
View File
@@ -8384,3 +8384,15 @@ video.lightbox-media {
transition: none;
}
}
/* In place of the box, for an account that can only watch. */
.composer-watching {
display: flex;
align-items: center;
gap: var(--spacing-s);
padding: var(--spacing-s) var(--spacing-m);
}
.composer-watching .button {
margin-left: auto;
}
+26 -6
View File
@@ -137,8 +137,8 @@ export function AccountsPanel(): JSX.Element {
{accounts.length === 0 && (
<>
<p className="muted">
No accounts yet. Pick a service above to connect one — nobilis keeps the connection
alive in the background, so it survives closing this window.
No accounts yet. Pick a service above to connect one — moho stays connected in the
background, even with this window closed.
</p>
{/* Before the first account rather than after: adding one is the
first thing that fetches anything, and somebody who needs all
@@ -463,8 +463,14 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
type="button"
className="button danger"
onClick={() => {
call('removeAccount', { accountId: account.id })
void store.refreshBuffers()
// Its conversations and its rail entry with it, once it is
// gone. An account's own entry is listed for as long as the
// account exists, and nothing announces it going, so a removed
// account's tile stayed until a restart.
void window.moho
.rpc('removeAccount', { accountId: account.id })
.then(() => Promise.all([store.refreshAccounts(), store.refreshBuffers(), store.refreshGroups()]))
.catch((e: Error) => store.toast('error', e.message))
}}
>
<Icon name="delete" size={16} /> Remove account
@@ -742,6 +748,10 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element {
await store.refreshAccounts()
store.clearPendingLink()
onDone()
// On to choosing a channel, as for Kick - a network with nothing to
// join on connect is otherwise a server tab and the accounts page.
// Not when channels were named here: those arrive by themselves.
if (!autojoin.trim()) store.setActivePanel('join', `${nick}@${host}`)
} catch (e) {
store.toast('error', (e as Error).message)
} finally {
@@ -1117,8 +1127,18 @@ function KickForm({ onDone }: { onDone: () => void }): JSX.Element {
const add = (token?: string): void => {
setBusy(true)
void window.moho
.rpc('addKickAccount', token ? { token, useTor } : { useTor })
.then(() => onDone())
.rpc<{ accountId: string }>('addKickAccount', token ? { token, useTor } : { useTor })
.then((r) => {
onDone()
// Straight to choosing a streamer. A new Kick account watches
// nothing, so it has no conversations and no rail entry yet - and
// left on the accounts page, a first-time user was looking at
// "select a server on the left" with nothing on the left, the way
// forward being a small + in the footer. Signing in may bring the
// account's follows with it; this is still where the next one is
// added.
if (r?.accountId) store.setActivePanel('join', r.accountId)
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
+17
View File
@@ -548,6 +548,23 @@ export function Composer(): JSX.Element | null {
if (inputRef.current) setText(composerText(inputRef.current))
}
// A Kick account with no sign-in watches; it cannot talk. Offering the
// box anyway meant typing a message the service was always going to refuse.
if (account && account.service === 'kick' && !account.hasPassword) {
return (
<div className="composer">
<div className="divider-h" />
<div className="composer-watching small muted">
<Icon name="visibility" size={16} />
<span>Watching signed out. Sign in to Kick to talk here.</span>
<button type="button" className="button" onClick={() => store.setActivePanel('accounts')}>
Sign in
</button>
</div>
</div>
)
}
return (
<div className="composer">
<div className="divider-h" />
+4 -11
View File
@@ -122,22 +122,19 @@ function SubmitField({
function IrcJoin({ account }: { account: Account }): JSX.Element {
const store = useStore()
const call = (method: string, params: Record<string, unknown>): void => {
void window.moho.rpc(method, params).catch((e: Error) => store.toast('error', e.message))
}
return (
<div className="panel join-panel">
<SubmitField
label="Join a channel"
placeholder="#channel"
onSubmit={(name) => call('joinBuffer', { accountId: account.id, name })}
onSubmit={(name) => void store.joinAndOpen(account.id, name)}
/>
<SubmitField
label="Message someone"
placeholder="nick"
// IRC has no "open a DM" call - a query buffer only exists once
// something is sent, so this joins a buffer named for the nick.
onSubmit={(nick) => call('joinBuffer', { accountId: account.id, name: nick })}
onSubmit={(nick) => void store.joinAndOpen(account.id, nick)}
/>
<IrcChannelBrowser account={account} />
</div>
@@ -259,7 +256,7 @@ interface IrcChannelListing {
}
function join(accountId: string, name: string, store: ReturnType<typeof useStore>): void {
void window.moho.rpc('joinBuffer', { accountId, name }).catch((e: Error) => store.toast('error', e.message))
void store.joinAndOpen(accountId, name)
}
/**
@@ -279,11 +276,7 @@ function KickJoin({ account }: { account: Account }): JSX.Element {
<SubmitField
label="Watch a streamer's chat"
placeholder="handle, or a kick.com link"
onSubmit={(name) =>
void window.moho
.rpc('joinBuffer', { accountId: account.id, name })
.catch((e: Error) => store.toast('error', e.message))
}
onSubmit={(name) => void store.joinAndOpen(account.id, name)}
/>
<p className="small muted">
Kick chat is public, so this works signed out. Signing in adds talking, and your
+60
View File
@@ -1306,6 +1306,17 @@ export class ChatStore {
.catch((e: Error) => this.toast('error', e.message))
}
private groupsTimer: ReturnType<typeof setTimeout> | null = null
/** The rail again, once a burst of conversations naming new entries settles. */
refreshGroupsSoon(): void {
if (this.groupsTimer) clearTimeout(this.groupsTimer)
this.groupsTimer = setTimeout(() => {
this.groupsTimer = null
void this.refreshGroups()
}, 250)
}
/**
* The rail. Kept in nobilis's order (its own `position`, then name), which
* for Discord is the order the user arranged their servers in.
@@ -3524,6 +3535,18 @@ export class ChatStore {
return
}
this.set({ buffers: [...buffers, { unread: 0, highlight: false, ...data }] })
// Filed under a rail entry this window has never been told of. An
// account's own entry is not announced when the account is made - the
// daemon lists one for every account when asked - so the first
// conversation of an account added during this run arrived pointing at
// nothing, and showed nowhere until a restart: a new Kick viewer joined
// a streamer and watched the window not change.
if (data.groupId && !this.state.groups.some((g) => g.id === data.groupId)) this.refreshGroupsSoon()
const waiting = this.openOnArrival
if (waiting && waiting.accountId === data.accountId && data.kind !== 'server' && Date.now() < waiting.until) {
this.openOnArrival = null
void this.selectBuffer(data.id)
}
this.followPeekJoin(data)
bestEffort(window.moho.rpc('subscribe', { bufferId: data.id }), `subscribe ${data.id}`)
// A channel this client had not heard of may already hold mentions in the
@@ -3578,6 +3601,9 @@ export class ChatStore {
})
} else {
void this.refreshAccounts()
// And its rail entry, which nothing else announces: a new account
// showed nowhere on the left until its first conversation arrived.
this.refreshGroupsSoon()
}
if (data.error) this.toast('error', `${data.accountId}: ${data.error}`)
}
@@ -4444,6 +4470,40 @@ export class ChatStore {
}
/** The room that was being joined has turned up: open it for real. */
/** A join asked for from the join page, waiting for its conversation. */
private openOnArrival: { accountId: string; until: number } | null = null
/**
* Joins, and opens what was joined once it exists.
*
* The join page used to ask and stay where it was, so somebody who had just
* joined a channel - a first-time user, especially, with nothing else on
* screen - went on looking at the form, and had to find the result on the
* left for themselves. One already joined opens at once.
*/
async joinAndOpen(accountId: string, name: string): Promise<void> {
// As the conversation will be named: an IRC channel as typed, a Kick
// streamer out of whichever form the handle came in.
const typed = name.trim().toLowerCase()
const wanted = typed.startsWith('#')
? typed
: typed.replace(/^https?:\/\/(www\.)?kick\.com\//, '').replace(/^@/, '').replace(/[/?#].*$/, '')
const existing = this.state.buffers.find(
(b) => b.accountId === accountId && b.name.toLowerCase() === wanted && !isJoining(b)
)
if (existing) {
void this.selectBuffer(existing.id)
return
}
this.openOnArrival = { accountId, until: Date.now() + 20_000 }
try {
await window.moho.rpc('joinBuffer', { accountId, name })
} catch (e) {
this.openOnArrival = null
this.toast('error', (e as Error).message)
}
}
private followPeekJoin(data: { id: string; remoteId?: string }): void {
const peek = this.state.peek
if (!peek?.joining || !data.remoteId) return