From 8be40c234e177b613d455683dfa78902c129f154 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 27 Mar 2026 21:50:35 +0100 Subject: [PATCH] handle repo announcements, releases, issues create new release notes --- src/components/ContentPreview/index.tsx | 9 + src/components/KindFilter/index.tsx | 5 +- src/components/Note/GitRepublicEventCard.tsx | 164 ++++++++++++++++++ src/components/Note/index.tsx | 7 + src/components/NoteList/index.tsx | 4 + src/components/PostEditor/PostContent.tsx | 124 ++++++++++++- .../Profile/ProfileFeedWithPins.tsx | 1 + src/constants.ts | 26 ++- src/i18n/locales/de.ts | 11 ++ src/i18n/locales/en.ts | 11 ++ src/lib/draft-event.ts | 41 +++++ src/lib/git-republic-event.ts | 56 ++++++ src/lib/kind-description.ts | 6 + src/services/local-storage.service.ts | 7 +- 14 files changed, 458 insertions(+), 14 deletions(-) create mode 100644 src/components/Note/GitRepublicEventCard.tsx create mode 100644 src/lib/git-republic-event.ts diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 2d06c1f2..ab1751fd 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -31,6 +31,7 @@ import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendatio import FollowPackPreview from './FollowPackPreview' import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' import NoteKindLabel from '../Note/NoteKindLabel' +import GitRepublicEventCard from '../Note/GitRepublicEventCard' /** Inert event so hooks can run before `event` is defined. */ const CONTENT_PREVIEW_HOOK_PLACEHOLDER = { @@ -183,6 +184,14 @@ export default function ContentPreview({ return withKindRow() } + if ( + event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT || + event.kind === ExtendedKind.GIT_ISSUE || + event.kind === ExtendedKind.GIT_RELEASE + ) { + return withKindRow() + } + if (isNip25ReactionKind(event.kind)) { return withKindRow(
diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 24a0e3fc..d8ca933e 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -28,7 +28,10 @@ const KIND_FILTER_OPTIONS = [ { kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' }, { kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' }, { kindGroup: [ExtendedKind.ZAP_RECEIPT], label: 'Zaps' }, - { kindGroup: [kinds.Repost, ExtendedKind.GENERIC_REPOST], label: 'Boosts' } + { kindGroup: [kinds.Repost, ExtendedKind.GENERIC_REPOST], label: 'Boosts' }, + { kindGroup: [ExtendedKind.GIT_REPO_ANNOUNCEMENT], label: 'Git repositories' }, + { kindGroup: [ExtendedKind.GIT_ISSUE], label: 'Git issues' }, + { kindGroup: [ExtendedKind.GIT_RELEASE], label: 'Git releases' } ] function buildShowKindsFromOptions( diff --git a/src/components/Note/GitRepublicEventCard.tsx b/src/components/Note/GitRepublicEventCard.tsx new file mode 100644 index 00000000..d486107c --- /dev/null +++ b/src/components/Note/GitRepublicEventCard.tsx @@ -0,0 +1,164 @@ +import { ExtendedKind } from '@/constants' +import { + getGitRepublicRepoContext, + gitRepublicRepoWebUrl, + type GitRepublicRepoContext +} from '@/lib/git-republic-event' +import { cn } from '@/lib/utils' +import { Event, nip19 } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ExternalLink, GitBranch, CircleDot, Tag } from 'lucide-react' +import MarkdownArticle from './MarkdownArticle/MarkdownArticle' + +function repoHeadline(ctx: GitRepublicRepoContext): string { + const name = ctx.displayName || ctx.repoId + try { + const npub = nip19.npubEncode(ctx.ownerHex) + const short = `${npub.slice(0, 14)}…` + return `${short} / ${name}` + } catch { + return name + } +} + +export default function GitRepublicEventCard({ + event, + className, + variant = 'full' +}: { + event: Event + className?: string + variant?: 'full' | 'compact' +}) { + const { t } = useTranslation() + const ctx = useMemo(() => getGitRepublicRepoContext(event), [event]) + const webUrl = useMemo(() => (ctx ? gitRepublicRepoWebUrl(ctx) : null), [ctx]) + + const subject = event.tags.find((t) => t[0] === 'subject')?.[1] + const titleTag = event.tags.find((t) => t[0] === 'title')?.[1] + const tagName = event.tags.find((t) => t[0] === 'tag')?.[1] + const description = + event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT + ? event.tags.find((t) => t[0] === 'description')?.[1] + : undefined + + const isDraft = event.tags.some((t) => t[0] === 'draft' && t[1] === 'true') + const isPrerelease = event.tags.some((t) => t[0] === 'prerelease' && t[1] === 'true') + + const { Icon, badge, headline } = useMemo(() => { + if (event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT) { + const name = event.tags.find((t) => t[0] === 'name')?.[1] || ctx?.repoId || t('Git Republic repository') + return { + Icon: GitBranch, + badge: t('Git Republic repository'), + headline: name + } + } + if (event.kind === ExtendedKind.GIT_ISSUE) { + return { + Icon: CircleDot, + badge: t('Git Republic issue'), + headline: subject || t('Git Republic issue') + } + } + if (event.kind === ExtendedKind.GIT_RELEASE) { + const h = titleTag || tagName || t('Git Republic release') + return { Icon: Tag, badge: t('Git Republic release'), headline: h } + } + return { Icon: GitBranch, badge: t('Git Republic'), headline: t('Git Republic event') } + }, [event, ctx?.repoId, subject, tagName, titleTag, t]) + + const body = + event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT + ? description || event.content + : event.content + + const compact = variant === 'compact' + + return ( +
+
+
+ +
+
+
+ + {badge} + + {isDraft ? ( + + {t('Draft')} + + ) : null} + {isPrerelease ? ( + + {t('Pre-release')} + + ) : null} +
+ {compact && (isDraft || isPrerelease) ? ( +
+ {isDraft ? ( + + {t('Draft')} + + ) : null} + {isPrerelease ? ( + + {t('Pre-release')} + + ) : null} +
+ ) : null} +

+ {headline} +

+ {event.kind === ExtendedKind.GIT_RELEASE && tagName ? ( +

{tagName}

+ ) : null} + {ctx ? ( +

+ {repoHeadline(ctx)} +

+ ) : null} + {webUrl ? ( + e.stopPropagation()} + > + + {t('Open in Git Republic')} + + ) : null} +
+
+ {body.trim() ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 9b215930..c223f134 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -69,6 +69,7 @@ import Zap from './Zap' import CitationCard from '@/components/CitationCard' import FollowPackPreview from '../ContentPreview/FollowPackPreview' import CalendarEventContent from '../CalendarEventContent' +import GitRepublicEventCard from './GitRepublicEventCard' export default function Note({ event, @@ -284,6 +285,12 @@ export default function Note({ content = } else if (event.kind === ExtendedKind.FOLLOW_PACK) { content = + } else if ( + event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT || + event.kind === ExtendedKind.GIT_ISSUE || + event.kind === ExtendedKind.GIT_RELEASE + ) { + content = } else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT) { // Plain text notes use MarkdownArticle for proper markdown rendering content = diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index a2624d7e..2d62c138 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -495,6 +495,8 @@ const NoteList = forwardRef( } // Kind 1111 (comments): show only if showKind1111 if (evt.kind === ExtendedKind.COMMENT && !showKind1111) return false + // Git Republic releases: same visibility as kind-1 OPs + if (evt.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false } if (shouldHideEvent(evt)) return false @@ -568,6 +570,7 @@ const NoteList = forwardRef( if (!isReply && !showKind1OPs) return false } if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false + if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false } if (shouldHideEvent(event)) return false @@ -1136,6 +1139,7 @@ const NoteList = forwardRef( if (!isReply && !showKind1OPs) return } if (event.kind === ExtendedKind.COMMENT && !showKind1111) return + if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return } if (shouldHideEventRef.current(event)) return if (pubkey && event.pubkey === pubkey) { diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 1fa19122..33602ca4 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { DropdownMenu, @@ -30,10 +31,12 @@ import { createCitationInternalDraftEvent, createCitationExternalDraftEvent, createCitationHardcopyDraftEvent, - createCitationPromptDraftEvent + createCitationPromptDraftEvent, + createGitReleaseDraftEvent } from '@/lib/draft-event' import { ExtendedKind } from '@/constants' -import { isTouchDevice } from '@/lib/utils' +import { parseRepoOwnerPubkeyInput } from '@/lib/git-republic-event' +import { cn, isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useFeed } from '@/providers/FeedProvider' import { useReply } from '@/providers/ReplyProvider' @@ -240,7 +243,16 @@ export default function PostContent({ const [citationGeohash, setCitationGeohash] = useState('') const [citationVersion, setCitationVersion] = useState('') const [citationSummary, setCitationSummary] = useState('') - + const [isGitRelease, setIsGitRelease] = useState(false) + const [releaseRepoOwnerInput, setReleaseRepoOwnerInput] = useState('') + const [releaseRepoId, setReleaseRepoId] = useState('') + const [releaseTagName, setReleaseTagName] = useState('') + const [releaseTagHash, setReleaseTagHash] = useState('') + const [releaseTitle, setReleaseTitle] = useState('') + const [releaseDownloadUrl, setReleaseDownloadUrl] = useState('') + const [releaseDraft, setReleaseDraft] = useState(false) + const [releasePrerelease, setReleasePrerelease] = useState(false) + const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false) const [showMediaKindDialog, setShowMediaKindDialog] = useState(false) const [pendingMediaUpload, setPendingMediaUpload] = useState<{ url: string; tags: string[][]; file: File } | null>(null) @@ -253,20 +265,32 @@ export default function PostContent({ } }, [mediaNoteKind, mediaImetaTags]) const isFirstRender = useRef(true) + const releaseFieldsOk = useMemo(() => { + if (!isGitRelease) return true + const owner = parseRepoOwnerPubkeyInput(releaseRepoOwnerInput) + return ( + !!owner && + !!releaseRepoId.trim() && + !!releaseTagName.trim() && + /^[0-9a-f]{40}$/i.test(releaseTagHash.trim()) + ) + }, [isGitRelease, releaseRepoOwnerInput, releaseRepoId, releaseTagName, releaseTagHash]) + const canPost = useMemo(() => { const isArticle = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent const result = ( !!pubkey && !posting && !uploadProgresses.length && - // For media notes, text is optional - just need media - ((mediaNoteKind !== null && mediaUrl) || !!text) && + // For media notes, text is optional - just need media; Git releases use the editor as release notes (optional) + ((mediaNoteKind !== null && mediaUrl) || !!text || isGitRelease) && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && (!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && (!isProtectedEvent || additionalRelayUrls.length > 0) && (!isHighlight || highlightData.sourceValue.trim() !== '') && // For articles, dTag is mandatory (!isArticle || !!articleDTag.trim()) && + (!isGitRelease || releaseFieldsOk) && // For citations, required fields must be filled (!isCitationInternal || !!citationInternalCTag.trim()) && (!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && @@ -296,6 +320,8 @@ export default function PostContent({ isWikiArticleMarkdown, isPublicationContent, articleDTag, + isGitRelease, + releaseFieldsOk, isCitationInternal, citationInternalCTag, isCitationExternal, @@ -387,6 +413,8 @@ export default function PostContent({ return ExtendedKind.WIKI_ARTICLE_MARKDOWN } else if (isPublicationContent) { return ExtendedKind.PUBLICATION_CONTENT + } else if (isGitRelease) { + return ExtendedKind.GIT_RELEASE } else if (isCitationInternal) { return ExtendedKind.CITATION_INTERNAL } else if (isCitationExternal) { @@ -411,6 +439,7 @@ export default function PostContent({ isWikiArticle, isWikiArticleMarkdown, isPublicationContent, + isGitRelease, isCitationInternal, isCitationExternal, isCitationHardcopy, @@ -646,6 +675,23 @@ export default function PostContent({ }) } + if (isGitRelease) { + const ownerHex = parseRepoOwnerPubkeyInput(releaseRepoOwnerInput) + if (!ownerHex) { + throw new Error(t('Invalid repository owner pubkey')) + } + return createGitReleaseDraftEvent(cleanedText, { + repoOwnerPubkey: ownerHex, + repoId: releaseRepoId.trim(), + tagName: releaseTagName.trim(), + tagHash: releaseTagHash.trim().toLowerCase(), + title: releaseTitle.trim() || undefined, + downloadUrl: releaseDownloadUrl.trim() || undefined, + isDraft: releaseDraft, + isPrerelease: releasePrerelease + }) + } + // Citations if (isCitationInternal) { return createCitationInternalDraftEvent(cleanedText, { @@ -781,6 +827,15 @@ export default function PostContent({ isWikiArticle, isWikiArticleMarkdown, isPublicationContent, + isGitRelease, + releaseRepoOwnerInput, + releaseRepoId, + releaseTagName, + releaseTagHash, + releaseTitle, + releaseDownloadUrl, + releaseDraft, + releasePrerelease, isCitationInternal, isCitationExternal, isCitationHardcopy, @@ -798,7 +853,8 @@ export default function PostContent({ articleImage, articleSubject, articleSummary, - pubkey + pubkey, + t ]) // Function to generate draft event JSON for preview @@ -808,7 +864,10 @@ export default function PostContent({ if (isArticle && !articleDTag.trim()) { throw new Error(t('D-Tag is required for articles')) } - + if (isGitRelease && !releaseFieldsOk) { + throw new Error(t('Fill repository release fields')) + } + if (!pubkey) { return JSON.stringify({ error: 'Not logged in' }, null, 2) } @@ -830,6 +889,8 @@ export default function PostContent({ isWikiArticleMarkdown, isPublicationContent, articleDTag, + isGitRelease, + releaseFieldsOk, createDraftEvent, t ]) @@ -998,6 +1059,11 @@ export default function PostContent({ // When enabling poll mode, clear other modes setIsPublicMessage(false) setIsHighlight(false) + setIsGitRelease(false) + setIsCitationInternal(false) + setIsCitationExternal(false) + setIsCitationHardcopy(false) + setIsCitationPrompt(false) } } @@ -1009,6 +1075,11 @@ export default function PostContent({ // When enabling public message mode, clear other modes setIsPoll(false) setIsHighlight(false) + setIsGitRelease(false) + setIsCitationInternal(false) + setIsCitationExternal(false) + setIsCitationHardcopy(false) + setIsCitationPrompt(false) } } @@ -1020,6 +1091,11 @@ export default function PostContent({ // When enabling highlight mode, clear other modes and set client tag to true setIsPoll(false) setIsPublicMessage(false) + setIsGitRelease(false) + setIsCitationInternal(false) + setIsCitationExternal(false) + setIsCitationHardcopy(false) + setIsCitationPrompt(false) setAddClientTag(true) } } @@ -1407,7 +1483,8 @@ export default function PostContent({ setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) - + setIsGitRelease(false) + // Clear uploaded file from map and picture accumulation ref uploadedMediaFileMap.current.clear() pictureImetaTagsRef.current = [] @@ -1426,6 +1503,7 @@ export default function PostContent({ setIsPublicMessage(false) setIsHighlight(false) setMediaNoteKind(null) + setIsGitRelease(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) @@ -1455,7 +1533,8 @@ export default function PostContent({ const handleCitationToggle = (type: 'internal' | 'external' | 'hardcopy' | 'prompt') => { if (parentEvent) return // Can't create citations as replies - + + setIsGitRelease(false) setIsCitationInternal(type === 'internal') setIsCitationExternal(type === 'external') setIsCitationHardcopy(type === 'hardcopy') @@ -1477,6 +1556,24 @@ export default function PostContent({ } } + const handleGitReleaseFromMenu = () => { + if (parentEvent) return + + setIsGitRelease(true) + setIsCitationInternal(false) + setIsCitationExternal(false) + setIsCitationHardcopy(false) + setIsCitationPrompt(false) + setIsPoll(false) + setIsPublicMessage(false) + setIsHighlight(false) + setMediaNoteKind(null) + setIsLongFormArticle(false) + setIsWikiArticle(false) + setIsWikiArticleMarkdown(false) + setIsPublicationContent(false) + } + const handleClear = () => { // Clear the post editor cache postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) @@ -1502,6 +1599,15 @@ export default function PostContent({ setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) + setIsGitRelease(false) + setReleaseRepoOwnerInput('') + setReleaseRepoId('') + setReleaseTagName('') + setReleaseTagHash('') + setReleaseTitle('') + setReleaseDownloadUrl('') + setReleaseDraft(false) + setReleasePrerelease(false) // Clear citation fields setCitationInternalCTag('') setCitationInternalRelayHint('') diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index a1f44fd0..1cdfa05a 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -99,6 +99,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string if (!isReply && !showKind1OPs) return false } if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false + if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false return true }, [profileTimelineShowKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies] diff --git a/src/constants.ts b/src/constants.ts index 6965c477..1e229064 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,13 @@ import { kinds, type Filter } from 'nostr-tools' export const JUMBLE_API_BASE_URL = (import.meta.env.VITE_JUMBLE_API_BASE_URL as string | undefined) ?? 'https://api.jumble.imwald.eu' +/** Git Republic web UI for repository links; override with VITE_GITREPUBLIC_WEB_BASE_URL for self-hosted. */ +export const GITREPUBLIC_WEB_BASE_URL = ( + (import.meta.env.VITE_GITREPUBLIC_WEB_BASE_URL as string | undefined) ?? 'https://gitrepublic.imwald.eu' +) + .trim() + .replace(/\/$/, '') + /** * Piper TTS proxy (same contract as aitherboard `POST /api/piper-tts`: JSON `{ text, voice?, speed? }`, body `audio/wav`). * Set `VITE_READ_ALOUD_TTS_URL` to your deployed aitherboard URL, e.g. `https://aitherboard.example.com/api/piper-tts`. @@ -350,7 +357,13 @@ export const ExtendedKind = { /** NIP-58 Badges: badge definition (addressable) */ BADGE_DEFINITION: 30009, /** Web page bookmark (URL in i/I or r tags); used in RSS+Web relay discovery */ - WEB_BOOKMARK: 39701 + WEB_BOOKMARK: 39701, + /** NIP-34 / Git Republic: repository announcement (addressable) */ + GIT_REPO_ANNOUNCEMENT: 30617, + /** NIP-34 / Git Republic: issue */ + GIT_ISSUE: 1621, + /** Git Republic: release (linked to repo via `a` tag) */ + GIT_RELEASE: 1642 } /** @@ -452,7 +465,10 @@ export const SUPPORTED_KINDS = [ // ExtendedKind.PUBLICATION_CONTENT, // Excluded - publication content should only be embedded in publications // NIP-89 Application Handlers ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION, - ExtendedKind.APPLICATION_HANDLER_INFO + ExtendedKind.APPLICATION_HANDLER_INFO, + ExtendedKind.GIT_REPO_ANNOUNCEMENT, + ExtendedKind.GIT_ISSUE, + ExtendedKind.GIT_RELEASE ] /** @@ -472,7 +488,11 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter( * and most faux spells. Reposts are still shown on profile timelines, Spells → Following, and Follows latest. */ export const DEFAULT_FEED_SHOW_KINDS = PROFILE_FEED_KINDS.filter( - (k) => k !== kinds.Repost && k !== ExtendedKind.GENERIC_REPOST + (k) => + k !== kinds.Repost && + k !== ExtendedKind.GENERIC_REPOST && + k !== ExtendedKind.GIT_REPO_ANNOUNCEMENT && + k !== ExtendedKind.GIT_ISSUE ) /** Order for faux-spells in the feed / spell picker. */ diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 3b3804c8..40043733 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -724,6 +724,17 @@ export default { 'Voice Posts': 'Sprachbeiträge', 'Photo Posts': 'Fotobeiträge', 'Video Posts': 'Videobeiträge', + 'Git repositories': 'Git-Repositories', + 'Git issues': 'Git-Issues', + 'Git releases': 'Git-Releases', + 'Git Republic repository': 'Git-Republic-Repository', + 'Git Republic issue': 'Git-Republic-Issue', + 'Git Republic release': 'Git-Republic-Release', + 'Git Republic event': 'Git-Republic-Ereignis', + 'Git Republic': 'Git Republic', + 'Open in Git Republic': 'In Git Republic öffnen', + 'Pre-release': 'Vorabversion', + Draft: 'Entwurf', 'Select All': 'Alle auswählen', 'Clear All': 'Alle löschen', 'Set as default filter': 'Als Standardfilter festlegen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8005fc9f..e1bfda26 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -769,6 +769,17 @@ export default { 'Voice Posts': 'Voice Posts', 'Photo Posts': 'Photo Posts', 'Video Posts': 'Video Posts', + 'Git repositories': 'Git repositories', + 'Git issues': 'Git issues', + 'Git releases': 'Git releases', + 'Git Republic repository': 'Git Republic repository', + 'Git Republic issue': 'Git Republic issue', + 'Git Republic release': 'Git Republic release', + 'Git Republic event': 'Git Republic event', + 'Git Republic': 'Git Republic', + 'Open in Git Republic': 'Open in Git Republic', + 'Pre-release': 'Pre-release', + Draft: 'Draft', 'Select All': 'Select All', 'Clear All': 'Clear All', 'Set as default filter': 'Set as default filter', diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 437e17e4..547e30fe 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -2223,3 +2223,44 @@ export function createCitationPromptDraftEvent( created_at: dayjs().unix() } } + +/** Git Republic release (kind 1642); mirrors `releases-service` tag layout. */ +export function createGitReleaseDraftEvent( + content: string, + options: { + repoOwnerPubkey: string + repoId: string + tagName: string + tagHash: string + title?: string + downloadUrl?: string + isDraft?: boolean + isPrerelease?: boolean + } +): TDraftEvent { + const repoAddress = `${ExtendedKind.GIT_REPO_ANNOUNCEMENT}:${options.repoOwnerPubkey}:${options.repoId}` + const tags: string[][] = [ + ['a', repoAddress], + ['p', options.repoOwnerPubkey], + ['tag', options.tagName], + ['r', options.tagHash, '', 'tag'] + ] + if (options.title) { + tags.push(['title', options.title]) + } + if (options.downloadUrl) { + tags.push(['r', options.downloadUrl, '', 'download']) + } + if (options.isDraft) { + tags.push(['draft', 'true']) + } + if (options.isPrerelease) { + tags.push(['prerelease', 'true']) + } + return { + kind: ExtendedKind.GIT_RELEASE, + content, + tags, + created_at: dayjs().unix() + } +} diff --git a/src/lib/git-republic-event.ts b/src/lib/git-republic-event.ts new file mode 100644 index 00000000..04f05c7c --- /dev/null +++ b/src/lib/git-republic-event.ts @@ -0,0 +1,56 @@ +import { ExtendedKind, GITREPUBLIC_WEB_BASE_URL } from '@/constants' +import type { Event } from 'nostr-tools' +import { nip19 } from 'nostr-tools' + +export type GitRepublicRepoContext = { + ownerHex: string + repoId: string + /** From kind 30617 `name` tag when available */ + displayName?: string +} + +/** + * Resolve owner pubkey, repo id, and optional display name for Git Republic events. + * Kind 30617 uses `d` + `name`; issues and releases reference the repo via `a` (30617:pubkey:repoId). + */ +export function getGitRepublicRepoContext(event: Event): GitRepublicRepoContext | null { + if (event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT) { + const d = event.tags.find((t) => t[0] === 'd')?.[1] + if (!d) return null + return { + ownerHex: event.pubkey, + repoId: d, + displayName: event.tags.find((t) => t[0] === 'name')?.[1] + } + } + + const a = event.tags.find((t) => t[0] === 'a')?.[1] + if (!a) return null + const parts = a.split(':') + if (parts.length !== 3 || parts[0] !== String(ExtendedKind.GIT_REPO_ANNOUNCEMENT)) return null + return { ownerHex: parts[1], repoId: parts[2] } +} + +/** Accepts hex pubkey or `npub…` for Git Republic repo owner fields in forms. */ +export function parseRepoOwnerPubkeyInput(input: string): string | null { + const t = input.trim() + if (!t) return null + if (/^[0-9a-fA-F]{64}$/.test(t)) return t.toLowerCase() + try { + const dec = nip19.decode(t) + if (dec.type === 'npub') return dec.data as string + } catch { + return null + } + return null +} + +export function gitRepublicRepoWebUrl(ctx: GitRepublicRepoContext): string | null { + try { + const npub = nip19.npubEncode(ctx.ownerHex) + const repo = encodeURIComponent(ctx.repoId) + return `${GITREPUBLIC_WEB_BASE_URL}/repos/${npub}/${repo}` + } catch { + return null + } +} diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts index 6d61fb90..23e39659 100644 --- a/src/lib/kind-description.ts +++ b/src/lib/kind-description.ts @@ -125,6 +125,12 @@ export function getKindDescription( return { number: 8, description: 'Badge award' } case ExtendedKind.WEB_BOOKMARK: return { number: 39701, description: 'Web bookmark' } + case ExtendedKind.GIT_REPO_ANNOUNCEMENT: + return { number: 30617, description: 'Git repository' } + case ExtendedKind.GIT_ISSUE: + return { number: 1621, description: 'Git issue' } + case ExtendedKind.GIT_RELEASE: + return { number: 1642, description: 'Git release' } default: return { number: kind, description: `Event (kind ${kind})` } } diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index acca424e..88e865c7 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -301,13 +301,18 @@ class LocalStorageService { showKinds.push(ExtendedKind.ZAP_POLL) } } + if (showKindsVersion < 11) { + if (!showKinds.includes(ExtendedKind.GIT_RELEASE)) { + showKinds.push(ExtendedKind.GIT_RELEASE) + } + } // v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent). this.showKinds = showKinds // Only persist when we read from localStorage. If SHOW_KINDS is missing here (migrated to IDB and // keys cleared), persisting would write DEFAULT_FEED_SHOW_KINDS to IndexedDB and wipe the user's // saved filter before initAsync/applySettings runs. this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) - this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '10') + this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '11') } // Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set)