Compare commits

...

18 Commits

Author SHA1 Message Date
Alex Gleason 67e8c23020 release: v2.2.3 2026-03-30 09:59:34 -05:00
Alex Gleason 94f0c8308d Show all sidebar items to logged-out users and update sidebar order
Stop filtering requiresAuth items from navigation. Pages already render
their own LoginArea when the user is not logged in, so hiding the items
from the sidebar, mobile drawer, and search prevented feature discovery
without providing any benefit.

Also update the default sidebar order: remove bookmarks and profile,
add letters.
2026-03-30 09:56:02 -05:00
Chad Curtis 80820ae9c4 Expand compose textarea smoothly as you type 2026-03-30 07:36:16 -05:00
Chad Curtis 5288b7a718 Letters: overflow menu, reply button, grid layout, and UX polish
- EnvelopeCard: add ... overflow menu trigger using NoteMoreMenu (no duplicated logic)
- NoteMoreMenu: detect NIP-44 ciphertext by content shape, show 'Encrypted content'
- LetterDetailSheet: reply button (InkPenIcon, primary colors); gift above card; letter centered in viewport; click outside closes
- LettersPage: reply pre-fills sender npub; default stationery falls back to parchment when no custom theme; 2-col mobile / 4-col desktop grid
- NotificationsPage: reply button next to 'View all letters' in letter notifications
- ComposeLetterSheet: auto-focus textarea on open; fix To field overflow on narrow screens
- InkPenIcon: new custom icon replacing PenLine on FAB and reply buttons
2026-03-30 06:21:50 -05:00
Chad Curtis 4643830512 Sync letter improvements from lief
- Make LetterContent.body optional; a letter requires a non-empty body
  or at least one sticker
- Replace colors:[]/flatMode with event-stripping: flat color moment =
  event field stripped from Stationery, removing the colors? field
- Remove edgeScale: stickers render at their stored scale value with no
  edge-proximity size reduction, matching lief and the NIP
- Fix sticker shrinking near card edges: add max-width:none to override
  Tailwind preflight max-width:100% on img/svg elements
2026-03-30 04:30:00 -05:00
Chad Curtis ef04de67c0 Improve notification rendering for badges and letters
- Add hideKindHeader prop to NoteCard, used by ReferencedNoteCard to
  suppress redundant action headers in repost/reaction/zap notifications
- Redesign badge award notification: show full BadgeContent showcase card
  with prominent rounded-pill Accept Badge button below
- Redesign letter notification: larger centered envelope (minimal mode
  hides name/timestamp), click opens LetterDetailSheet inline, View All
  Letters button below
2026-03-30 00:13:01 -05:00
Chad Curtis 5847cceba6 Auto-shrink stickers near card edges and clip overflow at rounded boundary
Stickers now scale down proportionally as their center approaches any
edge of the letter card, preventing them from overflowing the rounded
corners. A 5% drag buffer keeps sticker centers away from the very
edge. The sticker overlay uses pointer-events-none so the textarea
remains clickable underneath.
2026-03-29 23:53:20 -05:00
Chad Curtis f62b86027c Remove unused eslint-disable directive in LayoutContext 2026-03-29 22:59:41 -05:00
Chad Curtis 8e5018d3b2 Fix 3 letter bugs: allow drawing-only sends, fix sticker drag bounds, preserve theme events
- Allow sending letters with only stickers/drawings (no body text required)
- Fix sticker drag positioning by using cardRef instead of page container
  ref, so percentage coordinates map correctly to the card boundaries
- Preserve stationery source events (color moments/themes) in preferences
  so they embed as gift attachments in sent letters
- Accept sticker-only letters during decryption validation
2026-03-29 22:41:03 -05:00
Chad Curtis 8df17f5ae7 Fix top bar arc flash by deferring layout cleanup past Suspense boundary 2026-03-29 22:23:23 -05:00
Alex Gleason dd31ce681f Add Blobbi to default sidebar order after Badges 2026-03-29 22:05:31 -05:00
Alex Gleason 74345fdb2f Fix feed gaps when replies are disabled by over-fetching from relay
When followsFeedShowReplies is false, the relay limit was PAGE_SIZE (15)
but client-side reply filtering could discard most events, leaving only
a few visible posts per page with large time gaps between them.

Apply the same over-fetch pattern already used by useProfileFeed:
- Request PAGE_SIZE * 3 events when reply filtering is active
- Use rawCount (pre-filter) for pagination termination so pages where
  all items are replies don't prematurely stop pagination
2026-03-29 18:51:20 -05:00
Alex Gleason 02231ea1f9 Fix avatar shape flash by computing mask URL synchronously
The Avatar component was initializing maskUrl as '' and loading it in a
useEffect. Since hasCustomShape was true immediately, rounded-full was
removed on the first render, but the mask wasn't applied until after the
effect fired — causing a visible square flash for one frame.

getAvatarMaskUrl is already synchronous (renders emoji to canvas, caches
the data-URL), so compute it inline during render instead of deferring
to an effect. The mask is now applied on the very first paint.
2026-03-29 17:47:57 -05:00
Alex Gleason effc704613 Fix 'Cannot update component while rendering' warning in useLayoutOptions
useLayoutOptions was calling store.setOptions() synchronously during
render, which triggered useSyncExternalStore listeners in MobileBottomNav
(and MainLayout) while Index was still rendering.

Move the store update into useLayoutEffect, which fires synchronously
after commit but before browser paint — same visual result without
violating React's setState-during-render rule.
2026-03-29 17:43:55 -05:00
Alex Gleason 51fb1fd1cb Give comments (kind 1111) and generic reposts (kind 16) independent feed toggles
Previously comments shared feedKey 'feedIncludePosts' with kind 1, and
generic reposts shared 'feedIncludeReposts' with kind 6. This made it
impossible to toggle them independently in settings.

Add feedIncludeComments and feedIncludeGenericReposts to FeedSettings
and wire them to their respective EXTRA_KINDS entries.
2026-03-29 17:33:56 -05:00
Alex Gleason 1988e1b849 Fix duplicate React keys in content settings
Multiple ExtraKindDef entries share the same feedKey (e.g. posts/comments
both use feedIncludePosts) and multiple subKinds share the same showKey
(e.g. both video sub-kinds use showVideos). Using these as React keys
caused 'duplicate key' warnings.

