Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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(
Expand All @@ -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?;

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 24 additions & 3 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ?? [];
Expand Down Expand Up @@ -105,15 +110,30 @@ export function AppShell() {
searchAnchorChannelId,
searchAnchorEvent,
]);
const messageAuthorPubkeys = React.useMemo(
() => collectMessageAuthorPubkeys(resolvedMessages),
[resolvedMessages],
);
const messageProfilesQuery = useUsersBatchQuery(messageAuthorPubkeys, {
enabled: resolvedMessages.length > 0,
});

const timelineMessages = React.useMemo(
() =>
formatTimelineMessages(
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
Expand Down Expand Up @@ -382,6 +402,7 @@ export function AppShell() {

<SearchDialog
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onOpenResult={handleOpenSearchResult}
onOpenChange={setIsSearchOpen}
open={isSearchOpen}
Expand Down
8 changes: 7 additions & 1 deletion desktop/src/features/channels/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
46 changes: 33 additions & 13 deletions desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -128,6 +121,7 @@ type FeedSectionProps = {
icon: LucideIcon;
items: FeedItem[];
currentPubkey?: string;
profiles?: UserProfileLookup;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
};
Expand All @@ -140,6 +134,7 @@ function FeedSection({
icon: Icon,
items,
currentPubkey,
profiles,
availableChannelIds,
onOpenChannel,
}: FeedSectionProps) {
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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 />;
}
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down
26 changes: 23 additions & 3 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading