diff --git a/nobilis b/nobilis index f78d18c..0a3135a 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit f78d18c85c4faf0dd241866073747522228bad04 +Subproject commit 0a3135a11a562208c2263edbbfd9d9e96e6848d9 diff --git a/src/renderer/src/components/ConversationTools.tsx b/src/renderer/src/components/ConversationTools.tsx index f9ca990..865615e 100644 --- a/src/renderer/src/components/ConversationTools.tsx +++ b/src/renderer/src/components/ConversationTools.tsx @@ -7,13 +7,19 @@ import type { BufferEntry } from '../state/store' import type { Message } from '../../../shared/wire' /** - * What a channel's mode letters mean, for the ones worth explaining. + * What a channel's restrictions mean, for the ones worth explaining. * - * Not a complete table and not trying to be: every network invents its own, - * and the ones below are the handful that change whether you can say anything. - * An unknown letter is shown as itself rather than guessed at. + * IRC writes them as letters because that is what its server says; Kick has + * no such spelling and words them instead, so anything already in words is + * passed through rather than read a letter at a time - "followers only" is + * not f, o, l, l, o, w. + * + * The letter table is not complete and is not trying to be: every network + * invents its own, and these are the handful that change whether you can say + * anything. An unknown letter is shown as itself. */ function describeModes(modes: string): string { + if (/[\s]/.test(modes) || !/^\+?[a-z]+$/i.test(modes)) return modes const meanings: Record = { m: 'moderated — only voiced users may speak', i: 'invite only', diff --git a/src/renderer/src/components/NickList.tsx b/src/renderer/src/components/NickList.tsx index 314899f..cbc44e8 100644 --- a/src/renderer/src/components/NickList.tsx +++ b/src/renderer/src/components/NickList.tsx @@ -47,6 +47,21 @@ function shortBadge(prefix: string): string { return short[prefix] ?? prefix.slice(0, 3).toUpperCase() } +/** + * What the local user may do in a Kick channel. + * + * Read off their own badge in the list, which is only there once they have + * spoken - so a moderator who has said nothing yet is offered nothing. That + * is the honest reading: nothing here knows they are a moderator until Kick + * says so, and the alternative is offering actions that come back refused. + */ +function kickPermissions(members: Member[], me: string): Record { + if (!me) return {} + const mine = members.find((m) => m.nick.toLowerCase() === me.toLowerCase())?.prefix ?? '' + const moderator = mine === 'moderator' || mine === 'broadcaster' + return { canKick: moderator, canBan: moderator, canMute: moderator } +} + /** * Why the list says what it says, for the one service where it needs saying. */ @@ -102,8 +117,16 @@ export function NickList(): JSX.Element { // prefix, including ours, so what we may do is whatever our own prefix says. // Both are advisory - the server re-checks either way and refuses if we were // wrong, exactly as any other client would be refused. + // Kick answers this from the badges it already sent: the local user is a + // moderator of a channel if their own row says so. Same shape as IRC's, + // and advisory in the same way - Kick re-checks and refuses if we were + // wrong, which it explains far better than a status code would. const perms = - account?.service === 'irc' ? ircPermissions(members, account.currentNick) : (buffer && permissions[buffer.id]) || {} + account?.service === 'irc' + ? ircPermissions(members, account.currentNick) + : account?.service === 'kick' + ? kickPermissions(members, account.currentNick || account.displayName) + : (buffer && permissions[buffer.id]) || {} const groups = useMemo(() => { const filtered = query @@ -192,6 +215,7 @@ export function NickList(): JSX.Element { canSendFile={account?.service === 'irc'} canCall={canCall && !!member.userId} perms={perms} + service={account?.service} onToggleBlock={toggleBlocked} onMention={() => store.startReply('', member.nick, '')} onModerate={(action) => { @@ -211,6 +235,22 @@ export function NickList(): JSX.Element { .catch((e: Error) => store.toast('error', e.message)) return } + if (account.service === 'kick') { + // "Kick" is a timeout on Kick - there is nothing to + // remove somebody from - and "mute" means the same thing, + // so both become the short ban that they are. + const kickAction = action === 'ban' ? 'ban' : 'timeout' + void window.moho + .rpc('moderateKickUser', { + accountId: account.id, + bufferId: buffer.id, + username: member.nick, + action: kickAction, + minutes: 10 + }) + .catch((e: Error) => store.toast('error', e.message)) + return + } if (action === 'op' || action === 'deop' || action === 'voice') { store.toast('error', 'Only IRC has channel ranks') return @@ -273,6 +313,8 @@ interface MemberRowProps { /** IRC carries a file directly between two people; nothing else here does. */ canSendFile: boolean perms: { canKick?: boolean; canBan?: boolean; canMute?: boolean; canOp?: boolean } + /** Which service this row belongs to, so an action is named as it acts. */ + service: string | undefined onToggleBlock: (key: string) => void onMention: () => void onModerate: (action: ModerationAction) => void @@ -292,6 +334,7 @@ function MemberRow({ canWhisper, canSendFile, perms, + service, onToggleBlock, onMention, onModerate, @@ -321,7 +364,16 @@ function MemberRow({ ? ([{ label: 'Mute', icon: 'volume_off', onClick: () => onModerate('mute') }] as MenuEntry[]) : []), ...(perms.canKick - ? ([{ label: 'Kick', icon: 'logout', danger: true, onClick: () => onModerate('kick') }] as MenuEntry[]) + ? ([ + { + // Named for what it does on this service. There is nobody to + // remove from a livestream chat, so on Kick it is a timeout. + label: service === 'kick' ? 'Time out for 10 minutes' : 'Kick', + icon: 'logout', + danger: true, + onClick: () => onModerate('kick') + } + ] as MenuEntry[]) : []), ...(perms.canBan ? ([{ label: 'Ban', icon: 'gavel', danger: true, onClick: () => onModerate('ban') }] as MenuEntry[])