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
19 changes: 14 additions & 5 deletions crates/buzz-db/src/feed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ use sqlx::{PgPool, QueryBuilder};
use uuid::Uuid;

use buzz_core::kind::{
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER,
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_GIT_ISSUE, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_WORKFLOW_APPROVAL_REQUESTED,
};
use buzz_core::{CommunityId, StoredEvent};

Expand Down Expand Up @@ -103,7 +104,9 @@ fn build_mentions_query(
qb.push(" AND e.deleted_at IS NULL");
qb.push(format!(
" AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \
{KIND_FORUM_POST}, {KIND_FORUM_COMMENT})"
{KIND_TEXT_NOTE}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT}, {KIND_GIT_PULL_REQUEST}, \
{KIND_GIT_PR_UPDATE}, {KIND_GIT_ISSUE}, {KIND_GIT_STATUS_OPEN}, \
{KIND_GIT_STATUS_MERGED}, {KIND_GIT_STATUS_CLOSED}, {KIND_GIT_STATUS_DRAFT})"
));
push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids);
if let Some(s) = since {
Expand Down Expand Up @@ -252,7 +255,7 @@ mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;

const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials

async fn setup_pool() -> PgPool {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
Expand Down Expand Up @@ -777,6 +780,12 @@ mod tests {
sql.contains("AND m.community_id = "),
"mentions feed must also bind event_mentions.community_id: {sql}"
);
assert!(
sql.contains(&KIND_GIT_PULL_REQUEST.to_string())
&& sql.contains(&KIND_GIT_ISSUE.to_string())
&& sql.contains(&KIND_TEXT_NOTE.to_string()),
"mentions feed must include Buzz Git roots and comments: {sql}"
);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export default defineConfig({
"**/inbox-reactions.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-commit-detail.spec.ts",
"**/project-inbox.spec.ts",
"**/project-pr-review.spec.ts",
"**/persona-model-combobox-screenshots.spec.ts",
"**/drafts-screenshots.spec.ts",
Expand Down
15 changes: 14 additions & 1 deletion desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,20 @@ pub async fn get_feed(

// Mentions: messages that reference me via #p.
let mut mention_filter = serde_json::json!({
"kinds": [9, 40002, 1, 45001, 45003],
"kinds": [
9,
40002,
1,
45001,
45003,
buzz_core_pkg::kind::KIND_GIT_PULL_REQUEST,
buzz_core_pkg::kind::KIND_GIT_PR_UPDATE,
buzz_core_pkg::kind::KIND_GIT_ISSUE,
buzz_core_pkg::kind::KIND_GIT_STATUS_OPEN,
buzz_core_pkg::kind::KIND_GIT_STATUS_MERGED,
buzz_core_pkg::kind::KIND_GIT_STATUS_CLOSED,
buzz_core_pkg::kind::KIND_GIT_STATUS_DRAFT,
],
"#p": [my_pubkey],
"limit": cap,
});
Expand Down
87 changes: 77 additions & 10 deletions desktop/src/features/home/lib/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import {
getProjectInboxReference,
isProjectInboxItem,
} from "@/features/home/lib/projectInbox";
import type { TimelineReaction } from "@/features/messages/types";
import type {
Channel,
Expand All @@ -18,6 +22,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";

export type InboxFilter =
| "all"
| "project"
| "mention"
| "thread"
| "needs_action"
Expand All @@ -29,10 +34,10 @@ export type InboxFilter =
export type InboxItem = {
avatarUrl: string | null;
/**
* Stable conversation identity: `rootId ?? parentId ?? event.id` for the
* thread group. Does NOT change when a new reply advances the representative
* latest event. Use this for lifecycle continuity: scroll gating, draft
* keys, local-reply storage, and selection identity.
* Stable conversation identity: the NIP-10 root for messages, or a
* repository-scoped root for Buzz Git work. Does NOT change when a new reply
* advances the representative latest event. Use this for lifecycle
* continuity: scroll gating, draft keys, local-reply storage, and selection.
*/
conversationId: string;
id: string;
Expand Down Expand Up @@ -136,7 +141,33 @@ function diffInDays(from: Date, to: Date) {
);
}

function feedHeadline(item: FeedItem) {
function tagValue(item: FeedItem, name: string) {
return item.tags.find((tag) => tag[0] === name)?.[1]?.trim() || null;
}

function projectRootItem(item: FeedItem, groupItems: readonly FeedItem[]) {
return (
groupItems.find(
(candidate) => candidate.kind === 1618 || candidate.kind === 1621,
) ?? item
);
}

function projectTypeLabel(item: FeedItem) {
if (item.kind === 1618) return "Pull request";
if (item.kind === 1621) return "Issue";
return "Project update";
}

function feedHeadline(item: FeedItem, groupItems: readonly FeedItem[] = []) {
if (isProjectInboxItem(item)) {
const root = projectRootItem(item, groupItems);
return (
(tagValue(root, "subject") ?? root.content.trim().split("\n")[0]) ||
projectTypeLabel(root)
);
}

switch (item.kind) {
case 40007:
return "Reminder";
Expand Down Expand Up @@ -242,6 +273,14 @@ function resolveGroupChannel(
export function getInboxTypeLabel(item: InboxItem): InboxTypeLabel {
const channelName = item.channelLabel;

if (item.groupItems.some(isProjectInboxItem)) {
const root = projectRootItem(item.item, item.groupItems);
return {
text: projectTypeLabel(root),
channelLabel: null,
};
}

if (item.item.channelType === "dm") {
return {
text: item.senderLabel ? `DM from ${item.senderLabel}` : "DM",
Expand Down Expand Up @@ -300,23 +339,50 @@ function categoryPriority(category: FeedItemCategory) {
}

function getInboxThreadKey(item: FeedItem) {
const projectReference = getProjectInboxReference(item);
if (projectReference) {
return `project:${projectReference.repoAddress}:${projectReference.rootId}`;
}

const thread = getThreadReference(item.tags);
return thread.rootId ?? thread.parentId ?? item.id;
}

function getStableConversationId(item: FeedItem) {
return getInboxItemConversationId(item);
}

/**
* Returns the stable conversation ID for any FeedItem or relay event: the
* NIP-10 root tag id, falling back to parent-reply tag id, then event id.
* Returns the stable conversation ID for any FeedItem or relay event. Buzz Git
* roots include their repository coordinate; messages use the NIP-10 root,
* parent-reply tag, then event id.
* This is the same derivation used by `buildInboxItems` for `conversationId`.
*/
export function getInboxConversationId(
tags: string[][],
eventId: string,
kind?: number,
): string {
if (kind !== undefined) {
const projectReference = getProjectInboxReference({
id: eventId,
kind,
tags,
});
if (projectReference) {
return `project:${projectReference.repoAddress}:${projectReference.rootId}`;
}
}

const thread = getThreadReference(tags);
return thread.rootId ?? thread.parentId ?? eventId;
}

/** Returns the stable conversation identity for a complete Inbox feed item. */
export function getInboxItemConversationId(item: FeedItem) {
return getInboxConversationId(item.tags, item.id, item.kind);
}

function formatInboxTimestamp(unixSeconds: number) {
const date = new Date(unixSeconds * 1_000);
const now = new Date();
Expand Down Expand Up @@ -436,7 +502,7 @@ export function buildInboxItems({

group.items.push(item);
group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt);
if (item.id === threadKey) {
if (item.id === getStableConversationId(item)) {
group.rootItem = item;
}

Expand All @@ -447,7 +513,8 @@ export function buildInboxItems({
.sort(
([, left], [, right]) => right.latestActivityAt - left.latestActivityAt,
)
.map(([conversationId, group]) => {
.map(([, group]) => {
const conversationId = getStableConversationId(group.items[0]);
const latestItem = group.items.reduce((latest, current) =>
current.createdAt > latest.createdAt ? current : latest,
);
Expand All @@ -461,7 +528,7 @@ export function buildInboxItems({
profiles,
preferResolvedSelfLabel: true,
});
const subject = feedHeadline(item);
const subject = feedHeadline(item, group.items);
const preview = feedPreview(item);
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
item.tags,
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/features/home/lib/inboxViewHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type InboxContextMessage,
type InboxFilter,
} from "@/features/home/lib/inbox";
import { isProjectInboxItem } from "@/features/home/lib/projectInbox";
import {
getChannelIdFromTags,
getThreadReference,
Expand Down Expand Up @@ -39,6 +40,12 @@ export function matchesInboxFilter(
);
}

if (filter === "project") {
return [item.item, ...(item.groupItems ?? [])].some(
(groupItem) => groupItem && isProjectInboxItem(groupItem),
);
}

return item.categories.includes(filter);
}

Expand Down
Loading
Loading