Improve chat density and member invite UX (#83)

This commit is contained in:
Wes
2026-03-16 14:05:45 -07:00
committed by GitHub
parent c8dd3890fd
commit 5a6a025216
23 changed files with 930 additions and 279 deletions

View File

@@ -697,6 +697,15 @@ impl Db {
user::get_user(&self.pool, pubkey).await
}
/// Search users by display name, NIP-05 handle, or pubkey prefix.
pub async fn search_users(
&self,
query: &str,
limit: u32,
) -> Result<Vec<user::UserSearchProfile>> {
user::search_users(&self.pool, query, limit).await
}
/// Update a user's display_name, avatar_url, about, and/or nip05_handle.
pub async fn update_user_profile(
&self,

View File

@@ -19,6 +19,19 @@ pub struct UserProfile {
pub nip05_handle: Option<String>,
}
/// Lightweight user record returned from search.
#[derive(Debug, Clone)]
pub struct UserSearchProfile {
/// Raw 32-byte compressed public key.
pub pubkey: Vec<u8>,
/// Human-readable display name chosen by the user.
pub display_name: Option<String>,
/// URL of the user's avatar image.
pub avatar_url: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05_handle: Option<String>,
}
/// Ensure a user record exists for the given pubkey (upsert).
/// Creates with minimal fields if not present; no-op if already exists.
pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> {
@@ -167,6 +180,70 @@ pub async fn get_user_by_nip05(
))
}
/// Search users by display name, NIP-05 handle, or pubkey prefix.
///
/// Empty queries return an empty vec and do not hit the database.
pub async fn search_users(
pool: &MySqlPool,
query: &str,
limit: u32,
) -> Result<Vec<UserSearchProfile>> {
let normalized = query.trim().to_lowercase();
if normalized.is_empty() {
return Ok(Vec::new());
}
let contains_pattern = format!("%{normalized}%");
let prefix_pattern = format!("{normalized}%");
let limit = limit.clamp(1, 50) as i64;
let rows = sqlx::query_as::<_, (Vec<u8>, Option<String>, Option<String>, Option<String>)>(
r#"
SELECT pubkey, display_name, avatar_url, nip05_handle
FROM users
WHERE LOWER(COALESCE(display_name, '')) LIKE ?
OR LOWER(COALESCE(nip05_handle, '')) LIKE ?
OR LOWER(HEX(pubkey)) LIKE ?
ORDER BY
CASE
WHEN LOWER(COALESCE(display_name, '')) = ? THEN 0
WHEN LOWER(COALESCE(nip05_handle, '')) = ? THEN 1
WHEN LOWER(HEX(pubkey)) = ? THEN 2
WHEN LOWER(COALESCE(display_name, '')) LIKE ? THEN 3
WHEN LOWER(COALESCE(nip05_handle, '')) LIKE ? THEN 4
WHEN LOWER(HEX(pubkey)) LIKE ? THEN 5
ELSE 6
END,
COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(HEX(pubkey)))
LIMIT ?
"#,
)
.bind(&contains_pattern)
.bind(&contains_pattern)
.bind(&contains_pattern)
.bind(&normalized)
.bind(&normalized)
.bind(&normalized)
.bind(&prefix_pattern)
.bind(&prefix_pattern)
.bind(&prefix_pattern)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(
|(pubkey, display_name, avatar_url, nip05_handle)| UserSearchProfile {
pubkey,
display_name,
avatar_url,
nip05_handle,
},
)
.collect())
}
/// Set the owner pubkey for an agent user.
/// The owner pubkey must already exist in the users table (FK constraint).
/// Returns an error if the agent pubkey is not found (rows_affected == 0).

View File

@@ -70,7 +70,8 @@ pub use presence::{presence_handler, set_presence_handler};
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
pub use search::search_handler;
pub use users::{
get_profile, get_user_profile, get_users_batch, put_channel_add_policy, update_profile,
get_profile, get_user_profile, get_users_batch, put_channel_add_policy, search_users,
update_profile,
};
pub use workflows::{
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs,

View File

@@ -5,11 +5,12 @@
//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about, nip05_handle)
//! GET /api/users/{pubkey}/profile — get any user's profile by pubkey hex
//! POST /api/users/batch — resolve display names for multiple pubkeys
//! GET /api/users/search — search users by display name, NIP-05, or pubkey
use std::sync::Arc;
use axum::{
extract::{Json as ExtractJson, Path, State},
extract::{Json as ExtractJson, Path, Query, State},
http::{HeaderMap, StatusCode},
response::Json,
};
@@ -173,6 +174,15 @@ pub struct BatchProfilesRequest {
pub pubkeys: Vec<String>,
}
/// Query string for user search.
#[derive(Debug, Deserialize)]
pub struct SearchUsersQuery {
/// Case-insensitive search query.
pub q: String,
/// Maximum number of results to return.
pub limit: Option<u32>,
}
/// `POST /api/users/batch` — resolve profile summaries for multiple pubkeys.
pub async fn get_users_batch(
State(state): State<Arc<AppState>>,
@@ -256,6 +266,38 @@ pub async fn get_users_batch(
})))
}
/// `GET /api/users/search` — search users by display name, NIP-05, or pubkey prefix.
pub async fn search_users(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<SearchUsersQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
let q = query.q.trim();
if q.is_empty() {
return Ok(Json(serde_json::json!({ "users": [] })));
}
let results = state
.db
.search_users(q, query.limit.unwrap_or(8))
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
Ok(Json(serde_json::json!({
"users": results.into_iter().map(|user| {
serde_json::json!({
"pubkey": nostr_hex::encode(&user.pubkey),
"display_name": user.display_name,
"avatar_url": user.avatar_url,
"nip05_handle": user.nip05_handle,
})
}).collect::<Vec<_>>(),
})))
}
/// Request body for updating channel add policy.
#[derive(Debug, Deserialize)]
pub struct UpdateChannelAddPolicyBody {

View File

@@ -161,6 +161,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
"/api/users/me/channel-add-policy",
put(api::put_channel_add_policy),
)
.route("/api/users/search", get(api::search_users))
.route("/api/users/{pubkey}/profile", get(api::get_user_profile))
.route("/api/users/batch", post(api::get_users_batch))
// Feed route