Use def.id (always unique) for ContentTypeRow keys and sub.feedKey
(unique per sub-kind) for SubKindRow keys.
2026-03-29 17:29:45 -05:00
Alex Gleason 1b782f65d1 Display QR code in terminal for NIP-46 auth script 2026-03-29 16:58:22 -05:00
Alex Gleason 6c4eddece7 Add NIP-46 client-initiated auth script for Zapstore CI signing 2026-03-29 16:47:36 -05:00
39 changed files with 766 additions and 260 deletions
+14 -13
View File
@@ -1378,19 +1378,20 @@ NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
**Initial setup (one-time):**
1. Generate a client key: `nak key generate` (save the hex output)
2. Store it as `ZAPSTORE_CLIENT_KEY` in GitLab CI/CD variables
3. Get a bunker URL from Amber (with `secret` param for first connection)
4. Authorize the client key locally using `nak`:
```bash
export NOSTR_CLIENT_KEY="<the hex client key>"
nak event --sec "bunker://<pubkey>?relay=...&secret=<secret>" -c "test"
```
5. Approve the connection on Amber when prompted
6. Store the bunker URL **without the `secret` param** as `ZAPSTORE_BUNKER_URL` in GitLab CI/CD variables (the secret is single-use and no longer needed after authorization)
Run the NIP-46 client-initiated auth script:
```bash
node scripts/nip46-auth.mjs
```
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script will then output the `bunker://` URI and client key hex, and write the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
The script accepts options:
- `--relay <url>` -- relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
- `--name <name>` -- app name shown to the signer (default: `Ditto`)
- `--timeout <sec>` -- how long to wait for approval (default: 300)
**Key points:**
- The `secret` in bunker URLs is **single-use** -- it is consumed on first connection and cannot be reused
- The `ZAPSTORE_CLIENT_KEY` must be authorized locally first by connecting to the bunker with a fresh secret and approving on Amber
- After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs
- If the client key is rotated, the authorization step must be repeated with a new bunker URL secret
- If the client key is rotated, run the script again and update the GitLab CI/CD variables
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.2.2"
versionName "2.2.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.2;
MARKETING_VERSION = 2.2.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.2;
MARKETING_VERSION = 2.2.3;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.2.1",
"version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.2.1",
"version": "2.2.2",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.2.2",
"version": "2.2.3",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* NIP-46 Client-Initiated Auth Script
*
* Generates an ephemeral client keypair and a `nostrconnect://` URI.
* Import the URI into a remote signer app (e.g. Amber) to authorize
* the client key. Once authorized, the script outputs:
*
* - bunker:// URI (for ZAPSTORE_BUNKER_URL)
* - client secret key hex (for ZAPSTORE_CLIENT_KEY)
*
* It also writes the client key to ~/.config/zsp/bunker-keys/<bunkerPubkey>.key
* so that `zsp` can use it immediately.
*
* Usage:
* node scripts/nip46-auth.mjs [--relay wss://relay.example.com] [--name MyApp] [--timeout 300]
*/
import { NPool, NRelay1, NConnectSigner, NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
import QRCode from 'qrcode';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const result = {
relays: [],
name: 'Ditto',
timeout: 300, // seconds
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--relay':
result.relays.push(args[++i]);
break;
case '--name':
result.name = args[++i];
break;
case '--timeout':
result.timeout = parseInt(args[++i], 10);
break;
case '--help':
case '-h':
console.log(`Usage: node scripts/nip46-auth.mjs [options]
Options:
--relay <url> Relay URL for NIP-46 communication (repeatable)
Default: wss://relay.ditto.pub
--name <name> Application name shown to the signer
Default: Ditto
--timeout <sec> How long to wait for signer approval (seconds)
Default: 300 (5 minutes)
--help, -h Show this help message
`);
process.exit(0);
}
}
if (result.relays.length === 0) {
result.relays.push('wss://relay.ditto.pub');
}
return result;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
// 1. Generate ephemeral client keypair
const clientSecretKey = generateSecretKey();
const clientPubkey = getPublicKey(clientSecretKey);
const clientHex = bytesToHex(clientSecretKey);
console.log('');
console.log('=== NIP-46 Client-Initiated Auth ===');
console.log('');
console.log(`Client pubkey: ${clientPubkey}`);
console.log(`Relay(s): ${opts.relays.join(', ')}`);
console.log(`Timeout: ${opts.timeout}s`);
console.log('');
// 2. Generate random secret
const randomBytes = new Uint8Array(4);
crypto.getRandomValues(randomBytes);
const secret = Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// 3. Build nostrconnect:// URI
const searchParams = new URLSearchParams();
for (const relay of opts.relays) {
searchParams.append('relay', relay);
}
searchParams.set('secret', secret);
searchParams.set('name', opts.name);
const nostrConnectURI = `nostrconnect://${clientPubkey}?${searchParams.toString()}`;
console.log('Scan this QR code with your signer app (e.g. Amber):');
console.log('');
console.log(await QRCode.toString(nostrConnectURI, { type: 'terminal', small: true }));
console.log('Or import this URI manually:');
console.log('');
console.log(` ${nostrConnectURI}`);
console.log('');
console.log('Waiting for signer to approve the connection...');
console.log('');
// 4. Set up relay pool
const pool = new NPool({
open: (url) => new NRelay1(url),
reqRouter: async (filters) => new Map(opts.relays.map((r) => [r, filters])),
eventRouter: async () => opts.relays,
});
const clientSigner = new NSecSigner(clientSecretKey);
const relayGroup = pool.group(opts.relays);
// 5. Subscribe and wait for the signer's response
const signal = AbortSignal.timeout(opts.timeout * 1000);
const sub = relayGroup.req(
[{ kinds: [24133], '#p': [clientPubkey], limit: 1 }],
{ signal },
);
let bunkerPubkey;
let userPubkey;
try {
for await (const msg of sub) {
if (msg[0] === 'CLOSED') {
throw new Error('Relay closed the subscription before signer responded');
}
if (msg[0] === 'EVENT') {
const event = msg[2];
let decrypted;
try {
decrypted = await clientSigner.nip44.decrypt(event.pubkey, event.content);
} catch {
// Could not decrypt -- not for us, skip
continue;
}
let response;
try {
response = JSON.parse(decrypted);
} catch {
continue;
}
if (response.result !== secret && response.result !== 'ack') {
continue;
}
bunkerPubkey = event.pubkey;
console.log(`Signer responded! Bunker pubkey: ${bunkerPubkey}`);
console.log('');
// 6. Get user pubkey via the now-established connection
const signer = new NConnectSigner({
relay: relayGroup,
pubkey: bunkerPubkey,
signer: clientSigner,
timeout: 60_000,
});
console.log('Requesting user public key...');
userPubkey = await signer.getPublicKey();
console.log(`User pubkey: ${userPubkey}`);
console.log('');
break;
}
}
} catch (err) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
console.error(`Timed out after ${opts.timeout}s waiting for signer approval.`);
console.error('Make sure you imported the nostrconnect:// URI into your signer app.');
process.exit(1);
}
throw err;
}
if (!bunkerPubkey || !userPubkey) {
console.error('Failed to establish connection with remote signer.');
process.exit(1);
}
// 7. Build bunker:// URI (for CI)
const bunkerParams = new URLSearchParams();
for (const relay of opts.relays) {
bunkerParams.append('relay', relay);
}
const bunkerURI = `bunker://${bunkerPubkey}?${bunkerParams.toString()}`;
// 8. Write client key to zsp config
const zspDir = path.join(os.homedir(), '.config', 'zsp', 'bunker-keys');
const zspKeyFile = path.join(zspDir, `${bunkerPubkey}.key`);
fs.mkdirSync(zspDir, { recursive: true });
fs.writeFileSync(zspKeyFile, clientHex + '\n', { mode: 0o600 });
// 9. Print results
console.log('=== Connection Established ===');
console.log('');
console.log('Bunker URI (ZAPSTORE_BUNKER_URL):');
console.log(` ${bunkerURI}`);
console.log('');
console.log('Client secret key hex (ZAPSTORE_CLIENT_KEY):');
console.log(` ${clientHex}`);
console.log('');
console.log(`User pubkey: ${userPubkey}`);
console.log(`User npub: ${nip19.npubEncode(userPubkey)}`);
console.log('');
console.log(`zsp client key written to: ${zspKeyFile}`);
console.log('');
console.log('Next steps:');
console.log(' 1. Update ZAPSTORE_BUNKER_URL in GitLab CI/CD variables');
console.log(' 2. Update ZAPSTORE_CLIENT_KEY in GitLab CI/CD variables');
console.log('');
// Clean up
pool.close();
process.exit(0);
}
main().catch((err) => {
console.error('Fatal error:', err.message);
process.exit(1);
});
+4 -2
View File
@@ -59,7 +59,9 @@ const hardcodedConfig: AppConfig = {
},
feedSettings: {
feedIncludePosts: true,
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeArticles: true,
showArticles: true,
showEvents: true,
@@ -118,10 +120,10 @@ const hardcodedConfig: AppConfig = {
"feed",
"notifications",
"search",
"bookmarks",
"profile",
"themes",
"letters",
"badges",
"blobbi",
"theme",
"settings",
"help",
+11 -2
View File
@@ -234,6 +234,15 @@ export function ComposeBox({
}
}, [quotedEvent]);
// Auto-resize textarea height as content grows/shrinks
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
// Reset to auto so shrinking is detected correctly
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [content]);
const charCount = content.length;
const remaining = MAX_CHARS - charCount;
@@ -1162,10 +1171,10 @@ export function ComposeBox({
onPaste={handlePaste}
placeholder={placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words',
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={isExpanded ? 4 : 1}
rows={1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+3 -3
View File
@@ -199,7 +199,7 @@ function ContentTypeRow({ def }: { def: ExtraKindDef }) {
{hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
<SubKindRow
key={sub.showKey}
key={sub.feedKey}
sub={sub}
/>
))}
@@ -211,7 +211,7 @@ function NotesFeedSettings() {
return (
<>
{FEED_KINDS.map((def) => (
<ContentTypeRow key={def.feedKey ?? String(def.kind)} def={def} />
<ContentTypeRow key={def.id} def={def} />
))}
</>
);
@@ -231,7 +231,7 @@ function FeedSettingsFormInternals() {
</span>
</div>
{sectionKinds.map((def) => (
<ContentTypeRow key={def.feedKey ?? def.showKey ?? String(def.kind)} def={def} />
<ContentTypeRow key={def.id} def={def} />
))}
</div>
);
+2
View File
@@ -320,7 +320,9 @@ function SetupQuestionnaire({
showProfileThemeUpdates: true,
showCustomProfileThemes: true,
feedIncludePosts: true,
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeArticles: false,
feedIncludeVines: true,
feedIncludePolls: false,
+4 -11
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
UserPlus, LogOut,
@@ -26,7 +26,7 @@ import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getSidebarItem, isItemActive } from '@/lib/sidebarItems';
import { isItemActive } from '@/lib/sidebarItems';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
@@ -47,15 +47,8 @@ export function LeftSidebar() {
} = useFeedSettings();
const { config } = useAppContext();
const visibleItems = useMemo(() => {
if (user) return orderedItems;
return orderedItems.filter((id) => !getSidebarItem(id)?.requiresAuth);
}, [orderedItems, user]);
const visibleHiddenItems = useMemo(() => {
if (user) return hiddenItems;
return hiddenItems.filter((item) => !getSidebarItem(item.id)?.requiresAuth);
}, [hiddenItems, user]);
const visibleItems = orderedItems;
const visibleHiddenItems = hiddenItems;
const hasUnread = useHasUnreadNotifications();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
+5 -9
View File
@@ -20,7 +20,7 @@ import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getSidebarItem, isItemActive } from '@/lib/sidebarItems';
import { isItemActive } from '@/lib/sidebarItems';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
import { useUserStatus } from '@/hooks/useUserStatus';
@@ -86,19 +86,15 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const hasBgImage = Object.keys(bgStyle).length > 0;
const visibleItems = useMemo(() => {
const filtered = user ? orderedItems : orderedItems.filter((id) => !getSidebarItem(id)?.requiresAuth);
// Remove dividers that have no real items above them (at the top or right after another divider).
return filtered.filter((id, i) => {
return orderedItems.filter((id, i) => {
if (id !== 'divider') return true;
const prevNonDivider = filtered.slice(0, i).some((prev) => prev !== 'divider');
const prevNonDivider = orderedItems.slice(0, i).some((prev) => prev !== 'divider');
return prevNonDivider;
});
}, [orderedItems, user]);
}, [orderedItems]);
const visibleHiddenItems = useMemo(() => {
if (user) return hiddenItems;
return hiddenItems.filter((item) => !getSidebarItem(item.id)?.requiresAuth);
}, [hiddenItems, user]);
const visibleHiddenItems = hiddenItems;
const handleClose = () => { onOpenChange(false); setMoreMenuOpen(false); };
const handleLogout = async () => { await logout(); handleClose(); navigate('/'); };
+1 -3
View File
@@ -21,7 +21,6 @@ import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikip
import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch';
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils';
interface MobileSearchSheetProps {
@@ -32,7 +31,6 @@ interface MobileSearchSheetProps {
export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
@@ -51,7 +49,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const countryMatch = useMemo(() => searchCountry(query), [query]);
// Nav item suggestions (local, synchronous)
const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]);
const navItems = useMemo(() => searchSidebarItems(query), [query]);
// URL detection — show "Comment on" option when query is a full URL
const queryIsUrl = useMemo(() => isFullUrl(query), [query]);
+5 -2
View File
@@ -112,6 +112,8 @@ interface NoteCardProps {
threadedLast?: boolean;
/** If true, briefly highlight this card (e.g. newly loaded post). */
highlight?: boolean;
/** If true, suppress the kind-derived action header (e.g. "created a badge"). Used when the parent already provides context. */
hideKindHeader?: boolean;
}
/** Gets a tag value by name. */
@@ -183,6 +185,7 @@ export const NoteCard = memo(function NoteCard({
threaded,
threadedLast,
highlight,
hideKindHeader,
}: NoteCardProps) {
const { config } = useAppContext();
const { user } = useCurrentUser();
@@ -1034,7 +1037,7 @@ export const NoteCard = memo(function NoteCard({
// ── Threaded layout (with or without connector line) ──
if (threaded || threadedLast) {
// Kind action header (e.g. "updated their badges") — same logic as normal layout
const threadedKindHeader = !repostedBy && KIND_HEADER_MAP[event.kind]
const threadedKindHeader = !repostedBy && !hideKindHeader && KIND_HEADER_MAP[event.kind]
? (() => {
const cfg = KIND_HEADER_MAP[event.kind];
const isLive = event.kind === 30311 && getEffectiveStreamStatus(event) === "live";
@@ -1123,7 +1126,7 @@ export const NoteCard = memo(function NoteCard({
action="reposted"
/>
) : (
KIND_HEADER_MAP[event.kind] &&
!hideKindHeader && KIND_HEADER_MAP[event.kind] &&
(() => {
const cfg = KIND_HEADER_MAP[event.kind];
const isLive =
+5 -1
View File
@@ -396,7 +396,11 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
<span className="text-muted-foreground shrink-0 text-xs">{timeAgo(event.created_at)}</span>
</div>
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 max-h-[4.5em] overflow-hidden">
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? (
<span className="italic">Encrypted content</span>
) : (
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
)}
</div>
</div>
</div>
+1 -3
View File
@@ -23,7 +23,6 @@ import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikip
import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch';
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils';
interface ProfileSearchDropdownProps {
@@ -56,7 +55,6 @@ export function ProfileSearchDropdown({
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { user } = useCurrentUser();
const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Wikipedia & Archive search (async, debounced by their hooks at >=2 chars)
@@ -72,7 +70,7 @@ export function ProfileSearchDropdown({
const countryMatch = hideCountry ? null : countryMatchRaw;
// Nav item suggestions (local, synchronous)
const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]);
const navItems = useMemo(() => searchSidebarItems(query), [query]);
// URL detection — show "Comment on" option when query is a full URL
const queryIsUrl = useMemo(() => isFullUrl(query), [query]);
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
/**
* Ink pen / fountain pen icon — used for letter compose actions.
* Rendered as a standard lucide-style SVG component.
*/
export const InkPenIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, strokeWidth = 2, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M17.9 8H9.2m-4.1 4H14l8-8.2c-2.3-2.3-6.1-2.3-8.5 0L2 15m0 4h8m11-2v1c0 1 1 1.5 1 2.5c0 .8-.7 1.5-1.5 1.5h-5c-.8 0-1.5-.7-1.5-1.5c0-1 1-1.5 1-2.5v-1m-1 0h8" />
</svg>
),
);
InkPenIcon.displayName = 'InkPenIcon';
+22 -7
View File
@@ -76,7 +76,11 @@ function AnimationLetter({ content, width }: { content: LetterContent; width: nu
</div>
</StationeryBackground>
{content.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
<div className="absolute inset-0 rounded-2xl overflow-hidden pointer-events-none" style={{ zIndex: 20 }}>
<div className="relative w-full h-full">
<LetterStickers stickers={content.stickers} />
</div>
</div>
)}
</div>
);
@@ -124,6 +128,12 @@ export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProp
const queryClient = useQueryClient();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bodyAreaRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const timer = setTimeout(() => textareaRef.current?.focus(), 300);
return () => clearTimeout(timer);
}, []);
const initialRecipient = useMemo(() => {
if (!toPubkey) return undefined;
@@ -150,7 +160,11 @@ export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProp
);
// Start from the live theme stationery immediately — don't wait for encrypted settings.
// If the user has saved a custom stationery preference, switch to it once prefs load.
const [stationery, setStationery] = useState<Stationery>(themeStationery);
// If no custom theme is active, fall back to the default parchment color so the letter
// doesn't just inherit the plain app background.
const [stationery, setStationery] = useState<Stationery>(
isThemeDefault ? { color: DEFAULT_STATIONERY_COLOR } : themeStationery,
);
const [frame, setFrame] = useState<FrameStyle>(() => prefs.frame ?? 'none');
const [frameTint, setFrameTint] = useState(() => prefs.frameTint ?? false);
const [overlay, setOverlay] = useState<Overlay>('none');
@@ -185,7 +199,7 @@ export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProp
}, []);
useEffect(() => {
if (!userPickedStationery.current && isThemeDefault) {
if (!userPickedStationery.current && !isThemeDefault) {
setStationery(themeStationery);
}
}, [themeStationery, isThemeDefault]);
@@ -202,7 +216,7 @@ export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProp
return () => ro.disconnect();
}, []);
const canSend = !!resolvedRecipient && body.trim().length > 0 && !!user;
const canSend = !!resolvedRecipient && (body.trim().length > 0 || stickers.length > 0) && !!user;
const handleAddSticker = useCallback((emoji: { shortcode: string; url: string }) => {
setStickers((prev) => [
@@ -387,10 +401,10 @@ export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProp
}
beforeCard={
<div className="max-w-xl mx-auto w-full px-5 pb-2 pt-4 max-sidebar:pt-[calc(20px+2.5rem)]">
<div className="flex items-center">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-muted-foreground shrink-0 w-14">To</span>
{!initialRecipient && !resolvedRecipient ? (
<div className="flex-1">
<div className="flex-1 min-w-0">
<ProfileSearchDropdown
placeholder="search for a person..."
onSelect={(profile) => setResolvedRecipient(profile.pubkey)}
@@ -448,13 +462,14 @@ export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProp
}}
/>
)}
cardRef={cardRef}
cardOverlay={
<LetterStickers
stickers={stickers}
editable
onUpdate={handleUpdateSticker}
onRemove={handleRemoveSticker}
containerRef={bodyAreaRef}
containerRef={cardRef}
/>
}
/>
+48 -20
View File
@@ -20,20 +20,23 @@
* Label: name left-aligned, shorthand time right-aligned.
*/
import { useMemo } from 'react';
import { Clock } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Clock, MoreHorizontal } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useDecryptLetter } from '@/hooks/useLetters';
import { genUserName } from '@/lib/genUserName';
import { resolveStationery, DEFAULT_STATIONERY_COLOR, type Letter } from '@/lib/letterTypes';
import { hexLuminance, darkenHex, lightenHex, blendHex } from '@/lib/colorUtils';
import { StationeryBackground } from './StationeryBackground';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
interface EnvelopeCardProps {
letter: Letter;
mode: 'inbox' | 'sent';
index: number;
onClick: () => void;
/** Hide name/timestamp label — used in notification context. */
minimal?: boolean;
}
// ---------------------------------------------------------------------------
@@ -82,10 +85,11 @@ function shortTimeAgo(ts: number): string {
const V_PCT = 65;
const FLAP_Y_PCT = 15;
export function EnvelopeCard({ letter, mode, index, onClick }: EnvelopeCardProps) {
export function EnvelopeCard({ letter, mode, index, onClick, minimal }: EnvelopeCardProps) {
const otherPubkey = mode === 'inbox' ? letter.sender : letter.recipient;
const author = useAuthor(otherPubkey);
const { data: decrypted } = useDecryptLetter(letter);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const displayName = author.data?.metadata?.name || genUserName(otherPubkey);
const avatar = author.data?.metadata?.picture;
@@ -100,6 +104,7 @@ export function EnvelopeCard({ letter, mode, index, onClick }: EnvelopeCardProps
const flapClip = `polygon(0% 0%, 100% 0%, 100% ${FLAP_Y_PCT}%, 50% ${V_PCT}%, 0% ${FLAP_Y_PCT}%)`;
return (
<>
<button
onClick={onClick}
className="envelope-card group outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 rounded-xl w-full"
@@ -170,24 +175,26 @@ export function EnvelopeCard({ letter, mode, index, onClick }: EnvelopeCardProps
</svg>
{/* Layer 4: Name + time inside the envelope bottom */}
<div
className="absolute left-0 right-0 bottom-0 flex flex-col items-start px-2.5 pb-1.5 pt-0.5"
style={{ zIndex: 3 }}
>
<span
className="flex items-center gap-0.5 text-[9px] font-medium leading-tight"
style={{ color: C.textMuted }}
{!minimal && (
<div
className="absolute left-0 right-0 bottom-0 flex flex-col items-start px-2.5 pb-1.5 pt-0.5"
style={{ zIndex: 3 }}
>
<Clock className="w-2 h-2" />
{timeStr}
</span>
<span
className="text-[11px] font-semibold truncate leading-tight max-w-full"
style={{ color: C.text }}
>
{displayName}
</span>
</div>
<span
className="flex items-center gap-0.5 text-[9px] font-medium leading-tight"
style={{ color: C.textMuted }}
>
<Clock className="w-2 h-2" />
{timeStr}
</span>
<span
className="text-[11px] font-semibold truncate leading-tight max-w-full"
style={{ color: C.text }}
>
{displayName}
</span>
</div>
)}
{/* Layer 3: Avatar wax seal — positioned above V vertex */}
<div
@@ -225,8 +232,29 @@ export function EnvelopeCard({ letter, mode, index, onClick }: EnvelopeCardProps
)}
</div>
</div>
{/* Layer 5: Overflow menu trigger — lower right corner */}
<div
className="absolute bottom-1.5 right-1.5"
style={{ zIndex: 20 }}
>
<button
onClick={(e) => { e.stopPropagation(); setMoreMenuOpen(true); }}
className="p-1 rounded-full transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
style={{
background: `${C.body}cc`,
color: C.textMuted,
}}
title="More options"
>
<MoreHorizontal className="w-3.5 h-3.5" strokeWidth={2.5} />
</button>
</div>
</div>
</button>
<NoteMoreMenu event={letter.event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
</>
);
}
+5 -1
View File
@@ -316,7 +316,11 @@ export function LetterCard({ letter, mode }: LetterCardProps) {
</StationeryBackground>
{content?.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
<div className="absolute inset-0 rounded-3xl overflow-hidden pointer-events-none" style={{ zIndex: 20 }}>
<div className="relative w-full h-full">
<LetterStickers stickers={content.stickers} />
</div>
</div>
)}
</div>
</div>
+56 -13
View File
@@ -5,6 +5,7 @@
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react';
import { Loader2, Lock } from 'lucide-react';
import { InkPenIcon } from '@/components/icons/InkPenIcon';
import type { NostrEvent } from '@nostrify/nostrify';
import { useDecryptLetter } from '@/hooks/useLetters';
import { FONT_OPTIONS, LINE_HEIGHT_RATIO, COLOR_MOMENT_KIND, THEME_KIND, resolveStationery, colorMomentToStationery, themeToStationery, type Letter } from '@/lib/letterTypes';
@@ -24,6 +25,9 @@ import {
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { nip19 } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser';
// ---------------------------------------------------------------------------
// Attached gift — color moment or theme that can be applied on the spot
@@ -104,7 +108,7 @@ function LetterAttachment({ event }: { event: NostrEvent }) {
const textColor = backgroundTextColor(bg);
return (
<div className="relative max-w-[220px] mx-auto mt-16 pointer-events-none">
<div className="relative max-w-[220px] mx-auto mb-12 pointer-events-none">
{/* Present box — overlaps the bubble top */}
<div className="flex justify-center relative z-10 mb-[-20px]">
<svg width="56" height="60" viewBox="0 0 56 60" fill="none" className="drop-shadow-sm">
@@ -134,7 +138,7 @@ function LetterAttachment({ event }: { event: NostrEvent }) {
{/* Themed bubble — clickable */}
<div
onClick={handleApply}
onClick={(e) => { e.stopPropagation(); handleApply(); }}
role="button"
tabIndex={0}
title={applied ? 'Theme applied!' : `Tap to use "${attachment.label}" as your theme`}
@@ -183,11 +187,14 @@ function LetterAttachment({ event }: { event: NostrEvent }) {
interface LetterDetailSheetProps {
letter: Letter | null;
onClose: () => void;
/** Called when the user wants to reply — receives the sender's npub. */
onReply?: (senderNpub: string) => void;
}
export function LetterDetailSheet({ letter, onClose }: LetterDetailSheetProps) {
export function LetterDetailSheet({ letter, onClose, onReply }: LetterDetailSheetProps) {
const letterRef = useRef<HTMLDivElement>(null);
const [lineHeightPx, setLineHeightPx] = useState(0);
const { user } = useCurrentUser();
const { data: decrypted, isLoading: isDecrypting } = useDecryptLetter(letter ?? undefined);
const content = decrypted?.content;
@@ -233,14 +240,30 @@ export function LetterDetailSheet({ letter, onClose }: LetterDetailSheetProps) {
return (
<Dialog open={!!letter} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="p-0 gap-0 border-none bg-transparent shadow-none max-w-[calc(100vw-2rem)] sm:max-w-lg overflow-visible [&>button]:hidden">
<DialogContent
className="p-0 gap-0 border-none bg-transparent shadow-none max-w-[calc(100vw-2rem)] sm:max-w-lg overflow-visible [&>button]:hidden"
>
<DialogTitle className="sr-only">Letter</DialogTitle>
<div style={effectiveFrame && effectiveFrame !== 'none'
? { padding: '28px 28px 44px' }
: { padding: '0' }
}>
<div ref={letterRef} className="relative" style={{ containerType: 'inline-size' }}>
{/* Outer click-to-close layer — zero height so dialog centers on the card only */}
<div className="relative" onClick={onClose}>
{/* Attached gift — absolutely above the card */}
{effectiveStationery?.event && (
<div className="absolute bottom-full left-0 right-0 pointer-events-none">
<LetterAttachment event={effectiveStationery.event} />
</div>
)}
<div
ref={letterRef}
className="relative"
onClick={(e) => e.stopPropagation()}
style={{
containerType: 'inline-size',
...(effectiveFrame && effectiveFrame !== 'none' ? { padding: '28px 28px 44px' } : {}),
}}
>
<StationeryBackground
stationery={effectiveStationery}
frame={effectiveFrame}
@@ -303,13 +326,33 @@ export function LetterDetailSheet({ letter, onClose }: LetterDetailSheetProps) {
</StationeryBackground>
{content?.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
<div className="absolute inset-0 rounded-3xl overflow-hidden pointer-events-none" style={{ zIndex: 20 }}>
<div className="relative w-full h-full">
<LetterStickers stickers={content.stickers} />
</div>
</div>
)}
</div>
{/* Attached gift — color moment or theme */}
{effectiveStationery?.event && (
<LetterAttachment event={effectiveStationery.event} />
{/* Reply button — absolutely below the card */}
{onReply && letter && user?.pubkey !== letter.sender && (
<div className="absolute top-full left-0 right-0 flex justify-center pt-12 pointer-events-none">
<Button
variant="default"
size="lg"
className="gap-3 rounded-full px-12 text-lg h-14 bg-primary text-primary-foreground hover:bg-primary/90 pointer-events-auto"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
onClick={(e) => {
e.stopPropagation();
const senderNpub = nip19.npubEncode(letter.sender);
onClose();
setTimeout(() => onReply(senderNpub), 150);
}}
>
<InkPenIcon className="w-5 h-5" strokeWidth={2} />
Reply
</Button>
</div>
)}
</div>
</DialogContent>
+12 -2
View File
@@ -100,6 +100,8 @@ interface LetterEditorProps {
cardOverlay?: ReactNode;
/** Content rendered between the drawer and the card (e.g. recipient row). */
beforeCard?: ReactNode;
/** External ref for the card container — used for sticker positioning. */
cardRef?: React.RefObject<HTMLDivElement | null>;
}
// ---------------------------------------------------------------------------
@@ -116,6 +118,7 @@ export function LetterEditor({
bodyContent,
cardOverlay,
beforeCard,
cardRef: externalCardRef,
}: LetterEditorProps) {
const {
selectedFont, setSelectedFont,
@@ -126,7 +129,8 @@ export function LetterEditor({
signature, setSignature,
} = state;
const cardRef = useRef<HTMLDivElement>(null);
const internalCardRef = useRef<HTMLDivElement>(null);
const cardRef = externalCardRef ?? internalCardRef;
const [lineHeightPx, setLineHeightPx] = useState(0);
useEffect(() => {
@@ -285,7 +289,13 @@ export function LetterEditor({
</div>
</div>
</StationeryBackground>
{cardOverlay}
{cardOverlay && (
<div className="absolute inset-0 rounded-3xl overflow-hidden pointer-events-none" style={{ zIndex: 20 }}>
<div className="relative w-full h-full">
{cardOverlay}
</div>
</div>
)}
</div>
</div>
</>
@@ -12,14 +12,12 @@ import {
FONT_OPTIONS,
type Stationery,
type FrameStyle,
type SerializableStationery,
} from '@/lib/letterTypes';
import { LetterEditor, type BaseOverlay } from './LetterEditor';
/** Strip the non-serializable `event` field before persisting */
function toSerializable(s: Stationery): SerializableStationery {
const { event: _, ...rest } = s;
return rest;
/** Convert to serializable form for persisting. NostrEvent is plain JSON, so no stripping needed. */
function toSerializable(s: Stationery): Stationery {
return s;
}
export function LetterPreferencesSection() {
+8 -6
View File
@@ -14,7 +14,9 @@ import { sanitizeSvg } from '@/lib/sanitizeSvg';
const MIN_SCALE = 0.5;
const MAX_SCALE = 4;
const BASE_SIZE_CQW = 14;
const BASE_SIZE_CQW = 14; // cqw — base sticker size at scale=1, scales with card width
/** Inward buffer (%) so sticker centers can't reach the very edge of the card. */
const EDGE_BUFFER = 5;
function isSafeUrl(url: string): boolean {
try {
@@ -28,7 +30,7 @@ function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker;
if (sticker.svg) {
return (
<div
style={{ width: sizeCqw, height: sizeCqw }}
style={{ width: sizeCqw, height: sizeCqw, maxWidth: 'none' }}
className={`sticker-svg-wrap ${className ?? ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg) }}
/>
@@ -39,7 +41,7 @@ function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker;
<img
src={sticker.url}
alt={sticker.shortcode}
style={{ width: sizeCqw, height: sizeCqw }}
style={{ width: sizeCqw, height: sizeCqw, maxWidth: 'none' }}
className={className}
draggable={false}
/>
@@ -95,8 +97,8 @@ function EditableSticker({
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return { x: sticker.x, y: sticker.y };
return {
x: Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)),
y: Math.max(0, Math.min(100, ((clientY - rect.top) / rect.height) * 100)),
x: Math.max(EDGE_BUFFER, Math.min(100 - EDGE_BUFFER, ((clientX - rect.left) / rect.width) * 100)),
y: Math.max(EDGE_BUFFER, Math.min(100 - EDGE_BUFFER, ((clientY - rect.top) / rect.height) * 100)),
};
}, [containerRef, sticker.x, sticker.y]);
@@ -193,7 +195,7 @@ function EditableSticker({
return (
<div
className={`absolute select-none ${isDragging ? 'cursor-grabbing' : selected ? 'cursor-grab' : 'cursor-pointer'}`}
className={`absolute select-none pointer-events-auto ${isDragging ? 'cursor-grabbing' : selected ? 'cursor-grab' : 'cursor-pointer'}`}
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
+17 -9
View File
@@ -259,15 +259,23 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
const hasEmoji = !!resolved?.emoji;
const isColorMoment = selected?.event?.kind === COLOR_MOMENT_KIND;
const isTheme = selected?.event?.kind === THEME_KIND;
const isSingleColor = isColorMoment && selected?.colors !== undefined && selected.colors.length === 0;
// Remember the last color moment event so we can restore it when toggling off flat mode
const lastColorMomentRef = useRef<NostrEvent | undefined>(undefined);
if (isColorMoment && selected?.event) lastColorMomentRef.current = selected.event;
// Flat mode = color moment was selected but event has been stripped (only color remains)
const isFlatMode = !!(selected && !selected.event && lastColorMomentRef.current);
const toggleSingleColor = () => {
if (!selected || !isColorMoment) return;
if (isSingleColor) {
const { colors: _, ...rest } = selected;
if (!selected) return;
if (isFlatMode && lastColorMomentRef.current) {
// Restore: put the event back
onSelect({ ...selected, event: lastColorMomentRef.current });
} else if (isColorMoment && selected.event) {
// Flatten: strip the event, keep only color
const { event: _, ...rest } = selected;
onSelect(rest as Stationery);
} else {
onSelect({ ...selected, colors: [] });
}
};
@@ -359,7 +367,7 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
{tab === 'themes' && <ThemesGrid key={scope} selectedStationery={selected} onSelect={onSelect} authors={scopedAuthors} />}
</div>
{((hasEmoji && !isTheme) || isColorMoment) && (
{((hasEmoji && !isTheme) || isColorMoment || isFlatMode) && (
<div className="flex items-center gap-4 px-1 pt-1">
{hasEmoji && !isTheme && (
<label className="flex items-center gap-1.5">
@@ -367,9 +375,9 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
<span className="text-sm text-muted-foreground font-medium">emblem</span>
</label>
)}
{isColorMoment && (
{(isColorMoment || isFlatMode) && (
<label className="flex items-center gap-1.5">
<Switch checked={isSingleColor} onCheckedChange={toggleSingleColor} />
<Switch checked={isFlatMode} onCheckedChange={toggleSingleColor} />
<span className="text-sm text-muted-foreground font-medium">flat</span>
</label>
)}
+5 -23
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { type AvatarShape, isEmoji, getAvatarMaskUrlAsync, isValidAvatarShape } from "@/lib/avatarShape"
import { type AvatarShape, isEmoji, getAvatarMaskUrl, isValidAvatarShape } from "@/lib/avatarShape"
/**
* Shared ref so AvatarFallback can check if a sibling AvatarImage
@@ -29,28 +29,10 @@ const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
const isEmojiShape = hasValidShape && isEmoji(shape)
const hasCustomShape = isEmojiShape
// State for the async-loaded mask URL
const [maskUrl, setMaskUrl] = React.useState<string>('')
// Load mask URL asynchronously when shape changes
React.useEffect(() => {
if (!hasCustomShape || !shape) {
setMaskUrl('')
return
}
let cancelled = false
getAvatarMaskUrlAsync(shape).then((url) => {
if (!cancelled) {
setMaskUrl(url)
}
})
return () => {
cancelled = true
}
}, [hasCustomShape, shape])
// Compute mask URL synchronously — getAvatarMaskUrl renders the emoji
// to a canvas and caches the data-URL, so subsequent calls are instant.
// This avoids a flash of the unmasked square avatar on first paint.
const maskUrl = hasCustomShape && shape ? getAvatarMaskUrl(shape) : ''
const mergedStyle = React.useMemo<React.CSSProperties>(() => {
if (maskUrl) {
+4
View File
@@ -38,8 +38,12 @@ export interface BlossomServerMetadata {
export interface FeedSettings {
/** Include text posts (kind 1) in the feed */
feedIncludePosts: boolean;
/** Include NIP-22 comments (kind 1111) in the feed */
feedIncludeComments: boolean;
/** Include reposts (kind 6) in the feed */
feedIncludeReposts: boolean;
/** Include generic reposts (kind 16) in the feed */
feedIncludeGenericReposts: boolean;
/** Include long-form articles (kind 30023) in the feed */
feedIncludeArticles: boolean;
/** Show Articles (kind 30023) link in sidebar */
+51 -36
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useRef, useSyncExternalStore } from 'react';
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
/** Options that pages can set to configure the persistent MainLayout. */
export interface LayoutOptions {
@@ -60,6 +60,24 @@ export interface LayoutOptions {
hideBottomNav?: boolean;
}
/** All own-property keys of LayoutOptions used for shallow comparison. */
const LAYOUT_KEYS: (keyof LayoutOptions)[] = [
'showFAB', 'fabKind', 'fabHref', 'onFabClick', 'fabIcon',
'wrapperClassName', 'rightSidebar', 'scrollContainer',
'noOverscroll', 'noMaxWidth', 'hasSubHeader', 'noArcs',
'hideTopBar', 'hideBottomNav',
];
/** Shallow-compare two LayoutOptions objects. */
function shallowEqualOptions(a: LayoutOptions | null, b: LayoutOptions): boolean {
if (a === null) return false;
if (a === b) return true;
for (const key of LAYOUT_KEYS) {
if (a[key] !== b[key]) return false;
}
return true;
}
type Listener = () => void;
const EMPTY: LayoutOptions = {};
@@ -74,8 +92,6 @@ export class LayoutStore {
getSnapshot = (): LayoutOptions => this.options;
getOptions = (): LayoutOptions => this.options;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
@@ -122,47 +138,46 @@ function useLayoutStore(): LayoutStore {
* Hook for pages to declare their layout options.
* Call this at the top of a page component to configure the surrounding MainLayout.
*
* Sets options synchronously during render so the layout never shows stale values.
* Resets to defaults on unmount so options don't leak to the next page.
* Two effects collaborate:
*
* 1. **useLayoutEffect (no deps)** — runs after every commit, before paint.
* Writes the latest options to the store so MainLayout never paints stale
* values. Has no cleanup — the write is idempotent and runs every render.
*
* 2. **useEffect ([] deps)** — runs once on mount, returns a cleanup that
* fires only on unmount. The cleanup defers the reset to a
* requestAnimationFrame so it doesn't race with Suspense transitions:
* all pages are lazy-loaded, so the old page unmounts (cleanup fires)
* before the new page mounts. If the incoming page also calls
* useLayoutOptions, its useLayoutEffect overwrites the store
* synchronously before paint — by the time the rAF fires, the store
* no longer holds the old snapshot and the reset is skipped.
* If the incoming page does NOT call useLayoutOptions (e.g.
* PostDetailPage), the rAF fires on the next frame and clears stale
* options.
*/
export function useLayoutOptions(options: LayoutOptions): void {
const store = useLayoutStore();
const prev = useRef<LayoutOptions | null>(null);
// Set options synchronously during render (before commit) so the layout
// picks them up in the same paint as the new page.
const changed =
prev.current === null ||
prev.current.showFAB !== options.showFAB ||
prev.current.fabKind !== options.fabKind ||
prev.current.fabHref !== options.fabHref ||
prev.current.onFabClick !== options.onFabClick ||
prev.current.fabIcon !== options.fabIcon ||
prev.current.wrapperClassName !== options.wrapperClassName ||
prev.current.rightSidebar !== options.rightSidebar ||
prev.current.scrollContainer !== options.scrollContainer ||
prev.current.noOverscroll !== options.noOverscroll ||
prev.current.noMaxWidth !== options.noMaxWidth ||
prev.current.hasSubHeader !== options.hasSubHeader ||
prev.current.noArcs !== options.noArcs ||
prev.current.hideTopBar !== options.hideTopBar ||
prev.current.hideBottomNav !== options.hideBottomNav;
// Synchronous write — runs after every commit, before paint.
// No cleanup; the write is idempotent across re-renders.
useLayoutEffect(() => {
if (!shallowEqualOptions(prev.current, options)) {
prev.current = options;
store.setOptions(options);
}
});
if (changed) {
prev.current = options;
store.setOptions(options);
}
// Clean up on unmount — reset to defaults so the next page starts fresh.
// Only reset if the store still holds this component's options.
// During page transitions the new page's render-phase setOptions runs
// before the old page's cleanup effect, so blindly resetting would
// clobber the incoming page's options (causing the FAB to disappear).
// Unmount-only cleanup — deferred so incoming pages can overwrite first.
useEffect(() => {
return () => {
if (store.getOptions() === prev.current) {
store.reset();
}
const snapshot = prev.current;
requestAnimationFrame(() => {
if (store.getSnapshot() === snapshot) {
store.reset();
}
});
};
}, [store]);
}
+20 -8
View File
@@ -12,6 +12,13 @@ import type { NostrEvent } from '@nostrify/nostrify';
const PAGE_SIZE = 15;
/**
* Over-fetch multiplier: when client-side reply filtering is active, we ask
* the relay for more events than `PAGE_SIZE` to compensate for events that
* will be discarded. This prevents large time gaps in the visible feed.
*/
const OVER_FETCH_MULTIPLIER = 3;
// Re-export FeedItem for backwards compatibility
export type { FeedItem };
@@ -20,6 +27,8 @@ interface FeedPage {
items: FeedItem[];
/** The oldest timestamp from the raw relay query (before deduplication) for pagination. */
oldestQueryTimestamp: number;
/** Number of valid events returned by the relay (before client-side filtering). */
rawCount: number;
}
interface UseFeedOptions {
@@ -91,7 +100,8 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
if (tab === 'communities' && communityPubkeys.length > 0) {
// Communities feed — posts from community members with NIP-05 verification
const filter: Record<string, unknown> = { kinds: allKinds, authors: communityPubkeys, limit: PAGE_SIZE, ...tagFilters };
const fetchLimit = !feedSettings.followsFeedShowReplies ? PAGE_SIZE * OVER_FETCH_MULTIPLIER : PAGE_SIZE;
const filter: Record<string, unknown> = { kinds: allKinds, authors: communityPubkeys, limit: fetchLimit, ...tagFilters };
if (pageParam) {
filter.until = pageParam;
}
@@ -227,12 +237,13 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
// when NoteCard components mount.
cacheEvents(dedupedItems);
return { items: dedupedItems, oldestQueryTimestamp };
return { items: dedupedItems, oldestQueryTimestamp, rawCount: validFilteredEvents.length };
} else if (tab === 'follows' && user && followList !== undefined) {
// Follows feed — posts, reposts, and extra kinds from people you follow
// If followList is empty, just query own posts
const authors = followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey];
const filter: Record<string, unknown> = { kinds: allKinds, authors, limit: PAGE_SIZE, ...tagFilters };
const fetchLimit = !feedSettings.followsFeedShowReplies ? PAGE_SIZE * OVER_FETCH_MULTIPLIER : PAGE_SIZE;
const filter: Record<string, unknown> = { kinds: allKinds, authors, limit: fetchLimit, ...tagFilters };
if (pageParam) {
filter.until = pageParam;
}
@@ -309,7 +320,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
// Seed event cache so embedded note previews resolve instantly.
cacheEvents(dedupedItems);
return { items: dedupedItems, oldestQueryTimestamp };
return { items: dedupedItems, oldestQueryTimestamp, rawCount: validEvents.length };
} else {
// Global feed — all enabled kinds except reposts (too noisy without author filter)
const globalKinds = allKinds.filter((k) => !isRepostKind(k));
@@ -338,13 +349,14 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
// Seed event cache so embedded note previews resolve instantly.
cacheEvents(items);
return { items, oldestQueryTimestamp };
return { items, oldestQueryTimestamp, rawCount: validEvents.length };
}
},
getNextPageParam: (lastPage) => {
if (lastPage.items.length === 0) return undefined;
// Use the oldest timestamp from the raw relay query (before deduplication) minus 1
// This ensures we don't skip events when deduplication reduces the page size
// Use rawCount (pre-filter) to decide if there are more events on the relay.
// Reply filtering may discard all items from a page, but that doesn't mean
// the relay is exhausted.
if (lastPage.rawCount === 0) return undefined;
return lastPage.oldestQueryTimestamp - 1;
},
initialPageParam: undefined as number | undefined,
+1 -1
View File
@@ -193,7 +193,7 @@ export function useDecryptLetter(letter: Letter | undefined) {
try {
const decrypted = await user.signer.nip44.decrypt(otherPubkey, letter.event.content);
const parsed = JSON.parse(decrypted) as LetterContent;
if (!parsed.body) return null;
if (!parsed.body && (!parsed.stickers || parsed.stickers.length === 0)) return null;
return {
content: parsed,
+2 -2
View File
@@ -91,7 +91,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
{
kind: 1111,
id: 'comments',
feedKey: 'feedIncludePosts',
feedKey: 'feedIncludeComments',
label: 'Comments',
description: 'NIP-22 comments on posts and external content',
addressable: false,
@@ -111,7 +111,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
{
kind: 16,
id: 'generic-reposts',
feedKey: 'feedIncludeReposts',
feedKey: 'feedIncludeGenericReposts',
label: 'Reposted Other Content',
description: 'Shared non-text-note posts from others',
addressable: false,
+9 -14
View File
@@ -29,7 +29,8 @@ export interface LetterSticker {
}
export interface LetterContent {
body: string;
/** Main letter text. Optional — a letter must have either a non-empty body or at least one sticker. */
body?: string;
closing?: string;
signature?: string;
/** Stickers placed on the letter card — stored in encrypted content for privacy */
@@ -53,8 +54,7 @@ export interface Stationery {
emoji?: string;
/** Emoji display mode: 'tile' (faint repeating pattern) or 'emblem' (single large centered glyph). */
emojiMode?: 'tile' | 'emblem';
/** Palette colors override. Empty array = "flat" mode (suppress palette). */
colors?: string[];
/** CSS font-family string (e.g. "Caveat, cursive"). Set from the sender's font choice. */
fontFamily?: string;
/** Frame style ID. */
@@ -110,14 +110,9 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
const event = s.event;
if (event?.kind === COLOR_MOMENT_KIND) {
// Colors override: empty array = flat mode, undefined = read from event
if (s.colors !== undefined) {
base.colors = s.colors.length > 0 ? s.colors : undefined;
} else {
const hexRe = /^#[0-9A-Fa-f]{6}$/;
const eventColors = event.tags.filter(([n]) => n === 'c').map(([, c]) => c).filter((c) => hexRe.test(c));
if (eventColors.length >= 2) base.colors = eventColors;
}
const hexRe = /^#[0-9A-Fa-f]{6}$/;
const eventColors = event.tags.filter(([n]) => n === 'c').map(([, c]) => c).filter((c) => hexRe.test(c));
if (eventColors.length >= 2) base.colors = eventColors;
base.layout = s.layout ?? event.tags.find(([n]) => n === 'layout')?.[1];
if (!base.emoji) {
const raw = event.content?.trim();
@@ -150,7 +145,6 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
// No event or unknown kind — use legacy flat fallbacks (old letters, presets)
base.textColor = s.textColor;
base.primaryColor = s.primaryColor;
base.colors = s.colors;
base.layout = s.layout;
base.imageUrl = s.imageUrl;
base.imageMode = s.imageMode ?? 'cover';
@@ -247,9 +241,10 @@ export const FONT_OPTIONS = [
];
/**
* Serializable stationery for localStorage persistence (no raw NostrEvent).
* Serializable stationery for localStorage persistence.
* NostrEvent is a plain JSON object, so it serializes fine.
*/
export type SerializableStationery = Omit<Stationery, 'event'>;
export type SerializableStationery = Stationery;
/**
* User's default letter preferences — persisted per-pubkey in settings.
+2
View File
@@ -136,7 +136,9 @@ export const BlossomServerMetadataSchema = z.object({
*/
export const FeedSettingsSchema = z.looseObject({
feedIncludePosts: z.boolean().optional(),
feedIncludeComments: z.boolean().optional(),
feedIncludeReposts: z.boolean().optional(),
feedIncludeGenericReposts: z.boolean().optional(),
feedIncludeArticles: z.boolean().optional(),
showArticles: z.boolean().optional(),
showEvents: z.boolean().optional(),
-2
View File
@@ -239,7 +239,6 @@ export function itemPath(
*/
export function searchSidebarItems(
query: string,
isLoggedIn: boolean,
): SidebarItemDef[] {
const q = query.trim().toLowerCase();
if (q.length === 0) return [];
@@ -248,7 +247,6 @@ export function searchSidebarItems(
const wordMatches: SidebarItemDef[] = [];
for (const item of SIDEBAR_ITEMS) {
if (item.requiresAuth && !isLoggedIn) continue;
const label = item.label.toLowerCase();
if (label.startsWith(q)) {
prefixMatches.push(item);
+16 -7
View File
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate } from 'react-router-dom';
import { PenLine, Settings, Loader2 } from 'lucide-react';
import { Settings, Loader2 } from 'lucide-react';
import { MailboxIcon } from '@/components/icons/MailboxIcon';
import { InkPenIcon } from '@/components/icons/InkPenIcon';
import { Button } from '@/components/ui/button';
import { FabButton } from '@/components/FabButton';
@@ -44,6 +45,7 @@ export function LettersPage() {
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('inbox');
const [composing, setComposing] = useState(false);
const [replyToNpub, setReplyToNpub] = useState<string | undefined>(undefined);
const [selectedLetter, setSelectedLetter] = useState<Letter | null>(null);
useLayoutOptions({ showFAB: false, hasSubHeader: !!user, noOverscroll: composing });
@@ -99,7 +101,8 @@ export function LettersPage() {
>
{composing && (
<ComposeLetterSheet
onClose={() => setComposing(false)}
onClose={() => { setComposing(false); setReplyToNpub(undefined); }}
toPubkey={replyToNpub}
/>
)}
<PageHeader title="Letters" icon={<MailboxIcon className="size-5" />} backTo="/" alwaysShowBack>
@@ -122,8 +125,8 @@ export function LettersPage() {
{/* Envelope grid */}
<div className="px-4 py-3">
{isLoading && (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 sidebar:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sidebar:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<EnvelopeSkeleton key={i} index={i} />
))}
</div>
@@ -152,7 +155,7 @@ export function LettersPage() {
{!isLoading && activeLetters && activeLetters.length > 0 && (
<>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 sidebar:grid-cols-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sidebar:grid-cols-4">
{activeLetters.map((letter, i) => (
<EnvelopeCard
key={letter.event.id}
@@ -185,16 +188,22 @@ export function LettersPage() {
<LetterDetailSheet
letter={selectedLetter}
onClose={() => setSelectedLetter(null)}
onReply={(npub) => {
setSelectedLetter(null);
setReplyToNpub(npub);
setComposing(true);
}}
/>
{/* Compose FAB */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton onClick={() => setComposing(true)} icon={<PenLine size={18} strokeWidth={3} />} title="Write a letter" />
<FabButton onClick={() => setComposing(true)} icon={<InkPenIcon style={{ width: 18, height: 18 }} strokeWidth={2.5} />} title="Write a letter" />
</div>
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton onClick={() => setComposing(true)} icon={<PenLine size={18} strokeWidth={3} />} title="Write a letter" />
<FabButton onClick={() => setComposing(true)} icon={<InkPenIcon style={{ width: 18, height: 18 }} strokeWidth={2.5} />} title="Write a letter" />
</div>
</div>
</div>
+108 -49
View File
@@ -35,9 +35,12 @@ import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
import { LETTER_KIND, type Letter } from '@/lib/letterTypes';
import { EnvelopeCard } from '@/components/letter/EnvelopeCard';
import { LetterDetailSheet } from '@/components/letter/LetterDetailSheet';
import { ComposeLetterSheet } from '@/components/letter/ComposeLetterSheet';
import { InkPenIcon } from '@/components/icons/InkPenIcon';
import { Button } from '@/components/ui/button';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import type { BadgeData } from '@/components/BadgeContent';
import { BadgeContent, type BadgeData } from '@/components/BadgeContent';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
@@ -323,7 +326,7 @@ function ReferencedNoteCard({ item }: { item: NotificationItem }) {
if (!event) return null;
return <NoteCard event={event} className="border-0" />;
return <NoteCard event={event} className="border-0" hideKindHeader />;
}
// ──────────────────────────────────────
@@ -705,6 +708,9 @@ function CommentNotification({ item, isNew }: { item: NotificationItem; isNew: b
// ──────────────────────────────────────
function LetterNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) {
const navigate = useNavigate();
const [showDetail, setShowDetail] = useState(false);
const [replyToNpub, setReplyToNpub] = useState<string | undefined>(undefined);
const [composing, setComposing] = useState(false);
const letter = useMemo<Letter>(() => ({
event: item.event,
@@ -715,25 +721,64 @@ function LetterNotification({ item, isNew }: { item: NotificationItem; isNew: bo
}), [item.event]);
return (
<NotificationWrapper isNew={isNew}>
<div className="px-4 pt-3">
<NotificationHeader
actorPubkey={item.event.pubkey}
icon={<Mail className="size-4 text-primary" />}
action="sent you a letter"
<>
{composing && (
<ComposeLetterSheet
onClose={() => { setComposing(false); setReplyToNpub(undefined); }}
toPubkey={replyToNpub}
/>
</div>
<div className="px-4 pb-3 pt-1">
<div className="max-w-[160px]">
<EnvelopeCard
letter={letter}
mode="inbox"
index={0}
onClick={() => navigate('/letters')}
)}
<NotificationWrapper isNew={isNew}>
<div className="px-4 pt-3">
<NotificationHeader
actorPubkey={item.event.pubkey}
icon={<Mail className="size-4 text-primary" />}
action="sent you a letter"
/>
</div>
</div>
</NotificationWrapper>
<div className="flex flex-col items-center gap-3 px-4 pb-4 pt-2">
<div className="w-[280px]">
<EnvelopeCard
letter={letter}
mode="inbox"
index={0}
onClick={() => setShowDetail(true)}
minimal
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="rounded-full px-5 h-9 text-sm font-medium gap-1.5 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => navigate('/letters')}
>
<Mail className="size-3.5" />
View all letters
</Button>
<Button
variant="default"
className="rounded-full px-5 h-9 text-sm font-medium gap-1.5 bg-primary text-primary-foreground hover:bg-primary/90 border border-transparent"
onClick={() => {
setReplyToNpub(nip19.npubEncode(item.event.pubkey));
setComposing(true);
}}
>
<InkPenIcon className="size-3.5" strokeWidth={2} />
Reply
</Button>
</div>
</div>
<LetterDetailSheet
letter={showDetail ? letter : null}
onClose={() => setShowDetail(false)}
onReply={(npub) => {
setShowDetail(false);
setReplyToNpub(npub);
setComposing(true);
}}
/>
</NotificationWrapper>
</>
);
}
@@ -757,25 +802,26 @@ function unslugify(slug: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Hook: resolve the display name and badge data for a single badge award event. */
function useBadgeAward(awardEvent: NostrEvent): { name: string | undefined; badge: BadgeData | undefined } {
/** Hook: resolve the display name, badge data, and definition event for a single badge award event. */
function useBadgeAward(awardEvent: NostrEvent): { name: string | undefined; badge: BadgeData | undefined; definitionEvent: NostrEvent | undefined } {
const parsed = useMemo(() => parseBadgeATag(awardEvent), [awardEvent]);
const refs = useMemo(() => (parsed ? [parsed] : []), [parsed]);
const { badgeMap } = useBadgeDefinitions(refs);
if (!parsed) return { name: undefined, badge: undefined };
if (!parsed) return { name: undefined, badge: undefined, definitionEvent: undefined };
const aTag = `${BADGE_DEFINITION_KIND}:${parsed.pubkey}:${parsed.identifier}`;
const definition = badgeMap.get(aTag);
return {
name: definition?.name || unslugify(parsed.identifier),
badge: definition ?? undefined,
definitionEvent: definition?.event,
};
}
// ──────────────────────────────────────
// Accept Badge Button (shared by single and grouped badge notifications)
// ──────────────────────────────────────
function AcceptBadgeButton({ awardEvent }: { awardEvent: NostrEvent }) {
function AcceptBadgeButton({ awardEvent, prominent }: { awardEvent: NostrEvent; prominent?: boolean }) {
const { user } = useCurrentUser();
const { refs } = useProfileBadges(user?.pubkey);
const { mutate: acceptBadge, isPending, isSuccess } = useAcceptBadge();
@@ -789,13 +835,36 @@ function AcceptBadgeButton({ awardEvent }: { awardEvent: NostrEvent }) {
if (alreadyAccepted) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Check className="size-3" />
<span className={cn(
"inline-flex items-center gap-1 text-muted-foreground",
prominent ? "text-sm" : "text-xs",
)}>
<Check className={prominent ? "size-4" : "size-3"} />
Accepted
</span>
);
}
if (prominent) {
return (
<Button
className="rounded-full px-6 h-10 text-sm font-semibold gap-2 shadow-md hover:scale-105 active:scale-95 transition-all"
onClick={() => acceptBadge({ aTag, awardEventId: awardEvent.id })}
disabled={isPending}
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
{isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Award className="size-4" />
Accept Badge
</>
)}
</Button>
);
}
return (
<Button
size="sm"
@@ -820,7 +889,7 @@ function AcceptBadgeButton({ awardEvent }: { awardEvent: NostrEvent }) {
// Badge Award Notification (single actor)
// ──────────────────────────────────────
function BadgeAwardNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) {
const { name: badgeName, badge } = useBadgeAward(item.event);
const { definitionEvent } = useBadgeAward(item.event);
const parsed = useMemo(() => parseBadgeATag(item.event), [item.event]);
const badgeNaddr = useMemo(
() => parsed ? nip19.naddrEncode({ kind: BADGE_DEFINITION_KIND, pubkey: parsed.pubkey, identifier: parsed.identifier }) : undefined,
@@ -829,30 +898,20 @@ function BadgeAwardNotification({ item, isNew }: { item: NotificationItem; isNew
return (
<NotificationWrapper isNew={isNew}>
<div className="px-4 pt-3 pb-3">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<NotificationHeader
actorPubkey={item.event.pubkey}
icon={<Award className="size-4 text-primary" />}
action={badgeName ? `awarded you the "${badgeName}" badge` : 'awarded you a badge'}
/>
</div>
<div className="shrink-0">
<AcceptBadgeButton awardEvent={item.event} />
</div>
</div>
{badge && (
<Link to={badgeNaddr ? `/${badgeNaddr}` : '#'} className="mt-2 flex items-center gap-3 rounded-lg border border-border/50 bg-muted/30 p-2 transition-colors hover:bg-muted/60">
<BadgeThumbnail badge={badge} size={48} className="shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{badge.name}</p>
{badge.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{badge.description}</p>
)}
</div>
</Link>
)}
<div className="px-4 pt-3">
<NotificationHeader
actorPubkey={item.event.pubkey}
icon={<Award className="size-4 text-primary" />}
action="awarded you a badge"
/>
</div>
{definitionEvent && (
<Link to={badgeNaddr ? `/${badgeNaddr}` : '#'} className="block">
<BadgeContent event={definitionEvent} />
</Link>
)}
<div className="flex justify-center pb-4 pt-1">
<AcceptBadgeButton awardEvent={item.event} prominent />
</div>
</NotificationWrapper>
);
+2
View File
@@ -34,7 +34,9 @@ export function TestApp({ children }: TestAppProps) {
},
feedSettings: {
feedIncludePosts: true,
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeArticles: false,
showArticles: false,
showEvents: false,