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)