View File

@@ -7,8 +7,8 @@ use tauri::State;
use crate::{
app_state::AppState,
models::{
GetUsersBatchBody, ProfileInfo, SetPresenceBody, SetPresenceResponse, UpdateProfileBody,
UsersBatchResponse,
GetUsersBatchBody, ProfileInfo, SearchUsersResponse, SetPresenceBody, SetPresenceResponse,
UpdateProfileBody, UsersBatchResponse,
},
relay::{build_authed_request, send_empty_request, send_json_request},
};
@@ -82,6 +82,25 @@ pub async fn get_users_batch(
send_json_request(request).await
}
#[tauri::command]
pub async fn search_users(
query: String,
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchUsersResponse, String> {
let limit = limit.unwrap_or(8);
let limit_param = limit.to_string();
let request = build_authed_request(
&state.http_client,
Method::GET,
"/api/users/search",
&state,
)?
.query(&[("q", query.as_str()), ("limit", limit_param.as_str())]);
send_json_request(request).await
}
#[tauri::command]
pub async fn get_presence(
pubkeys: Vec<String>,

View File

@@ -27,6 +27,7 @@ pub fn run() {
update_profile,
get_user_profile,
get_users_batch,
search_users,
get_presence,
set_presence,
get_relay_ws_url,

View File

@@ -32,6 +32,19 @@ pub struct UsersBatchResponse {
pub missing: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UserSearchResultInfo {
pub pubkey: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct SearchUsersResponse {
pub users: Vec<UserSearchResultInfo>,
}
#[derive(Serialize, Deserialize)]
pub struct SetPresenceResponse {
pub status: PresenceStatus,

View File

@@ -10,7 +10,6 @@ import {
MessageSquare,
Shield,
User,
UserPlus,
Users,
} from "lucide-react";
import * as React from "react";
@@ -43,6 +42,7 @@ import {
SheetTitle,
} from "@/shared/ui/sheet";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard";
type ChannelManagementSheetProps = {
channel: Channel | null;
@@ -52,13 +52,6 @@ type ChannelManagementSheetProps = {
open: boolean;
};
const roleOptions: Array<Exclude<ChannelMember["role"], "owner">> = [
"member",
"admin",
"guest",
"bot",
];
const roleOrder: Record<ChannelMember["role"], number> = {
owner: 0,
admin: 1,
@@ -197,9 +190,6 @@ export function ChannelManagementSheet({
const [descriptionDraft, setDescriptionDraft] = React.useState("");
const [topicDraft, setTopicDraft] = React.useState("");
const [purposeDraft, setPurposeDraft] = React.useState("");
const [invitePubkeys, setInvitePubkeys] = React.useState("");
const [inviteRole, setInviteRole] =
React.useState<Exclude<ChannelMember["role"], "owner">>("member");
// Sync drafts from server only when the sheet opens or the channel changes —
// not on every background refetch, which would clobber in-flight edits.
@@ -232,11 +222,6 @@ export function ChannelManagementSheet({
const resolvedChannel = detail ?? channel;
const parsedInvitePubkeys = invitePubkeys
.split(/[\s,]+/)
.map((value) => value.trim())
.filter((value) => value.length > 0);
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
@@ -499,92 +484,17 @@ export function ChannelManagementSheet({
title="Members"
>
{canManageChannel && resolvedChannel.channelType !== "dm" ? (
<form
className="space-y-3 rounded-2xl border border-border/80 bg-muted/20 p-4"
onSubmit={(event) => {
event.preventDefault();
void addMembersMutation
.mutateAsync({
pubkeys: parsedInvitePubkeys,
role: inviteRole,
})
.then((result) => {
if (result.errors.length === 0) {
setInvitePubkeys("");
}
});
}}
>
<div className="flex items-center gap-2 text-sm font-medium">
<UserPlus className="h-4 w-4" />
Invite members
</div>
<Textarea
className="min-h-24"
data-testid="channel-management-add-pubkeys"
disabled={addMembersMutation.isPending}
onChange={(event) => setInvitePubkeys(event.target.value)}
placeholder="Paste one or more pubkeys, separated by spaces, commas, or new lines."
value={invitePubkeys}
/>
<div className="flex flex-wrap items-center gap-3">
<label
className="flex items-center gap-2 text-sm text-muted-foreground"
htmlFor="channel-member-role"
>
Role
</label>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
data-testid="channel-management-add-role"
disabled={addMembersMutation.isPending}
id="channel-member-role"
onChange={(event) =>
setInviteRole(
event.target.value as Exclude<
ChannelMember["role"],
"owner"
>,
)
}
value={inviteRole}
>
{roleOptions.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<Button
data-testid="channel-management-add-members"
disabled={
addMembersMutation.isPending ||
parsedInvitePubkeys.length === 0
}
size="sm"
type="submit"
>
{addMembersMutation.isPending
? "Inviting..."
: "Add members"}
</Button>
</div>
{addMembersMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{addMembersMutation.error.message}
</p>
) : null}
{addMembersMutation.data &&
addMembersMutation.data.errors.length > 0 ? (
<div className="space-y-1 text-sm text-destructive">
{addMembersMutation.data.errors.map((error) => (
<p key={`${error.pubkey}-${error.error}`}>
{formatPubkey(error.pubkey)}: {error.error}
</p>
))}
</div>
) : null}
</form>
<ChannelMemberInviteCard
existingMembers={members}
isPending={addMembersMutation.isPending}
onSubmit={(input) => addMembersMutation.mutateAsync(input)}
open={open}
requestErrorMessage={
addMembersMutation.error instanceof Error
? addMembersMutation.error.message
: null
}
/>
) : null}
<div className="space-y-2" data-testid="channel-members-list">

View File

@@ -0,0 +1,298 @@
import { Search, UserPlus, X } from "lucide-react";
import * as React from "react";
import { useUserSearchQuery } from "@/features/profile/hooks";
import type {
AddChannelMembersResult,
ChannelMember,
UserSearchResult,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
function formatPubkey(pubkey: string) {
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
}
function formatSearchUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
formatPubkey(user.pubkey)
);
}
function formatSearchUserSecondary(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return formatPubkey(user.pubkey);
}
export function ChannelMemberInviteCard({
existingMembers,
isPending,
onSubmit,
open,
requestErrorMessage,
}: {
existingMembers: ChannelMember[];
isPending: boolean;
onSubmit: (input: {
pubkeys: string[];
role: Exclude<ChannelMember["role"], "owner">;
}) => Promise<AddChannelMembersResult>;
open: boolean;
requestErrorMessage?: string | null;
}) {
const [invitePubkeys, setInvitePubkeys] = React.useState("");
const [inviteQuery, setInviteQuery] = React.useState("");
const [selectedInvitees, setSelectedInvitees] = React.useState<
UserSearchResult[]
>([]);
const [inviteRole, setInviteRole] =
React.useState<Exclude<ChannelMember["role"], "owner">>("member");
const [submissionErrors, setSubmissionErrors] = React.useState<
AddChannelMembersResult["errors"]
>([]);
const deferredInviteQuery = React.useDeferredValue(inviteQuery.trim());
const selectedInviteePubkeys = React.useMemo(
() =>
new Set(selectedInvitees.map((invitee) => invitee.pubkey.toLowerCase())),
[selectedInvitees],
);
const memberPubkeys = React.useMemo(
() => new Set(existingMembers.map((member) => member.pubkey.toLowerCase())),
[existingMembers],
);
const userSearchQuery = useUserSearchQuery(deferredInviteQuery, {
enabled: open && deferredInviteQuery.length > 0,
limit: 8,
});
const inviteSearchResults = React.useMemo(
() =>
(userSearchQuery.data ?? []).filter(
(user) =>
!memberPubkeys.has(user.pubkey.toLowerCase()) &&
!selectedInviteePubkeys.has(user.pubkey.toLowerCase()),
),
[memberPubkeys, selectedInviteePubkeys, userSearchQuery.data],
);
React.useEffect(() => {
if (!open) {
setInvitePubkeys("");
setInviteQuery("");
setSelectedInvitees([]);
setSubmissionErrors([]);
}
}, [open]);
const parsedInvitePubkeys = invitePubkeys
.split(/[\s,]+/)
.map((value) => value.trim())
.filter((value) => value.length > 0);
const inviteTargets = [
...new Set([
...selectedInvitees.map((invitee) => invitee.pubkey),
...parsedInvitePubkeys,
]),
];
return (
<form
className="space-y-3 rounded-2xl border border-border/80 bg-muted/20 p-4"
onSubmit={(event) => {
event.preventDefault();
void onSubmit({
pubkeys: inviteTargets,
role: inviteRole,
}).then((result) => {
const addedPubkeys = new Set(
result.added.map((pubkey) => pubkey.toLowerCase()),
);
setSelectedInvitees((current) =>
current.filter(
(invitee) => !addedPubkeys.has(invitee.pubkey.toLowerCase()),
),
);
setInvitePubkeys(
parsedInvitePubkeys
.filter((pubkey) => !addedPubkeys.has(pubkey.toLowerCase()))
.join("\n"),
);
setInviteQuery("");
setSubmissionErrors(result.errors);
});
}}
>
<div className="flex items-center gap-2 text-sm font-medium">
<UserPlus className="h-4 w-4" />
Add members
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="channel-management-search-users"
>
Search people
</label>
<div className="rounded-xl border border-border/80 bg-background">
<div className="flex items-center gap-2 px-3 py-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
className="h-auto border-0 px-0 py-0 shadow-none focus-visible:ring-0"
data-testid="channel-management-search-users"
disabled={isPending}
id="channel-management-search-users"
onChange={(event) => setInviteQuery(event.target.value)}
placeholder="Search by name, NIP-05, or pubkey."
value={inviteQuery}
/>
</div>
{selectedInvitees.length > 0 ? (
<div className="flex flex-wrap gap-2 border-t border-border/70 px-3 py-2">
{selectedInvitees.map((invitee) => (
<div
className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-muted/60 px-3 py-1 text-xs"
data-testid={`selected-invitee-${invitee.pubkey}`}
key={invitee.pubkey}
>
<span className="font-medium">
{formatSearchUserName(invitee)}
</span>
<button
aria-label={`Remove ${formatSearchUserName(invitee)}`}
className="text-muted-foreground transition-colors hover:text-foreground"
onClick={() => {
setSelectedInvitees((current) =>
current.filter(
(candidate) => candidate.pubkey !== invitee.pubkey,
),
);
}}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
{deferredInviteQuery.length > 0 ? (
<div className="border-t border-border/70 px-2 py-2">
{userSearchQuery.isLoading ? (
<p className="px-2 py-1 text-sm text-muted-foreground">
Searching…
</p>
) : inviteSearchResults.length > 0 ? (
<div className="space-y-1">
{inviteSearchResults.map((result) => (
<button
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
data-testid={`channel-user-search-result-${result.pubkey}`}
key={result.pubkey}
onClick={() => {
setSelectedInvitees((current) => [...current, result]);
setInviteQuery("");
}}
type="button"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{formatSearchUserName(result)}
</p>
<p className="truncate text-xs text-muted-foreground">
{formatSearchUserSecondary(result)}
</p>
</div>
<span className="text-xs text-muted-foreground">Add</span>
</button>
))}
</div>
) : (
<p className="px-2 py-1 text-sm text-muted-foreground">
No matching users.
</p>
)}
</div>
) : null}
</div>
{userSearchQuery.error instanceof Error ? (
<p className="text-sm text-destructive">
{userSearchQuery.error.message}
</p>
) : null}
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-management-add-pubkeys"
>
Paste pubkeys
</label>
<Textarea
className="min-h-24"
data-testid="channel-management-add-pubkeys"
disabled={isPending}
id="channel-management-add-pubkeys"
onChange={(event) => setInvitePubkeys(event.target.value)}
placeholder="Optional: paste one or more pubkeys, separated by spaces, commas, or new lines."
value={invitePubkeys}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<label
className="flex items-center gap-2 text-sm text-muted-foreground"
htmlFor="channel-member-role"
>
Role
</label>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
data-testid="channel-management-add-role"
disabled={isPending}
id="channel-member-role"
onChange={(event) =>
setInviteRole(
event.target.value as Exclude<ChannelMember["role"], "owner">,
)
}
value={inviteRole}
>
{["member", "admin", "guest", "bot"].map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<Button
data-testid="channel-management-add-members"
disabled={isPending || inviteTargets.length === 0}
size="sm"
type="submit"
>
{isPending ? "Adding..." : "Add members"}
</Button>
</div>
{requestErrorMessage ? (
<p className="text-sm text-destructive">{requestErrorMessage}</p>
) : null}
{submissionErrors.length > 0 ? (
<div className="space-y-1 text-sm text-destructive">
{submissionErrors.map((error) => (
<p key={`${error.pubkey}-${error.error}`}>
{formatPubkey(error.pubkey)}: {error.error}
</p>
))}
</div>
) : null}
</form>
);
}

View File

@@ -1,4 +1,3 @@
import { Paperclip, SendHorizontal } from "lucide-react";
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
@@ -10,11 +9,11 @@ import {
} from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "./MentionAutocomplete";
import { MessageComposerToolbar } from "./MessageComposerToolbar";
type MessageComposerProps = {
channelId?: string | null;
@@ -223,6 +222,47 @@ export function MessageComposer({
[content],
);
const openMentionPicker = React.useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const cursorPosition = textarea.selectionStart ?? content.length;
const existingMention = detectMentionQuery(content, cursorPosition);
if (existingMention) {
pendingSelectionRef.current = cursorPosition;
setMentionStartIndex(existingMention.startIndex);
setMentionQuery(existingMention.query);
setMentionSelectedIndex(0);
textarea.focus();
return;
}
const { end, start } = draftSelectionRef.current;
const nextStart = Math.min(start, content.length);
const nextEnd = Math.min(end, content.length);
const previousCharacter = content.slice(0, nextStart).slice(-1);
const prefix =
nextStart > 0 && previousCharacter && !/\s/.test(previousCharacter)
? " @"
: "@";
const nextContent = `${content.slice(0, nextStart)}${prefix}${content.slice(nextEnd)}`;
const mentionIndex = nextStart + (prefix.startsWith(" ") ? 1 : 0);
const nextCursor = mentionIndex + 1;
draftSelectionRef.current = {
end: nextCursor,
start: nextCursor,
};
pendingSelectionRef.current = nextCursor;
setContent(nextContent);
setIsEmojiPickerOpen(false);
setMentionStartIndex(mentionIndex);
setMentionQuery("");
setMentionSelectedIndex(0);
}, [content]);
const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
const markdown = `\n![image](${descriptor.url})\n`;
setContent((prev) => prev + markdown);
@@ -582,50 +622,25 @@ export function MessageComposer({
value={content}
/>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button
disabled={disabled || isUploading}
onClick={() => {
void handlePaperclip();
}}
size="icon"
title="Attach image"
type="button"
variant="ghost"
>
{isUploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
<ComposerEmojiPicker
disabled={disabled}
onEmojiSelect={insertEmoji}
onOpenChange={setIsEmojiPickerOpen}
onTriggerMouseDown={() => {
updateDraftSelection(textareaRef.current);
}}
open={isEmojiPickerOpen}
/>
</div>
<Button
className="gap-2"
data-testid="send-message"
disabled={
disabled ||
isSending ||
(content.trim().length === 0 && pendingImeta.length === 0)
}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
</Button>
</div>
<MessageComposerToolbar
composerDisabled={disabled}
isEmojiPickerOpen={isEmojiPickerOpen}
isSending={isSending}
isUploading={isUploading}
onCaptureSelection={() => {
updateDraftSelection(textareaRef.current);
}}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onOpenMentionPicker={openMentionPicker}
onPaperclip={() => {
void handlePaperclip();
}}
sendDisabled={
disabled ||
(content.trim().length === 0 && pendingImeta.length === 0)
}
/>
</form>
</div>
</footer>

View File

@@ -0,0 +1,79 @@
import { AtSign, Paperclip, SendHorizontal } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
export function MessageComposerToolbar({
composerDisabled,
isEmojiPickerOpen,
isSending,
isUploading,
onCaptureSelection,
onEmojiPickerOpenChange,
onEmojiSelect,
onOpenMentionPicker,
onPaperclip,
sendDisabled,
}: {
composerDisabled: boolean;
isEmojiPickerOpen: boolean;
isSending: boolean;
isUploading: boolean;
onCaptureSelection: () => void;
onEmojiPickerOpenChange: (open: boolean) => void;
onEmojiSelect: (emoji: string) => void;
onOpenMentionPicker: () => void;
onPaperclip: () => void;
sendDisabled: boolean;
}) {
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
size="icon"
title="Mention someone"
type="button"
variant="ghost"
>
<AtSign className="h-4 w-4" />
</Button>
<Button
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
size="icon"
title="Attach image"
type="button"
variant="ghost"
>
{isUploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
<ComposerEmojiPicker
disabled={composerDisabled}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}
onTriggerMouseDown={onCaptureSelection}
open={isEmojiPickerOpen}
/>
</div>
<Button
className="gap-2"
data-testid="send-message"
disabled={sendDisabled || isSending}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
</Button>
</div>
);
}

View File

@@ -115,14 +115,14 @@ export function MessageRow({
{message.depth > 0 ? (
<div
aria-hidden
className="absolute bottom-2 left-3 top-2 rounded-full border-l border-border/70"
className="absolute bottom-1.5 left-3 top-1.5 rounded-full border-l border-border/70"
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
/>
) : null}
<article
className={cn(
"group/message flex gap-3 rounded-2xl px-2 py-1.5 transition-colors",
"group/message flex gap-2.5 rounded-2xl px-2 py-1 transition-colors",
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
activeReplyTargetId === message.id
? "bg-muted/60 ring-1 ring-border"
@@ -140,7 +140,7 @@ export function MessageRow({
{message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-9 w-9 rounded-xl object-cover shadow-sm"
className="h-8 w-8 rounded-lg object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
@@ -151,7 +151,7 @@ export function MessageRow({
) : (
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
"flex h-8 w-8 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
@@ -166,7 +166,7 @@ export function MessageRow({
) : message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-9 w-9 shrink-0 rounded-xl object-cover shadow-sm"
className="h-8 w-8 shrink-0 rounded-lg object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
@@ -177,7 +177,7 @@ export function MessageRow({
) : (
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
@@ -188,8 +188,8 @@ export function MessageRow({
</div>
)}
<div className="min-w-0 flex-1 space-y-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
@@ -231,13 +231,13 @@ export function MessageRow({
</div>
{renderBody()}
{reactions.length > 0 ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{reactions.map((reaction: TimelineReaction) => (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
@@ -267,14 +267,14 @@ export function MessageRow({
</div>
) : null}
{reactionErrorMessage ? (
<p className="mt-2 text-xs text-destructive">
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
</p>
) : null}
{expandedDiffId === message.id ? (
<React.Suspense
fallback={
<div className="p-4 text-sm text-muted-foreground">
<div className="p-3 text-sm text-muted-foreground">
Loading diff viewer…
</div>
}

View File

@@ -60,17 +60,17 @@ export function MessageTimeline({
return (
<div className="relative min-h-0 flex-1">
<div
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 [overflow-anchor:none] sm:px-6"
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
data-testid="message-timeline"
onScroll={syncScrollState}
ref={timelineRef}
>
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-3"
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
ref={contentRef}
>
<div
className="flex items-center gap-4"
className="flex items-center gap-3"
data-testid="message-timeline-day-divider"
>
<Separator className="flex-1" />

View File

@@ -80,10 +80,10 @@ export function SystemMessageRow({
return (
<div
className="flex items-center gap-3 px-2 py-1.5"
className="flex items-center gap-2.5 px-2 py-1"
data-testid="system-message-row"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted">
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-muted">
<ArrowRightLeft className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground">{description}</p>

View File

@@ -6,9 +6,9 @@ export function TimelineSkeleton() {
return (
<>
{skeletonRows.map((row) => (
<div className="flex gap-3" key={row}>
<Skeleton className="h-9 w-9 rounded-xl" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex gap-2.5" key={row}>
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="min-w-0 flex-1 space-y-1">
<Skeleton className="h-3.5 w-44" />
<Skeleton className="h-4 w-full max-w-2xl" />
<Skeleton className="h-4 w-full max-w-xl" />

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getProfile,
searchUsers,
getUserProfile,
getUsersBatch,
updateProfile,
@@ -9,6 +10,7 @@ import {
import type {
Profile,
UpdateProfileInput,
UserSearchResult,
UsersBatchResponse,
} from "@/shared/api/types";
@@ -54,6 +56,25 @@ export function useUsersBatchQuery(
});
}
export function useUserSearchQuery(
query: string,
options?: {
enabled?: boolean;
limit?: number;
},
) {
const normalizedQuery = query.trim().toLowerCase();
const enabled = (options?.enabled ?? true) && normalizedQuery.length > 0;
return useQuery<UserSearchResult[]>({
enabled,
queryKey: ["user-search", normalizedQuery, options?.limit ?? 8],
queryFn: () => searchUsers(normalizedQuery, options?.limit ?? 8),
staleTime: 30_000,
gcTime: 5 * 60 * 1_000,
});
}
export function useUpdateProfileMutation() {
const queryClient = useQueryClient();

View File

@@ -33,6 +33,7 @@ import type {
UpdateProfileInput,
UpdateChannelInput,
UserProfileSummary,
UserSearchResult,
UsersBatchResponse,
CreateManagedAgentInput,
CreateManagedAgentResponse,
@@ -65,6 +66,17 @@ type RawUsersBatchResponse = {
missing: string[];
};
type RawUserSearchResult = {
pubkey: string;
display_name: string | null;
avatar_url: string | null;
nip05_handle: string | null;
};
type RawSearchUsersResponse = {
users: RawUserSearchResult[];
};
type RawPresenceLookup = Record<string, PresenceStatus>;
type RawSetPresenceResult = {
@@ -392,6 +404,15 @@ function fromRawUserProfileSummary(
};
}
function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult {
return {
pubkey: user.pubkey,
displayName: user.display_name,
avatarUrl: user.avatar_url,
nip05Handle: user.nip05_handle,
};
}
export async function getIdentity(): Promise<Identity> {
const identity = await invokeTauri<RawIdentity>("get_identity");
@@ -436,6 +457,17 @@ export async function getUsersBatch(
};
}
export async function searchUsers(
query: string,
limit = 8,
): Promise<UserSearchResult[]> {
const response = await invokeTauri<RawSearchUsersResponse>("search_users", {
query,
limit,
});
return response.users.map(fromRawUserSearchResult);
}
export async function getPresence(pubkeys: string[]): Promise<PresenceLookup> {
const response = await invokeTauri<RawPresenceLookup>("get_presence", {
pubkeys,

View File

@@ -99,6 +99,13 @@ export type UsersBatchResponse = {
missing: string[];
};
export type UserSearchResult = {
pubkey: string;
displayName: string | null;
avatarUrl: string | null;
nip05Handle: string | null;
};
export type UpdateProfileInput = {
displayName?: string;
avatarUrl?: string;

View File

@@ -13,121 +13,134 @@ type MarkdownProps = {
tight?: boolean;
};
const markdownComponents: Components = {
a: ({ children, href, ...props }) => (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
href={href}
rel="noreferrer"
target="_blank"
>
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground">
{children}
</blockquote>
),
br: () => <br />,
code: ({
children,
className,
...props
}: React.ComponentProps<"code"> & { inline?: boolean }) => {
const code = String(children).replace(/\n$/, "");
const isBlock =
typeof className === "string" && className.includes("language-")
? true
: code.includes("\n");
type MarkdownVariant = "default" | "compact" | "tight";
function createMarkdownComponents(variant: MarkdownVariant): Components {
const paragraphClassName =
variant === "tight"
? "leading-5"
: variant === "compact"
? "leading-6"
: "leading-7";
const listItemClassName =
variant === "tight" ? "my-0.5 [&_p]:inline" : "my-1 [&_p]:inline";
const listClassName =
variant === "tight"
? "space-y-0.5 pl-6 marker:text-muted-foreground"
: "space-y-1 pl-6 marker:text-muted-foreground";
return {
a: ({ children, href, ...props }) => (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
href={href}
rel="noreferrer"
target="_blank"
>
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground">
{children}
</blockquote>
),
br: () => <br />,
code: ({
children,
className,
...props
}: React.ComponentProps<"code"> & { inline?: boolean }) => {
const code = String(children).replace(/\n$/, "");
const isBlock =
typeof className === "string" && className.includes("language-")
? true
: code.includes("\n");
if (isBlock) {
return (
<code
{...props}
className={cn(
"block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground",
className,
)}
>
{code}
</code>
);
}
if (isBlock) {
return (
<code
{...props}
className={cn(
"block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground",
"rounded-md bg-muted px-1.5 py-0.5 font-mono text-[13px] text-foreground",
className,
)}
>
{code}
{children}
</code>
);
}
return (
<code
{...props}
className={cn(
"rounded-md bg-muted px-1.5 py-0.5 font-mono text-[13px] text-foreground",
className,
)}
>
},
h1: ({ children }) => (
<h1 className="text-lg font-semibold tracking-tight">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-base font-semibold tracking-tight">{children}</h2>
),
h3: ({ children }) => (
<h3 className="font-semibold tracking-tight">{children}</h3>
),
hr: () => <hr className="border-border/80" />,
img: ({ alt, src }) => (
<img
alt={alt}
className="max-h-96 rounded-2xl border border-border/70 object-cover"
src={src}
/>
),
li: ({ children }) => <li className={listItemClassName}>{children}</li>,
ol: ({ children }) => (
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
),
p: ({ children }) => <p className={paragraphClassName}>{children}</p>,
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-2xl border border-border/70 bg-muted/60 px-4 py-3 shadow-sm">
{children}
</code>
);
},
h1: ({ children }) => (
<h1 className="text-lg font-semibold tracking-tight">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-base font-semibold tracking-tight">{children}</h2>
),
h3: ({ children }) => (
<h3 className="font-semibold tracking-tight">{children}</h3>
),
hr: () => <hr className="border-border/80" />,
img: ({ alt, src }) => (
<img
alt={alt}
className="max-h-96 rounded-2xl border border-border/70 object-cover"
src={src}
/>
),
li: ({ children }) => <li className="my-1 [&_p]:inline">{children}</li>,
ol: ({ children }) => (
<ol className="list-decimal space-y-1 pl-6 marker:text-muted-foreground">
{children}
</ol>
),
p: ({ children }) => <p className="leading-7">{children}</p>,
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-2xl border border-border/70 bg-muted/60 px-4 py-3 shadow-sm">
{children}
</pre>
),
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
),
table: ({ children }) => (
<div className="overflow-x-auto rounded-2xl border border-border/70">
<table className="w-full border-collapse text-left text-sm">
</pre>
),
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
),
table: ({ children }) => (
<div className="overflow-x-auto rounded-2xl border border-border/70">
<table className="w-full border-collapse text-left text-sm">
{children}
</table>
</div>
),
td: ({ children }) => (
<td className="border-t border-border/70 px-3 py-2 align-top">
{children}
</table>
</div>
),
td: ({ children }) => (
<td className="border-t border-border/70 px-3 py-2 align-top">
{children}
</td>
),
th: ({ children }) => (
<th className="bg-muted/60 px-3 py-2 font-semibold text-foreground">
{children}
</th>
),
ul: ({ children }) => (
<ul className="list-disc space-y-1 pl-6 marker:text-muted-foreground">
{children}
</ul>
),
mention: ({ children }: { children?: React.ReactNode }) => (
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary">
{children}
</span>
),
} as Components;
</td>
),
th: ({ children }) => (
<th className="bg-muted/60 px-3 py-2 font-semibold text-foreground">
{children}
</th>
),
ul: ({ children }) => (
<ul className={cn("list-disc", listClassName)}>{children}</ul>
),
mention: ({ children }: { children?: React.ReactNode }) => (
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary">
{children}
</span>
),
} as Components;
}
export function Markdown({
className,
@@ -135,6 +148,7 @@ export function Markdown({
content,
tight = false,
}: MarkdownProps) {
const variant = tight ? "tight" : compact ? "compact" : "default";
let processedContent = content;
if (/^(?:\s{2}\n)+/.test(content)) {
@@ -157,7 +171,7 @@ export function Markdown({
)}
>
<ReactMarkdown
components={markdownComponents}
components={createMarkdownComponents(variant)}
remarkPlugins={[remarkGfm, remarkBreaks, remarkMentions]}
>
{processedContent}

View File

@@ -52,6 +52,17 @@ type RawUsersBatchResponse = {
missing: string[];
};
type RawUserSearchResult = {
pubkey: string;
display_name: string | null;
avatar_url: string | null;
nip05_handle: string | null;
};
type RawSearchUsersResponse = {
users: RawUserSearchResult[];
};
type PresenceStatus = "online" | "away" | "offline";
type RawPresenceLookup = Record<string, PresenceStatus>;
@@ -526,6 +537,18 @@ function getMockProfileByPubkey(pubkey: string): RawProfile | null {
};
}
function listMockProfiles(): RawProfile[] {
const pubkeys = new Set<string>([
...mockProfiles.keys(),
...mockDisplayNames.keys(),
DEFAULT_REAL_IDENTITY.pubkey,
]);
return [...pubkeys]
.map((pubkey) => getMockProfileByPubkey(pubkey))
.filter((profile): profile is RawProfile => profile !== null);
}
function listMockChannels(): RawChannelWithMembership[] {
return mockChannels.map(toRawChannel);
}
@@ -1311,6 +1334,59 @@ async function handleGetUsersBatch(
});
}
async function handleSearchUsers(
args: {
query: string;
limit?: number;
},
config: E2eConfig | undefined,
) {
const identity = getIdentity(config);
if (!identity) {
const normalizedQuery = args.query.trim().toLowerCase();
if (normalizedQuery.length === 0) {
return { users: [] } satisfies RawSearchUsersResponse;
}
const results = listMockProfiles()
.filter((profile) => {
const displayName = profile.display_name?.toLowerCase() ?? "";
const nip05Handle = profile.nip05_handle?.toLowerCase() ?? "";
const pubkey = profile.pubkey.toLowerCase();
return (
displayName.includes(normalizedQuery) ||
nip05Handle.includes(normalizedQuery) ||
pubkey.includes(normalizedQuery)
);
})
.sort((left, right) => {
const leftName = left.display_name ?? left.nip05_handle ?? left.pubkey;
const rightName =
right.display_name ?? right.nip05_handle ?? right.pubkey;
return leftName.localeCompare(rightName);
})
.slice(0, args.limit ?? 8)
.map((profile) => ({
pubkey: profile.pubkey,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
nip05_handle: profile.nip05_handle,
}));
return {
users: results,
} satisfies RawSearchUsersResponse;
}
const searchParams = new URLSearchParams();
searchParams.set("q", args.query);
searchParams.set("limit", String(args.limit ?? 8));
return relayJsonRequest<RawSearchUsersResponse>(
config,
`/api/users/search?${searchParams.toString()}`,
);
}
async function handleGetPresence(
args: {
pubkeys: string[];
@@ -2671,6 +2747,11 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleGetUsersBatch>[0],
activeConfig,
);
case "search_users":
return handleSearchUsers(
payload as Parameters<typeof handleSearchUsers>[0],
activeConfig,
);
case "get_presence":
return handleGetPresence(
(payload as Parameters<typeof handleGetPresence>[0]) ?? {

View File

@@ -253,13 +253,26 @@ test("manage channel can invite and remove members", async ({ page }) => {
await page.goto("/");
await openChannelManagement(page, "general");
await page.getByTestId("channel-management-search-users").fill("char");
await expect(
page.getByTestId(
`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`,
),
).toBeVisible();
await page
.getByTestId("channel-management-add-pubkeys")
.fill(TEST_IDENTITIES.charlie.pubkey);
.getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`selected-invitee-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
await page.getByTestId("channel-management-add-role").selectOption("admin");
await page.getByTestId("channel-management-add-members").click();
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveValue(
await expect(
page.getByTestId(`selected-invitee-${TEST_IDENTITIES.charlie.pubkey}`),
).toHaveCount(0);
await expect(page.getByTestId("channel-management-search-users")).toHaveValue(
"",
);
await expect(

View File

@@ -56,6 +56,24 @@ test("selecting a mention inserts @Name into input", async ({ page }) => {
await expect(input).toHaveValue("Hey @alice ");
});
test("mention button opens autocomplete and inserts a selected member", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("Hey ");
await page.getByTestId("message-insert-mention").click();
const dropdown = autocomplete(page);
await expect(dropdown).toBeVisible();
await dropdown.getByText("alice").click();
await expect(input).toHaveValue("Hey @alice ");
});
test("keyboard navigation selects mention with Enter", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();