mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Improve chat density and member invite UX (#83)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
298
desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx
Normal file
298
desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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\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>
|
||||
|
||||
79
desktop/src/features/messages/ui/MessageComposerToolbar.tsx
Normal file
79
desktop/src/features/messages/ui/MessageComposerToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]) ?? {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user