diff --git a/AGENTS.md b/AGENTS.md index 32a2f122..98f55479 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1596,7 +1596,7 @@ The project automatically publishes Android AABs (App Bundles) to [Google Play]( | Variable | Description | Protected | Masked | Raw | |---|---|---|---|---| -| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No | +| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON file. The CI job decodes it with `base64 -d` before passing it to `fastlane supply`. | Yes | Yes | No | #### Initial Setup (one-time) @@ -1604,7 +1604,17 @@ The project automatically publishes Android AABs (App Bundles) to [Google Play]( 2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project 3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it 4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app` -5. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**. +5. **Base64-encode** the key file: + + ```bash + # Linux + base64 -w0 service-account.json + + # macOS + base64 -i service-account.json | tr -d '\n' + ``` + +6. Add the base64-encoded value as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**. Do **not** paste the raw JSON — the CI script expects base64 and will fail to decode a raw value. #### Key Points diff --git a/CHANGELOG.md b/CHANGELOG.md index 2641aeb5..d4a01377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2.7.1] - 2026-04-16 + +### Added +- Tap the Home tab while already on Home to scroll to the top and refresh your feed +- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch +- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone + +### Changed +- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen +- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder +- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection +- Android's automatic cloud backup now excludes your wallet credentials + +### Fixed +- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed +- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds +- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data + ## [2.7.0] - 2026-04-14 ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index 83a7c0b0..3ec513ee 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "2.7.0" + versionName "2.7.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2b3b5d22..45a90195 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..d70bdc19 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 1ebe0aeb..d8b4b2ea 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -327,7 +327,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.7.0; + MARKETING_VERSION = 2.7.1; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.7.0; + MARKETING_VERSION = 2.7.1; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package-lock.json b/package-lock.json index 9207368a..01104cd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.7.0", + "version": "2.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.7.0", + "version": "2.7.1", "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", diff --git a/package.json b/package.json index 61e2d210..c862c1c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ditto", "private": true, - "version": "2.7.0", + "version": "2.7.1", "type": "module", "scripts": { "dev": "npm i --silent && vite", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 2641aeb5..d4a01377 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2.7.1] - 2026-04-16 + +### Added +- Tap the Home tab while already on Home to scroll to the top and refresh your feed +- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch +- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone + +### Changed +- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen +- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder +- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection +- Android's automatic cloud backup now excludes your wallet credentials + +### Fixed +- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed +- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds +- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data + ## [2.7.0] - 2026-04-14 ### Added diff --git a/src/components/AppHandlerContent.tsx b/src/components/AppHandlerContent.tsx index cbd531d8..2bc153ee 100644 --- a/src/components/AppHandlerContent.tsx +++ b/src/components/AppHandlerContent.tsx @@ -105,8 +105,12 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App'; const about = metadata.about; - const picture = metadata.picture; - const banner = metadata.banner; + // Sanitize image URLs to reject non-https schemes (http IP leaks, data: URIs, + // etc.). The CSP \`img-src\` already blocks most of these, but sanitizing + // defense-in-depth matches the treatment of the website URL below and keeps + // the component safe if it is ever rendered outside the app's own CSP. + const picture = sanitizeUrl(metadata.picture); + const banner = sanitizeUrl(metadata.banner); const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata)); const hashtags = getAllTags(event.tags, 't'); diff --git a/src/components/DMProvider.tsx b/src/components/DMProvider.tsx index 192aebf8..1c271d43 100644 --- a/src/components/DMProvider.tsx +++ b/src/components/DMProvider.tsx @@ -905,6 +905,29 @@ export function DMProvider({ children, config }: DMProviderProps) { const messageContent = await user.signer.nip44.decrypt(sealEvent.pubkey, sealEvent.content); const messageEvent = JSON.parse(messageContent) as NostrEvent; + // NIP-17: clients MUST verify that the inner rumor's pubkey matches the + // seal's pubkey. Without this check, anyone can gift-wrap a rumor whose + // `pubkey` field claims to be someone else and impersonate that user. + // The seal signature authenticates only the seal author, not whatever + // pubkey appears inside the (unsigned) rumor. + if (messageEvent.pubkey !== sealEvent.pubkey) { + console.log(`[DM] ⚠️ NIP-17 IMPERSONATION ATTEMPT - inner pubkey does not match seal pubkey`, { + giftWrapId: event.id, + sealPubkey: sealEvent.pubkey, + innerPubkey: messageEvent.pubkey, + }); + return { + processedMessage: { + ...event, + content: '', + decryptedContent: '', + error: 'Inner event pubkey does not match seal pubkey (possible impersonation)', + }, + conversationPartner: event.pubkey, + sealEvent, + }; + } + // Accept both kind 14 (text) and kind 15 (files/attachments) if (messageEvent.kind !== 14 && messageEvent.kind !== 15) { console.log(`[DM] ⚠️ NIP-17 MESSAGE WITH UNSUPPORTED INNER EVENT KIND:`, { diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 8161f05f..ece7aa70 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useInView } from 'react-intersection-observer'; import { useNostr } from '@nostrify/react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { usePageRefresh } from '@/hooks/usePageRefresh'; import { ComposeBox } from '@/components/ComposeBox'; import { LandingHero } from '@/components/LandingHero'; @@ -19,8 +19,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFeedTab } from '@/hooks/useFeedTab'; import { useInterests } from '@/hooks/useInterests'; import { useMuteList } from '@/hooks/useMuteList'; +import { useTabFeed } from '@/hooks/useProfileFeed'; import { useSavedFeeds } from '@/hooks/useSavedFeeds'; -import { useStreamPosts } from '@/hooks/useStreamPosts'; import { useResolveTabFilter } from '@/hooks/useResolveTabFilter'; import { useCuratorFollowList } from '@/hooks/useCuratorFollowList'; import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed'; @@ -355,11 +355,11 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee ); } -/** Renders a saved search feed using useStreamPosts (live streaming). */ +/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */ function SavedFeedContent({ feed }: { feed: SavedFeed }) { const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' }); const { user } = useCurrentUser(); - const queryClient = useQueryClient(); + const { muteItems } = useMuteList(); // Resolve variable placeholders ($follows etc.) the same way profile tabs do const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter( @@ -368,32 +368,62 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { user?.pubkey ?? '', ); - const search = typeof resolvedFilter?.search === 'string' ? resolvedFilter.search : ''; - const kindsOverride = Array.isArray(resolvedFilter?.kinds) ? resolvedFilter.kinds as number[] : undefined; - const authorPubkeys = Array.isArray(resolvedFilter?.authors) ? resolvedFilter.authors as string[] : undefined; + // Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension) + // to match the behavior of the core feeds and ensure latest native Nostr + // posts are returned. + const augmentedFilter = useMemo(() => { + if (!resolvedFilter) return null; + const existing = resolvedFilter.search ?? ''; + const search = existing.includes('protocol:nostr') + ? existing + : existing + ? `${existing} protocol:nostr` + : 'protocol:nostr'; + return { ...resolvedFilter, search }; + }, [resolvedFilter]); - const { posts, isLoading: isStreamLoading } = useStreamPosts(search, { - includeReplies: true, - mediaType: 'all', - kindsOverride, - authorPubkeys: authorPubkeys && authorPubkeys.length > 0 ? authorPubkeys : undefined, - }); + const { + data: rawData, + isLoading: isFeedLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving); - const isLoading = isResolving || isStreamLoading; + const isLoading = isResolving || isFeedLoading; - // useStreamPosts doesn't use TanStack Query, so refresh by invalidating the - // resolution query and letting the stream reconnect via remount. - const handleRefresh = useCallback(async () => { - await queryClient.invalidateQueries({ queryKey: ['resolve-tab-filter'] }); - }, [queryClient]); + // Prefix key -- usePageRefresh does prefix matching, so this invalidates + // the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed. + const queryKey = useMemo( + () => ['tab-feed', `saved-${feed.id}`], + [feed.id], + ); + const handleRefresh = usePageRefresh(queryKey); - // Simple scroll-based load more isn't available with useStreamPosts (it's a stream), - // but we still wire the ref for future pagination support + // Infinite scroll: fetch next page when sentinel is in view useEffect(() => { - // intentionally empty — useStreamPosts handles its own streaming - }, [inView]); + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); - if (isLoading && posts.length === 0) { + // Flatten pages, deduplicate, and filter muted content + const feedItems = useMemo(() => { + if (!rawData?.pages) return []; + const seen = new Set(); + return rawData.pages + .flatMap((page) => page.items) + .filter((item) => { + const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id; + if (!key || seen.has(key)) return false; + seen.add(key); + if (shouldHideFeedEvent(item.event)) return false; + if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false; + return true; + }); + }, [rawData?.pages, muteItems]); + + if (isLoading && feedItems.length === 0) { return (
{Array.from({ length: 5 }).map((_, i) => ( @@ -403,10 +433,10 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { ); } - if (posts.length === 0) { + if (feedItems.length === 0) { return ( - + ); } @@ -414,10 +444,23 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) { return (
- {posts.map((event) => ( - + {feedItems.map((item) => ( + ))} -
+ {hasNextPage && ( +
+ {isFetchingNextPage && ( +
+ +
+ )} +
+ )} + {!hasNextPage &&
}
); diff --git a/src/components/InitialSyncGate.tsx b/src/components/InitialSyncGate.tsx index d8b9f8a9..8a159293 100644 --- a/src/components/InitialSyncGate.tsx +++ b/src/components/InitialSyncGate.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Check, ChevronRight, + Download, Eye, EyeOff, Heart, @@ -13,6 +14,7 @@ import { } from "lucide-react"; import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; import { saveNsec } from "@/lib/credentialManager"; +import { openUrl } from "@/lib/downloadFile"; import { fetchFreshEvent } from "@/lib/fetchFreshEvent"; import { type ReactNode, @@ -255,7 +257,7 @@ function SetupQuestionnaire({ isSignup?: boolean; }) { const { nostr } = useNostr(); - const { updateConfig } = useAppContext(); + const { config, updateConfig } = useAppContext(); const { user } = useCurrentUser(); const { updateSettings } = useEncryptedSettings(); const login = useLoginActions(); @@ -300,6 +302,18 @@ function SetupQuestionnaire({ // Continue handler for the download step — saves the key via the best // available method (native credential manager on iOS/Android, file download // on web), logs in, and advances to the next step. + // + // If the user dismisses the iOS credential prompt, `saveNsec` resolves to + // `'dismissed'` and we still advance — dismissal is a legitimate choice + // (e.g. the user is saving the key in their own password manager). + // + // On Android, if no credential provider is available (e.g. GrapheneOS or + // other de-Googled devices), `saveNsec` falls back to writing the key to + // the app's Documents folder and returns `'saved-to-file'`. We surface a + // toast so the user knows where to find the backup file. + // + // Only unexpected errors (decode failure, filesystem write failure) + // surface as a destructive toast. const handleDownloadContinue = useCallback(async () => { try { const decoded = nip19.decode(nsec); @@ -308,7 +322,15 @@ function SetupQuestionnaire({ const pubkey = getPublicKey(decoded.data); const npub = nip19.npubEncode(pubkey); - await saveNsec(npub, nsec); + const result = await saveNsec(npub, nsec, config.appName); + + if (result === "saved-to-file") { + toast({ + title: "Secret key saved", + description: + "Your secret key was saved to the Documents folder on your device.", + }); + } login.nsec(nsec); next(); @@ -320,7 +342,7 @@ function SetupQuestionnaire({ variant: "destructive", }); } - }, [nsec, login, next]); + }, [nsec, login, next, config.appName]); // Save settings and transition to the follows step (or outro if they have follows) const handleSaveAndContinue = useCallback(async () => { @@ -496,7 +518,7 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) { Create your account

- Your identity on Nostr is a cryptographic key pair. We'll generate one + Your identity on Nostr is a cryptographic key. We'll generate one for you now.

@@ -506,7 +528,7 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) { className="w-full max-w-xs gap-2 rounded-full h-12" onClick={onGenerate} > - Generate my keys + Generate my key
@@ -518,18 +540,34 @@ function DownloadStep({ onContinue, }: { nsec: string; - onContinue: () => void; + onContinue: () => Promise | void; }) { + const { config } = useAppContext(); const [showKey, setShowKey] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + // Wrap the continue handler in an in-flight guard so rapid double-taps + // don't trigger multiple credential prompts. `finally` guarantees the + // button is re-enabled even if the handler throws, so users can never + // get stuck on a disabled button. + const handleClick = async () => { + if (isSaving) return; + setIsSaving(true); + try { + await onContinue(); + } finally { + setIsSaving(false); + } + }; return (

- Save your secret key + Your secret key

- This is your only way to access your account. Keep it somewhere safe. + This secret key controls your account on {config.appName}. You'll need it to log in later. Without it, you'll lose your account.

@@ -538,6 +576,8 @@ function DownloadStep({ type={showKey ? "text" : "password"} value={nsec} readOnly + onFocus={(e) => e.currentTarget.select()} + onClick={(e) => e.currentTarget.select()} className="pr-10 font-mono text-base md:text-sm" />
-
-

- Important -

-

- This key is your only means of accessing your account. If you lose it, - there is no way to recover it. -

-
+ {showKey && ( +
+

+ NEVER share your secret key with anyone. Avoid screenshotting your key or pasting it anywhere except a password manager. If shared, others will be able to access your account.{" "} + { + e.preventDefault(); + openUrl("https://soapbox.pub/blog/managing-nostr-keys/"); + }} + className="underline underline-offset-2 hover:no-underline" + > + Learn more + +

+
+ )}
); diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index bef31ca7..18c310be 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -18,6 +18,7 @@ import { useProfileBadges } from '@/hooks/useProfileBadges'; import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions'; import { BadgeShowcaseGrid } from '@/components/BadgeShowcaseGrid'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; /** Shared classes for all editable fields — static muted bg when idle, border on hover/focus */ const editableBase = [ @@ -129,6 +130,9 @@ export function ProfileCard({ const initial = displayName[0]?.toUpperCase() ?? '?'; const patch = (key: keyof NostrMetadata) => (v: string) => onChange?.({ [key]: v }); + // Sanitize banner URL from untrusted metadata before CSS url() interpolation + const bannerUrl = sanitizeUrl(metadata.banner); + // Read shape from metadata (it's a custom property passed through the loose schema) const rawShape = metadata.shape; const shape: AvatarShape | undefined = isValidAvatarShape(rawShape) ? rawShape : undefined; @@ -187,8 +191,8 @@ export function ProfileCard({
editable && onPickImage?.('banner')} diff --git a/src/components/SandboxFrame.tsx b/src/components/SandboxFrame.tsx index e5060e02..0e37b587 100644 --- a/src/components/SandboxFrame.tsx +++ b/src/components/SandboxFrame.tsx @@ -33,7 +33,7 @@ import { // --------------------------------------------------------------------------- export interface SandboxFrameProps - extends Omit, 'src' | 'id'> { + extends Omit, 'src' | 'id' | 'sandbox'> { /** HMAC-derived subdomain identifier. */ id: string; /** @@ -324,6 +324,20 @@ const SandboxFrameWeb = forwardRef(