mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Plumb desktop profile identity UI (#32)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use std::sync::Mutex;
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32};
|
||||
use reqwest::Method;
|
||||
@@ -25,6 +25,18 @@ pub struct ProfileInfo {
|
||||
pub nip05_handle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UserProfileSummaryInfo {
|
||||
pub display_name: Option<String>,
|
||||
pub nip05_handle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UsersBatchResponse {
|
||||
pub profiles: HashMap<String, UserProfileSummaryInfo>,
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChannelInfo {
|
||||
pub id: String,
|
||||
@@ -125,6 +137,8 @@ struct UpdateProfileBody<'a> {
|
||||
avatar_url: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
about: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
nip05_handle: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -313,6 +327,7 @@ async fn update_profile(
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
about: Option<String>,
|
||||
nip05_handle: Option<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ProfileInfo, String> {
|
||||
let request = build_authed_request(
|
||||
@@ -325,6 +340,7 @@ async fn update_profile(
|
||||
display_name: display_name.as_deref(),
|
||||
avatar_url: avatar_url.as_deref(),
|
||||
about: about.as_deref(),
|
||||
nip05_handle: nip05_handle.as_deref(),
|
||||
});
|
||||
send_empty_request(request).await?;
|
||||
|
||||
@@ -337,6 +353,41 @@ async fn update_profile(
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_user_profile(
|
||||
pubkey: Option<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ProfileInfo, String> {
|
||||
let path = match pubkey {
|
||||
Some(pubkey) => format!("/api/users/{pubkey}/profile"),
|
||||
None => "/api/users/me/profile".to_string(),
|
||||
};
|
||||
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GetUsersBatchBody<'a> {
|
||||
pubkeys: &'a [String],
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_users_batch(
|
||||
pubkeys: Vec<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<UsersBatchResponse, String> {
|
||||
let request = build_authed_request(
|
||||
&state.http_client,
|
||||
Method::POST,
|
||||
"/api/users/batch",
|
||||
&state,
|
||||
)?
|
||||
.json(&GetUsersBatchBody {
|
||||
pubkeys: pubkeys.as_slice(),
|
||||
});
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn sign_event(
|
||||
kind: u16,
|
||||
@@ -619,6 +670,8 @@ pub fn run() {
|
||||
get_identity,
|
||||
get_profile,
|
||||
update_profile,
|
||||
get_user_profile,
|
||||
get_users_batch,
|
||||
get_relay_ws_url,
|
||||
sign_event,
|
||||
create_auth_event,
|
||||
|
||||
@@ -14,11 +14,15 @@ import { useHomeFeedQuery } from "@/features/home/hooks";
|
||||
import { HomeView } from "@/features/home/ui/HomeView";
|
||||
import {
|
||||
useChannelMessagesQuery,
|
||||
useChannelSubscription,
|
||||
mergeMessages,
|
||||
useSendMessageMutation,
|
||||
useChannelSubscription,
|
||||
} from "@/features/messages/hooks";
|
||||
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import {
|
||||
collectMessageAuthorPubkeys,
|
||||
formatTimelineMessages,
|
||||
} from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
||||
import { SearchDialog } from "@/features/search/ui/SearchDialog";
|
||||
@@ -62,6 +66,7 @@ export function AppShell() {
|
||||
const [searchAnchorEvent, setSearchAnchorEvent] =
|
||||
React.useState<RelayEvent | null>(null);
|
||||
const identityQuery = useIdentityQuery();
|
||||
const profileQuery = useProfileQuery();
|
||||
const homeFeedQuery = useHomeFeedQuery();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
@@ -105,6 +110,13 @@ export function AppShell() {
|
||||
searchAnchorChannelId,
|
||||
searchAnchorEvent,
|
||||
]);
|
||||
const messageAuthorPubkeys = React.useMemo(
|
||||
() => collectMessageAuthorPubkeys(resolvedMessages),
|
||||
[resolvedMessages],
|
||||
);
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageAuthorPubkeys, {
|
||||
enabled: resolvedMessages.length > 0,
|
||||
});
|
||||
|
||||
const timelineMessages = React.useMemo(
|
||||
() =>
|
||||
@@ -112,8 +124,16 @@ export function AppShell() {
|
||||
resolvedMessages,
|
||||
activeChannel,
|
||||
identityQuery.data?.pubkey,
|
||||
profileQuery.data?.avatarUrl ?? null,
|
||||
messageProfilesQuery.data?.profiles,
|
||||
),
|
||||
[activeChannel, identityQuery.data?.pubkey, resolvedMessages],
|
||||
[
|
||||
activeChannel,
|
||||
identityQuery.data?.pubkey,
|
||||
profileQuery.data?.avatarUrl,
|
||||
messageProfilesQuery.data?.profiles,
|
||||
resolvedMessages,
|
||||
],
|
||||
);
|
||||
|
||||
const channelDescription = activeChannel
|
||||
@@ -382,6 +402,7 @@ export function AppShell() {
|
||||
|
||||
<SearchDialog
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onOpenResult={handleOpenSearchResult}
|
||||
onOpenChange={setIsSearchOpen}
|
||||
open={isSearchOpen}
|
||||
|
||||
@@ -44,7 +44,13 @@ const channelTypeOrder = {
|
||||
} as const;
|
||||
|
||||
function sortChannels(channels: Channel[]) {
|
||||
return [...channels].sort((left, right) => {
|
||||
const uniqueChannels = new Map<string, Channel>();
|
||||
|
||||
for (const channel of channels) {
|
||||
uniqueChannels.set(channel.id, channel);
|
||||
}
|
||||
|
||||
return [...uniqueChannels.values()].sort((left, right) => {
|
||||
const typeOrder =
|
||||
channelTypeOrder[left.channelType] - channelTypeOrder[right.channelType];
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -18,18 +23,6 @@ const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
|
||||
numeric: "auto",
|
||||
});
|
||||
|
||||
function truncatePubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatActor(pubkey: string, currentPubkey: string | undefined) {
|
||||
if (currentPubkey && pubkey === currentPubkey) {
|
||||
return "You";
|
||||
}
|
||||
|
||||
return truncatePubkey(pubkey);
|
||||
}
|
||||
|
||||
function formatRelativeTime(unixSeconds: number) {
|
||||
const diff = unixSeconds - Math.floor(Date.now() / 1_000);
|
||||
const absoluteDiff = Math.abs(diff);
|
||||
@@ -128,6 +121,7 @@ type FeedSectionProps = {
|
||||
icon: LucideIcon;
|
||||
items: FeedItem[];
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
availableChannelIds: ReadonlySet<string>;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
};
|
||||
@@ -140,6 +134,7 @@ function FeedSection({
|
||||
icon: Icon,
|
||||
items,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
availableChannelIds,
|
||||
onOpenChannel,
|
||||
}: FeedSectionProps) {
|
||||
@@ -195,7 +190,12 @@ function FeedSection({
|
||||
{feedHeadline(item)}
|
||||
</h3>
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{formatActor(item.pubkey, currentPubkey)}
|
||||
{resolveUserLabel({
|
||||
pubkey: item.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
})}
|
||||
</p>
|
||||
{item.channelName ? (
|
||||
<p className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-primary">
|
||||
@@ -356,6 +356,22 @@ export function HomeView({
|
||||
onOpenChannel,
|
||||
onRefresh,
|
||||
}: HomeViewProps) {
|
||||
const feedItems = feed
|
||||
? [
|
||||
...feed.feed.mentions,
|
||||
...feed.feed.needsAction,
|
||||
...feed.feed.activity,
|
||||
...feed.feed.agentActivity,
|
||||
]
|
||||
: [];
|
||||
const feedProfilesQuery = useUsersBatchQuery(
|
||||
feedItems.map((item) => item.pubkey),
|
||||
{
|
||||
enabled: feedItems.length > 0,
|
||||
},
|
||||
);
|
||||
const feedProfiles = feedProfilesQuery.data?.profiles;
|
||||
|
||||
if (isLoading && !feed) {
|
||||
return <HomeLoadingState />;
|
||||
}
|
||||
@@ -459,6 +475,7 @@ export function HomeView({
|
||||
<FeedSection
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={feedProfiles}
|
||||
description="Messages where your pubkey was tagged."
|
||||
emptyDescription="When someone mentions you in an accessible channel, it will land here."
|
||||
emptyTitle="No mentions right now"
|
||||
@@ -470,6 +487,7 @@ export function HomeView({
|
||||
<FeedSection
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={feedProfiles}
|
||||
description="Approvals and reminders that need you."
|
||||
emptyDescription="Workflow approval requests and reminders will appear here."
|
||||
emptyTitle="Nothing needs action"
|
||||
@@ -481,6 +499,7 @@ export function HomeView({
|
||||
<FeedSection
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={feedProfiles}
|
||||
description="Recent updates from channels you can access."
|
||||
emptyDescription="Channel activity will populate here once the relay has recent events."
|
||||
emptyTitle="No recent channel activity"
|
||||
@@ -492,6 +511,7 @@ export function HomeView({
|
||||
<FeedSection
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={feedProfiles}
|
||||
description="Agent jobs, progress, and results."
|
||||
emptyDescription="Agent activity appears here once agents start posting into accessible channels."
|
||||
emptyTitle="No agent activity yet"
|
||||
|
||||
@@ -11,17 +11,36 @@ type MessageQueryContext = {
|
||||
queryKey: readonly ["channel-messages", string];
|
||||
};
|
||||
|
||||
function dedupeMessagesById(messages: RelayEvent[]) {
|
||||
const seenIds = new Set<string>();
|
||||
const deduped: RelayEvent[] = [];
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
|
||||
if (seenIds.has(message.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(message.id);
|
||||
deduped.push(message);
|
||||
}
|
||||
|
||||
return deduped.reverse();
|
||||
}
|
||||
|
||||
export function mergeMessages(
|
||||
current: RelayEvent[],
|
||||
incoming: RelayEvent,
|
||||
): RelayEvent[] {
|
||||
const deduped = current.filter(
|
||||
const normalizedCurrent = dedupeMessagesById(current);
|
||||
const deduped = normalizedCurrent.filter(
|
||||
(message) =>
|
||||
message.id !== incoming.id &&
|
||||
!(message.pending && incoming.content === message.content),
|
||||
);
|
||||
|
||||
return [...deduped, incoming].sort(
|
||||
return dedupeMessagesById([...deduped, incoming]).sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
);
|
||||
}
|
||||
@@ -52,7 +71,8 @@ export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
return relayClient.fetchChannelHistory(channel.id);
|
||||
const history = await relayClient.fetchChannelHistory(channel.id);
|
||||
return dedupeMessagesById(history);
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: 30 * 60 * 1_000,
|
||||
|
||||
@@ -1,40 +1,52 @@
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
function truncatePubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
|
||||
function formatMessageAuthor(
|
||||
event: RelayEvent,
|
||||
channel: Channel | null,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
) {
|
||||
if (currentPubkey && event.pubkey === currentPubkey) {
|
||||
return "You";
|
||||
}
|
||||
|
||||
if (channel?.channelType === "dm") {
|
||||
const participantIndex = channel.participantPubkeys.indexOf(event.pubkey);
|
||||
if (participantIndex >= 0) {
|
||||
return (
|
||||
channel.participants[participantIndex] ?? truncatePubkey(event.pubkey)
|
||||
const fallbackName =
|
||||
channel?.channelType === "dm"
|
||||
? (() => {
|
||||
const participantIndex = channel.participantPubkeys.indexOf(
|
||||
event.pubkey,
|
||||
);
|
||||
}
|
||||
if (participantIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return truncatePubkey(event.pubkey);
|
||||
return channel.participants[participantIndex] ?? null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return resolveUserLabel({
|
||||
pubkey: event.pubkey,
|
||||
currentPubkey,
|
||||
fallbackName,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTimelineMessages(
|
||||
events: RelayEvent[],
|
||||
channel: Channel | null,
|
||||
currentPubkey: string | undefined,
|
||||
currentUserAvatarUrl: string | null,
|
||||
profiles?: UserProfileLookup,
|
||||
): TimelineMessage[] {
|
||||
return events.map((event) => ({
|
||||
id: event.id,
|
||||
author: formatMessageAuthor(event, channel, currentPubkey),
|
||||
author: formatMessageAuthor(event, channel, currentPubkey, profiles),
|
||||
avatarUrl:
|
||||
currentPubkey === event.pubkey ? (currentUserAvatarUrl ?? null) : null,
|
||||
time: new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
@@ -44,3 +56,7 @@ export function formatTimelineMessages(
|
||||
pending: event.pending,
|
||||
}));
|
||||
}
|
||||
|
||||
export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
|
||||
return [...new Set(events.map((event) => event.pubkey.toLowerCase()))];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type TimelineMessage = {
|
||||
id: string;
|
||||
author: string;
|
||||
avatarUrl?: string | null;
|
||||
role?: string;
|
||||
time: string;
|
||||
body: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ function isNearBottom(container: HTMLDivElement) {
|
||||
}
|
||||
|
||||
function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
const [hasAvatarError, setHasAvatarError] = React.useState(false);
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
@@ -43,6 +44,18 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
data-message-id={message.id}
|
||||
data-testid="message-row"
|
||||
>
|
||||
{message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-9 w-9 shrink-0 rounded-xl border border-border/80 object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
@@ -50,11 +63,13 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="min-w-0 flex-1 space-y-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
@@ -73,7 +88,7 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Markdown className="max-w-3xl" compact content={message.body} />
|
||||
<Markdown className="max-w-3xl" content={message.body} tight />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { getProfile, updateProfile } from "@/shared/api/tauri";
|
||||
import type { Profile, UpdateProfileInput } from "@/shared/api/types";
|
||||
import {
|
||||
getProfile,
|
||||
getUserProfile,
|
||||
getUsersBatch,
|
||||
updateProfile,
|
||||
} from "@/shared/api/tauri";
|
||||
import type {
|
||||
Profile,
|
||||
UpdateProfileInput,
|
||||
UsersBatchResponse,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export const profileQueryKey = ["profile"] as const;
|
||||
|
||||
@@ -14,6 +23,37 @@ export function useProfileQuery(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserProfileQuery(pubkey?: string) {
|
||||
return useQuery({
|
||||
enabled: typeof pubkey === "string" && pubkey.length > 0,
|
||||
queryKey: ["user-profile", pubkey?.toLowerCase() ?? ""],
|
||||
queryFn: () => getUserProfile(pubkey),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsersBatchQuery(
|
||||
pubkeys: string[],
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
},
|
||||
) {
|
||||
const normalizedPubkeys = [
|
||||
...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase())),
|
||||
]
|
||||
.filter((pubkey) => pubkey.length > 0)
|
||||
.sort();
|
||||
const enabled = (options?.enabled ?? true) && normalizedPubkeys.length > 0;
|
||||
|
||||
return useQuery<UsersBatchResponse>({
|
||||
enabled,
|
||||
queryKey: ["users-batch", ...normalizedPubkeys],
|
||||
queryFn: () => getUsersBatch(normalizedPubkeys),
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60 * 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProfileMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
80
desktop/src/features/profile/lib/identity.ts
Normal file
80
desktop/src/features/profile/lib/identity.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { UserProfileSummary } from "@/shared/api/types";
|
||||
|
||||
export type UserProfileLookup = Record<string, UserProfileSummary>;
|
||||
|
||||
export function truncatePubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey: string) {
|
||||
return pubkey.toLowerCase();
|
||||
}
|
||||
|
||||
function getResolvedProfile(
|
||||
pubkey: string,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
) {
|
||||
if (!profiles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return profiles[normalizePubkey(pubkey)] ?? null;
|
||||
}
|
||||
|
||||
export function resolveUserLabel(input: {
|
||||
pubkey: string;
|
||||
currentPubkey?: string;
|
||||
fallbackName?: string | null;
|
||||
profiles?: UserProfileLookup;
|
||||
preferResolvedSelfLabel?: boolean;
|
||||
}) {
|
||||
const {
|
||||
currentPubkey,
|
||||
fallbackName,
|
||||
preferResolvedSelfLabel = false,
|
||||
profiles,
|
||||
pubkey,
|
||||
} = input;
|
||||
|
||||
if (
|
||||
typeof currentPubkey === "string" &&
|
||||
normalizePubkey(currentPubkey) === normalizePubkey(pubkey)
|
||||
) {
|
||||
if (!preferResolvedSelfLabel) {
|
||||
return "You";
|
||||
}
|
||||
}
|
||||
|
||||
const profile = getResolvedProfile(pubkey, profiles);
|
||||
const displayName = profile?.displayName?.trim();
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
const nip05Handle = profile?.nip05Handle?.trim();
|
||||
if (nip05Handle) {
|
||||
return nip05Handle;
|
||||
}
|
||||
|
||||
const safeFallback = fallbackName?.trim();
|
||||
if (safeFallback) {
|
||||
return safeFallback;
|
||||
}
|
||||
|
||||
return truncatePubkey(pubkey);
|
||||
}
|
||||
|
||||
export function resolveUserSecondaryLabel(input: {
|
||||
pubkey: string;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const profile = getResolvedProfile(input.pubkey, input.profiles);
|
||||
const displayName = profile?.displayName?.trim();
|
||||
const nip05Handle = profile?.nip05Handle?.trim();
|
||||
|
||||
if (displayName && nip05Handle) {
|
||||
return nip05Handle;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
resolveUserSecondaryLabel,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { useSearchMessagesQuery } from "@/features/search/hooks";
|
||||
import type { Channel, SearchHit } from "@/shared/api/types";
|
||||
import {
|
||||
@@ -118,6 +123,7 @@ function SearchLoadingState() {
|
||||
|
||||
type SearchDialogProps = {
|
||||
channels: Channel[];
|
||||
currentPubkey?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onOpenResult: (hit: SearchHit) => void;
|
||||
@@ -125,6 +131,7 @@ type SearchDialogProps = {
|
||||
|
||||
export function SearchDialog({
|
||||
channels,
|
||||
currentPubkey,
|
||||
open,
|
||||
onOpenChange,
|
||||
onOpenResult,
|
||||
@@ -144,6 +151,13 @@ export function SearchDialog({
|
||||
});
|
||||
|
||||
const results = searchQuery.data?.hits ?? [];
|
||||
const resultProfilesQuery = useUsersBatchQuery(
|
||||
results.map((hit) => hit.pubkey),
|
||||
{
|
||||
enabled: open && results.length > 0,
|
||||
},
|
||||
);
|
||||
const resultProfiles = resultProfilesQuery.data?.profiles;
|
||||
|
||||
const openResult = React.useCallback(
|
||||
(hit: SearchHit) => {
|
||||
@@ -293,6 +307,16 @@ export function SearchDialog({
|
||||
<div className="space-y-2">
|
||||
{results.map((hit, index) => {
|
||||
const channel = channelLookup.get(hit.channelId);
|
||||
const authorLabel = resolveUserLabel({
|
||||
pubkey: hit.pubkey,
|
||||
currentPubkey,
|
||||
profiles: resultProfiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const authorSecondaryLabel = resolveUserSecondaryLabel({
|
||||
pubkey: hit.pubkey,
|
||||
profiles: resultProfiles,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -324,10 +348,18 @@ export function SearchDialog({
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{describeSearchHit(hit)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{authorLabel}
|
||||
</p>
|
||||
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatRelativeTime(hit.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
{authorSecondaryLabel ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{authorSecondaryLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm leading-6 text-foreground">
|
||||
{truncateContent(hit.content)}
|
||||
</p>
|
||||
|
||||
@@ -190,25 +190,30 @@ function ProfileSettingsCard({
|
||||
const currentDisplayName = profile?.displayName ?? "";
|
||||
const currentAvatarUrl = profile?.avatarUrl ?? "";
|
||||
const currentAbout = profile?.about ?? "";
|
||||
const currentNip05Handle = profile?.nip05Handle ?? "";
|
||||
|
||||
const [displayNameDraft, setDisplayNameDraft] = React.useState("");
|
||||
const [avatarUrlDraft, setAvatarUrlDraft] = React.useState("");
|
||||
const [aboutDraft, setAboutDraft] = React.useState("");
|
||||
const [nip05HandleDraft, setNip05HandleDraft] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setDisplayNameDraft(currentDisplayName);
|
||||
setAvatarUrlDraft(currentAvatarUrl);
|
||||
setAboutDraft(currentAbout);
|
||||
}, [currentAbout, currentAvatarUrl, currentDisplayName]);
|
||||
setNip05HandleDraft(currentNip05Handle);
|
||||
}, [currentAbout, currentAvatarUrl, currentDisplayName, currentNip05Handle]);
|
||||
|
||||
const nextDisplayName = displayNameDraft.trim();
|
||||
const nextAvatarUrl = avatarUrlDraft.trim();
|
||||
const nextAbout = aboutDraft.trim();
|
||||
const nextNip05Handle = nip05HandleDraft.trim();
|
||||
|
||||
const updatePayload: {
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
about?: string;
|
||||
nip05Handle?: string;
|
||||
} = {};
|
||||
|
||||
if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) {
|
||||
@@ -220,6 +225,9 @@ function ProfileSettingsCard({
|
||||
if (nextAbout.length > 0 && nextAbout !== currentAbout) {
|
||||
updatePayload.about = nextAbout;
|
||||
}
|
||||
if (nextNip05Handle !== currentNip05Handle) {
|
||||
updatePayload.nip05Handle = nextNip05Handle;
|
||||
}
|
||||
|
||||
const hasPendingClearRequest =
|
||||
(currentDisplayName.length > 0 && nextDisplayName.length === 0) ||
|
||||
@@ -272,6 +280,12 @@ function ProfileSettingsCard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{updateProfileMutation.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{updateProfileMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{updateProfileMutation.isSuccess ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-primary/20 bg-primary/10 px-3 py-2 text-sm text-primary">
|
||||
<Check className="h-4 w-4" />
|
||||
@@ -280,7 +294,7 @@ function ProfileSettingsCard({
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
description="Identity comes from your keypair. These fields are read-only here."
|
||||
description="Your keypair is fixed for this device. Profile fields and NIP-05 are editable below."
|
||||
title="Identity"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -335,6 +349,28 @@ function ProfileSettingsCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="profile-nip05">
|
||||
NIP-05 handle
|
||||
</label>
|
||||
<div className="relative min-w-0">
|
||||
<AtSign className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
data-testid="profile-nip05-input"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
id="profile-nip05"
|
||||
onChange={(event) => setNip05HandleDraft(event.target.value)}
|
||||
placeholder="alice@localhost"
|
||||
value={nip05HandleDraft}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Must match this relay's domain. Leave blank to clear your
|
||||
current handle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
@@ -386,7 +422,7 @@ function ProfileSettingsCard({
|
||||
{hasPendingClearRequest ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Clearing existing profile fields is not supported yet. Blank
|
||||
fields are ignored for now.
|
||||
display name, avatar, and about values are ignored for now.
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
SetChannelTopicInput,
|
||||
UpdateProfileInput,
|
||||
UpdateChannelInput,
|
||||
UserProfileSummary,
|
||||
UsersBatchResponse,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
type RawIdentity = {
|
||||
@@ -34,6 +36,16 @@ type RawProfile = {
|
||||
nip05_handle: string | null;
|
||||
};
|
||||
|
||||
type RawUserProfileSummary = {
|
||||
display_name: string | null;
|
||||
nip05_handle: string | null;
|
||||
};
|
||||
|
||||
type RawUsersBatchResponse = {
|
||||
profiles: Record<string, RawUserProfileSummary>;
|
||||
missing: string[];
|
||||
};
|
||||
|
||||
type RawChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -203,6 +215,15 @@ function fromRawProfile(profile: RawProfile): Profile {
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawUserProfileSummary(
|
||||
profile: RawUserProfileSummary,
|
||||
): UserProfileSummary {
|
||||
return {
|
||||
displayName: profile.display_name,
|
||||
nip05Handle: profile.nip05_handle,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIdentity(): Promise<Identity> {
|
||||
const identity = await invoke<RawIdentity>("get_identity");
|
||||
|
||||
@@ -224,6 +245,29 @@ export async function updateProfile(
|
||||
return fromRawProfile(profile);
|
||||
}
|
||||
|
||||
export async function getUserProfile(pubkey?: string): Promise<Profile> {
|
||||
const profile = await invoke<RawProfile>("get_user_profile", { pubkey });
|
||||
return fromRawProfile(profile);
|
||||
}
|
||||
|
||||
export async function getUsersBatch(
|
||||
pubkeys: string[],
|
||||
): Promise<UsersBatchResponse> {
|
||||
const response = await invoke<RawUsersBatchResponse>("get_users_batch", {
|
||||
pubkeys,
|
||||
});
|
||||
|
||||
return {
|
||||
profiles: Object.fromEntries(
|
||||
Object.entries(response.profiles).map(([pubkey, profile]) => [
|
||||
pubkey,
|
||||
fromRawUserProfileSummary(profile),
|
||||
]),
|
||||
),
|
||||
missing: response.missing,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelayWsUrl(): Promise<string> {
|
||||
return invoke<string>("get_relay_ws_url");
|
||||
}
|
||||
|
||||
@@ -87,10 +87,21 @@ export type Profile = {
|
||||
nip05Handle: string | null;
|
||||
};
|
||||
|
||||
export type UserProfileSummary = {
|
||||
displayName: string | null;
|
||||
nip05Handle: string | null;
|
||||
};
|
||||
|
||||
export type UsersBatchResponse = {
|
||||
profiles: Record<string, UserProfileSummary>;
|
||||
missing: string[];
|
||||
};
|
||||
|
||||
export type UpdateProfileInput = {
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
about?: string;
|
||||
nip05Handle?: string;
|
||||
};
|
||||
|
||||
export type RelayEvent = {
|
||||
|
||||
@@ -9,6 +9,7 @@ type MarkdownProps = {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
content: string;
|
||||
tight?: boolean;
|
||||
};
|
||||
|
||||
const markdownComponents: Components = {
|
||||
@@ -126,6 +127,7 @@ export function Markdown({
|
||||
className,
|
||||
compact = false,
|
||||
content,
|
||||
tight = false,
|
||||
}: MarkdownProps) {
|
||||
let processedContent = content;
|
||||
|
||||
@@ -140,7 +142,9 @@ export function Markdown({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
compact
|
||||
tight
|
||||
? "max-w-none break-words text-sm leading-5 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1"
|
||||
: compact
|
||||
? "max-w-none break-words text-[15px] leading-6 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1.5"
|
||||
: "max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3",
|
||||
className,
|
||||
|
||||
@@ -25,6 +25,16 @@ type RawProfile = {
|
||||
nip05_handle: string | null;
|
||||
};
|
||||
|
||||
type RawUserProfileSummary = {
|
||||
display_name: string | null;
|
||||
nip05_handle: string | null;
|
||||
};
|
||||
|
||||
type RawUsersBatchResponse = {
|
||||
profiles: Record<string, RawUserProfileSummary>;
|
||||
missing: string[];
|
||||
};
|
||||
|
||||
type RawChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -281,6 +291,26 @@ function cloneProfile(profile: RawProfile): RawProfile {
|
||||
return { ...profile };
|
||||
}
|
||||
|
||||
function getMockProfileByPubkey(pubkey: string): RawProfile | null {
|
||||
const normalizedPubkey = pubkey.toLowerCase();
|
||||
const existing = mockProfiles.get(normalizedPubkey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!mockDisplayNames.has(normalizedPubkey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey: normalizedPubkey,
|
||||
display_name: mockDisplayNames.get(normalizedPubkey) ?? null,
|
||||
avatar_url: null,
|
||||
about: null,
|
||||
nip05_handle: null,
|
||||
};
|
||||
}
|
||||
|
||||
function listMockChannels(): RawChannel[] {
|
||||
return mockChannels.map(toRawChannel);
|
||||
}
|
||||
@@ -817,6 +847,7 @@ async function handleUpdateProfile(
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
about?: string;
|
||||
nip05Handle?: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
@@ -826,6 +857,7 @@ async function handleUpdateProfile(
|
||||
const nextDisplayName = args.displayName?.trim();
|
||||
const nextAvatarUrl = args.avatarUrl?.trim();
|
||||
const nextAbout = args.about?.trim();
|
||||
const nextNip05Handle = args.nip05Handle?.trim();
|
||||
|
||||
if (nextDisplayName && nextDisplayName !== profile.display_name) {
|
||||
profile.display_name = nextDisplayName;
|
||||
@@ -837,6 +869,13 @@ async function handleUpdateProfile(
|
||||
if (nextAbout && nextAbout !== profile.about) {
|
||||
profile.about = nextAbout;
|
||||
}
|
||||
if (
|
||||
typeof nextNip05Handle === "string" &&
|
||||
nextNip05Handle !== profile.nip05_handle
|
||||
) {
|
||||
profile.nip05_handle =
|
||||
nextNip05Handle.length > 0 ? nextNip05Handle : null;
|
||||
}
|
||||
|
||||
return cloneProfile(profile);
|
||||
}
|
||||
@@ -847,12 +886,76 @@ async function handleUpdateProfile(
|
||||
display_name: args.displayName,
|
||||
avatar_url: args.avatarUrl,
|
||||
about: args.about,
|
||||
nip05_handle: args.nip05Handle,
|
||||
}),
|
||||
});
|
||||
|
||||
return relayJsonRequest<RawProfile>(config, "/api/users/me/profile");
|
||||
}
|
||||
|
||||
async function handleGetUserProfile(
|
||||
args: {
|
||||
pubkey?: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const pubkey = (args.pubkey ?? getMockMemberPubkey(config)).toLowerCase();
|
||||
const profile = getMockProfileByPubkey(pubkey);
|
||||
if (!profile) {
|
||||
throw new Error(`User ${pubkey} not found.`);
|
||||
}
|
||||
|
||||
return cloneProfile(profile);
|
||||
}
|
||||
|
||||
const path = args.pubkey
|
||||
? `/api/users/${args.pubkey}/profile`
|
||||
: "/api/users/me/profile";
|
||||
return relayJsonRequest<RawProfile>(config, path);
|
||||
}
|
||||
|
||||
async function handleGetUsersBatch(
|
||||
args: {
|
||||
pubkeys: string[];
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const profiles: RawUsersBatchResponse["profiles"] = {};
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const pubkey of args.pubkeys) {
|
||||
const normalizedPubkey = pubkey.toLowerCase();
|
||||
const profile = getMockProfileByPubkey(normalizedPubkey);
|
||||
|
||||
if (!profile) {
|
||||
missing.push(pubkey);
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles[normalizedPubkey] = {
|
||||
display_name: profile.display_name,
|
||||
nip05_handle: profile.nip05_handle,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawUsersBatchResponse>(config, "/api/users/batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
pubkeys: args.pubkeys,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateChannel(
|
||||
args: {
|
||||
name: string;
|
||||
@@ -1273,6 +1376,17 @@ async function handleGetFeed(
|
||||
|
||||
const activity = includeType("activity")
|
||||
? [
|
||||
{
|
||||
id: "mock-feed-self-activity",
|
||||
kind: 40001,
|
||||
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
content: "I posted a note about the launch checklist.",
|
||||
created_at: now - 25 * 60,
|
||||
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
channel_name: "general",
|
||||
tags: [["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]],
|
||||
category: "activity" as const,
|
||||
},
|
||||
{
|
||||
id: "mock-feed-activity",
|
||||
kind: 40001,
|
||||
@@ -1673,6 +1787,16 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as Parameters<typeof handleUpdateProfile>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "get_user_profile":
|
||||
return handleGetUserProfile(
|
||||
(payload as Parameters<typeof handleGetUserProfile>[0]) ?? {},
|
||||
activeConfig,
|
||||
);
|
||||
case "get_users_batch":
|
||||
return handleGetUsersBatch(
|
||||
payload as Parameters<typeof handleGetUsersBatch>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "get_relay_ws_url":
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_channels":
|
||||
|
||||
@@ -17,6 +17,9 @@ test("send a message and see it in timeline", async ({ page }) => {
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
await expect(page.getByTestId("message-row").last()).toContainText(
|
||||
"npub1mock...",
|
||||
);
|
||||
});
|
||||
|
||||
test("send multiple messages in sequence", async ({ page }) => {
|
||||
@@ -162,3 +165,29 @@ test("send message to DM channel", async ({ page }) => {
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
|
||||
test("shows your avatar on your own message when profile avatar is set", async ({
|
||||
page,
|
||||
}) => {
|
||||
const message = `Avatar message ${Date.now()}`;
|
||||
const avatarUrl =
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E';
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
|
||||
await page.getByTestId("profile-save").click();
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await page.getByTestId("message-input").fill(message);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const lastMessage = page.getByTestId("message-row").last();
|
||||
await expect(lastMessage).toContainText(message);
|
||||
await expect(lastMessage.getByTestId("message-avatar-image")).toHaveAttribute(
|
||||
"src",
|
||||
avatarUrl,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
const displayName = `Tyler QA ${stamp}`;
|
||||
const avatarUrl = `https://example.com/avatar-${stamp}.png`;
|
||||
const about = `Coordinating relay profile setup ${stamp}`;
|
||||
const nip05Handle = `tyler-${stamp}@localhost`;
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
@@ -22,6 +23,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
|
||||
|
||||
await page.getByTestId("profile-display-name").fill(displayName);
|
||||
await page.getByTestId("profile-nip05-input").fill(nip05Handle);
|
||||
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
|
||||
await page.getByTestId("profile-about").fill(about);
|
||||
await page.getByTestId("profile-save").click();
|
||||
@@ -29,6 +31,10 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
await expect(page.getByTestId("profile-display-name")).toHaveValue(
|
||||
displayName,
|
||||
);
|
||||
await expect(page.getByTestId("profile-nip05")).toContainText(nip05Handle);
|
||||
await expect(page.getByTestId("profile-nip05-input")).toHaveValue(
|
||||
nip05Handle,
|
||||
);
|
||||
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(avatarUrl);
|
||||
await expect(page.getByTestId("profile-about")).toHaveValue(about);
|
||||
|
||||
@@ -40,6 +46,10 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
await expect(page.getByTestId("profile-display-name")).toHaveValue(
|
||||
displayName,
|
||||
);
|
||||
await expect(page.getByTestId("profile-nip05")).toContainText(nip05Handle);
|
||||
await expect(page.getByTestId("profile-nip05-input")).toHaveValue(
|
||||
nip05Handle,
|
||||
);
|
||||
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(avatarUrl);
|
||||
await expect(page.getByTestId("profile-about")).toHaveValue(about);
|
||||
});
|
||||
|
||||
@@ -91,6 +91,22 @@ test("opens a mocked channel from the home feed", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("home feed uses your resolved profile label instead of You", async ({
|
||||
page,
|
||||
}) => {
|
||||
const activitySection = page.locator("section").filter({
|
||||
has: page.getByRole("heading", { name: "Channel Activity" }),
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(activitySection).toContainText(
|
||||
"I posted a note about the launch checklist.",
|
||||
);
|
||||
await expect(activitySection).toContainText("npub1mock...");
|
||||
await expect(activitySection).not.toContainText("You");
|
||||
});
|
||||
|
||||
test("opens relay-backed search from the sidebar and loads the exact result", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -118,6 +134,24 @@ test("opens relay-backed search from the sidebar and loads the exact result", as
|
||||
);
|
||||
});
|
||||
|
||||
test("search results use your resolved profile label instead of You", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.keyboard.press(
|
||||
process.platform === "darwin" ? "Meta+K" : "Control+K",
|
||||
);
|
||||
await expect(page.getByTestId("search-dialog")).toBeVisible();
|
||||
|
||||
await page.getByTestId("search-input").fill("welcome");
|
||||
const results = page.getByTestId("search-results");
|
||||
|
||||
await expect(results).toContainText("Welcome to #general");
|
||||
await expect(results).toContainText("npub1mock...");
|
||||
await expect(results).not.toContainText("You");
|
||||
});
|
||||
|
||||
test("replaces the channel pane when switching channels", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user