feat(messaging-ui): message timestamps + group/channel settings panel (0.1.5)
- Every message now shows a Slack-style time (clock today, then 'Yesterday', weekday, else a date; full timestamp on hover). - New ConversationSettings panel for groups AND channels (public + private): rename, member list, add people (from the directory), remove, and leave. Opened from a new thread header gear; DMs show no gear. Adapter gains addMember/removeMember/renameConversation (optional, OPA still gates writes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-messaging-ui",
|
"name": "@insignia/iios-messaging-ui",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -80,4 +80,17 @@ export interface MessagingAdapter {
|
|||||||
|
|
||||||
/** Leave a channel by id. */
|
/** Leave a channel by id. */
|
||||||
leaveChannel?(threadId: string): Promise<void>;
|
leaveChannel?(threadId: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Group / channel administration (optional) ───────────────────
|
||||||
|
// Enable the settings panel for groups + channels. Absent methods hide their affordance;
|
||||||
|
// the server (OPA) still enforces who may actually add/remove/rename (admin-only).
|
||||||
|
|
||||||
|
/** Add a person to a group or private channel. */
|
||||||
|
addMember?(threadId: string, userId: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Remove a person from a group or channel. */
|
||||||
|
removeMember?(threadId: string, userId: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Rename a group or channel. */
|
||||||
|
renameConversation?(threadId: string, subject: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,21 @@ export class MockAdapter implements MessagingAdapter {
|
|||||||
return MOCK_PEOPLE.map((p) => ({ ...p }));
|
return MOCK_PEOPLE.map((p) => ({ ...p }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addMember(threadId: string, userId: string): Promise<void> {
|
||||||
|
const t = this.threads.get(threadId);
|
||||||
|
if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(threadId: string, userId: string): Promise<void> {
|
||||||
|
const t = this.threads.get(threadId);
|
||||||
|
if (t) t.participants = t.participants.filter((p) => p !== userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameConversation(threadId: string, subject: string): Promise<void> {
|
||||||
|
const t = this.threads.get(threadId);
|
||||||
|
if (t) t.subject = subject;
|
||||||
|
}
|
||||||
|
|
||||||
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
||||||
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
|
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
|
||||||
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
|
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { MessagingProvider } from '../provider';
|
||||||
|
import { MockAdapter } from '../adapters/mock';
|
||||||
|
import { ConversationSettings } from './conversation-settings';
|
||||||
|
|
||||||
|
function mount(adapter = new MockAdapter()) {
|
||||||
|
render(
|
||||||
|
<MessagingProvider adapter={adapter}>
|
||||||
|
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
|
||||||
|
</MessagingProvider>,
|
||||||
|
);
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MockAdapter member management', () => {
|
||||||
|
it('adds and removes a member, and renames', async () => {
|
||||||
|
const a = new MockAdapter();
|
||||||
|
await a.addMember('th_mock_2', 'pp_sofia');
|
||||||
|
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
|
||||||
|
await a.removeMember('th_mock_2', 'pp_sofia');
|
||||||
|
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(false);
|
||||||
|
await a.renameConversation('th_mock_2', 'Renamed');
|
||||||
|
expect((await a.listConversations()).find((c) => c.threadId === 'th_mock_2')?.title).toBe('Renamed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<ConversationSettings />', () => {
|
||||||
|
it('lists members and adds one from the directory', async () => {
|
||||||
|
const a = mount();
|
||||||
|
// A current member is shown.
|
||||||
|
await screen.findByText('Dan Whitaker');
|
||||||
|
// Add an addable person from the directory.
|
||||||
|
const sofia = await screen.findByText('Sofia Ramirez');
|
||||||
|
fireEvent.click(sofia);
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useAdapter } from '../provider';
|
||||||
|
import { ModalPortal } from './modal-portal';
|
||||||
|
import type { Person } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings for a group or channel (public + private): rename, member list, add/remove people, and
|
||||||
|
* leave. Add/remove/rename appear only when the adapter implements them AND — for the server — OPA
|
||||||
|
* allows it (admin-only); the panel is optimistic and surfaces the error if the door refuses.
|
||||||
|
*/
|
||||||
|
export function ConversationSettings({
|
||||||
|
threadId,
|
||||||
|
title,
|
||||||
|
membership,
|
||||||
|
onClose,
|
||||||
|
onLeft,
|
||||||
|
}: {
|
||||||
|
threadId: string;
|
||||||
|
title: string;
|
||||||
|
membership: 'group' | 'channel';
|
||||||
|
onClose: () => void;
|
||||||
|
onLeft?: () => void;
|
||||||
|
}) {
|
||||||
|
const adapter = useAdapter();
|
||||||
|
const [members, setMembers] = useState<Person[]>([]);
|
||||||
|
const [directory, setDirectory] = useState<Person[]>([]);
|
||||||
|
const [name, setName] = useState(title);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [nonce, setNonce] = useState(0);
|
||||||
|
|
||||||
|
const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function';
|
||||||
|
const canRename = typeof adapter.renameConversation === 'function';
|
||||||
|
const canLeave = membership === 'channel' && typeof adapter.leaveChannel === 'function';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void Promise.all([
|
||||||
|
adapter.listMembers ? adapter.listMembers(threadId) : Promise.resolve<Person[]>([]),
|
||||||
|
adapter.directory ? adapter.directory() : Promise.resolve<Person[]>([]),
|
||||||
|
]).then(([m, d]) => {
|
||||||
|
if (alive) {
|
||||||
|
setMembers(m);
|
||||||
|
setDirectory(d);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [adapter, threadId, nonce]);
|
||||||
|
|
||||||
|
const memberIds = useMemo(() => new Set(members.map((m) => m.id)), [members]);
|
||||||
|
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||||
|
|
||||||
|
async function run(fn: () => Promise<void>): Promise<void> {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
setNonce((n) => n + 1);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalPortal>
|
||||||
|
<div className="miu-modal-overlay" onMouseDown={onClose}>
|
||||||
|
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="miu-modal-head">
|
||||||
|
<span>{membership === 'channel' ? 'Channel' : 'Group'} settings</span>
|
||||||
|
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="miu-modal-body">
|
||||||
|
{canRename ? (
|
||||||
|
<div className="miu-field">
|
||||||
|
<span className="miu-field-lbl">Name</span>
|
||||||
|
<div className="miu-composer-row">
|
||||||
|
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} aria-label="Conversation name" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="miu-send"
|
||||||
|
disabled={busy || !name.trim() || name.trim() === title}
|
||||||
|
onClick={() => void run(() => adapter.renameConversation!(threadId, name.trim()))}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="miu-field">
|
||||||
|
<span className="miu-field-lbl">Members · {members.length}</span>
|
||||||
|
<div className="miu-settings-list">
|
||||||
|
{members.map((m) => (
|
||||||
|
<div key={m.id} className="miu-settings-member">
|
||||||
|
<span className="miu-settings-name">{m.name}</span>
|
||||||
|
<span className="miu-pill">{m.kind}</span>
|
||||||
|
{canManage ? (
|
||||||
|
<button type="button" className="miu-attach-x" title="Remove" disabled={busy} onClick={() => void run(() => adapter.removeMember!(threadId, m.id))}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<div className="miu-field">
|
||||||
|
<span className="miu-field-lbl">Add people</span>
|
||||||
|
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
|
||||||
|
<div className="miu-settings-list">
|
||||||
|
{addable.length === 0 ? <div className="miu-empty">No one to add.</div> : null}
|
||||||
|
{addable.slice(0, 25).map((p) => (
|
||||||
|
<button key={p.id} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void run(() => adapter.addMember!(threadId, p.id))}>
|
||||||
|
<span className="miu-settings-name">{p.name}</span>
|
||||||
|
<span className="miu-pill">{p.kind}</span>
|
||||||
|
<span className="miu-settings-plus" aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="miu-modal-foot">
|
||||||
|
{canLeave ? (
|
||||||
|
<button type="button" className="miu-tab" disabled={busy} onClick={() => void run(async () => { await adapter.leaveChannel!(threadId); onLeft?.(); onClose(); })}>
|
||||||
|
Leave {membership}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<button type="button" className="miu-send" onClick={onClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@ const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
|||||||
|
|
||||||
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
||||||
|
|
||||||
|
/** Slack-style message time: today shows the clock, then "Yesterday", weekday, else a date. */
|
||||||
|
function messageTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(+d)) return '';
|
||||||
|
const now = new Date();
|
||||||
|
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (d.toDateString() === now.toDateString()) return time;
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(now.getDate() - 1);
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return `Yesterday ${time}`;
|
||||||
|
if (now.getTime() - d.getTime() < 7 * 86400000) return `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
|
||||||
|
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
function AttachmentView({ att }: { att: Attachment }) {
|
function AttachmentView({ att }: { att: Attachment }) {
|
||||||
if (isImage(att.mime)) {
|
if (isImage(att.mime)) {
|
||||||
return (
|
return (
|
||||||
@@ -54,6 +68,9 @@ export function MessageItem({
|
|||||||
{m.text ? highlightMentions(m.text, memberNames) : null}
|
{m.text ? highlightMentions(m.text, memberNames) : null}
|
||||||
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
|
||||||
|
{messageTime(m.at)}
|
||||||
|
</time>
|
||||||
<div className="miu-msg-actions">
|
<div className="miu-msg-actions">
|
||||||
{canReact ? (
|
{canReact ? (
|
||||||
<div className="miu-react-wrap">
|
<div className="miu-react-wrap">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function Messenger() {
|
|||||||
|
|
||||||
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
||||||
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
||||||
|
const selectedConversation = useMemo(() => conversations.find((c) => c.threadId === selected) ?? null, [conversations, selected]);
|
||||||
|
|
||||||
function pick(threadId: string): void {
|
function pick(threadId: string): void {
|
||||||
setBrowsing(false);
|
setBrowsing(false);
|
||||||
@@ -104,7 +105,16 @@ export function Messenger() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
|
<Thread
|
||||||
|
threadId={selected}
|
||||||
|
conversation={selectedConversation}
|
||||||
|
activeRootId={activeRoot}
|
||||||
|
onOpenThread={setActiveRoot}
|
||||||
|
onLeft={() => {
|
||||||
|
refetch();
|
||||||
|
setSelected(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMessages } from '../hooks/use-messages';
|
import { useMessages } from '../hooks/use-messages';
|
||||||
import { useMembers } from '../hooks/use-members';
|
import { useMembers } from '../hooks/use-members';
|
||||||
import { Composer } from './composer';
|
import { Composer } from './composer';
|
||||||
import { MessageItem } from './message-item';
|
import { MessageItem } from './message-item';
|
||||||
|
import { ConversationSettings } from './conversation-settings';
|
||||||
|
import type { Conversation } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The main conversation view: top-level messages + composer. Replies (messages with a
|
* The main conversation view: top-level messages + composer. Replies (messages with a
|
||||||
@@ -11,16 +13,24 @@ import { MessageItem } from './message-item';
|
|||||||
*/
|
*/
|
||||||
export function Thread({
|
export function Thread({
|
||||||
threadId,
|
threadId,
|
||||||
|
conversation,
|
||||||
activeRootId,
|
activeRootId,
|
||||||
onOpenThread,
|
onOpenThread,
|
||||||
|
onLeft,
|
||||||
}: {
|
}: {
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
conversation?: Conversation | null;
|
||||||
activeRootId?: string | null;
|
activeRootId?: string | null;
|
||||||
onOpenThread?: (rootId: string) => void;
|
onOpenThread?: (rootId: string) => void;
|
||||||
|
onLeft?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
||||||
const members = useMembers(threadId);
|
const members = useMembers(threadId);
|
||||||
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
const membership = conversation?.membership;
|
||||||
|
const manageable = membership === 'group' || membership === 'channel';
|
||||||
|
|
||||||
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
|
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
|
||||||
const replyCount = useMemo(() => {
|
const replyCount = useMemo(() => {
|
||||||
@@ -35,6 +45,19 @@ export function Thread({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
|
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
|
||||||
|
{conversation ? (
|
||||||
|
<div className="miu-thread-head">
|
||||||
|
<span className="miu-thread-title">
|
||||||
|
{membership === 'channel' ? '# ' : ''}
|
||||||
|
{conversation.title || 'Conversation'}
|
||||||
|
</span>
|
||||||
|
{manageable ? (
|
||||||
|
<button type="button" className="miu-thread-settings" title="Settings" aria-label="Conversation settings" onClick={() => setSettingsOpen(true)}>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="miu-messages">
|
<div className="miu-messages">
|
||||||
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||||
@@ -56,6 +79,16 @@ export function Thread({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
|
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
|
||||||
|
|
||||||
|
{settingsOpen && manageable && conversation ? (
|
||||||
|
<ConversationSettings
|
||||||
|
threadId={conversation.threadId}
|
||||||
|
title={conversation.title || ''}
|
||||||
|
membership={membership as 'group' | 'channel'}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
{...(onLeft ? { onLeft } : {})}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,80 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.miu-msg-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* thread header + group/channel settings */
|
||||||
|
.miu-thread-head {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--miu-border);
|
||||||
|
}
|
||||||
|
.miu-thread-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.miu-thread-settings {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.miu-thread-settings:hover {
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
color: var(--miu-text);
|
||||||
|
}
|
||||||
|
.miu-settings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.miu-settings-member {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--miu-panel);
|
||||||
|
color: var(--miu-text);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.miu-settings-member.is-add {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.miu-settings-member.is-add:hover {
|
||||||
|
border-color: var(--miu-accent);
|
||||||
|
}
|
||||||
|
.miu-settings-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.miu-settings-plus {
|
||||||
|
color: var(--miu-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.miu-thread-link {
|
.miu-thread-link {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
|
|||||||
Reference in New Issue
Block a user