Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e8c23020 | |||
| 94f0c8308d | |||
| 80820ae9c4 | |||
| 5288b7a718 | |||
| 4643830512 | |||
| ef04de67c0 | |||
| 5847cceba6 | |||
| f62b86027c | |||
| 8e5018d3b2 | |||
| 8df17f5ae7 | |||
| dd31ce681f | |||
| 74345fdb2f | |||
| 02231ea1f9 | |||
| effc704613 | |||
| 51fb1fd1cb | |||
| 1988e1b849 | |||
| 1b782f65d1 | |||
| 6c4eddece7 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -320,7 +320,9 @@ function SetupQuestionnaire({
|
||||
showProfileThemeUpdates: true,
|
||||
showCustomProfileThemes: true,
|
||||
feedIncludePosts: true,
|
||||
feedIncludeComments: true,
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeArticles: false,
|
||||
feedIncludeVines: true,
|
||||
feedIncludePolls: false,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('/'); };
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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';
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,9 @@ export function TestApp({ children }: TestAppProps) {
|
||||
},
|
||||
feedSettings: {
|
||||
feedIncludePosts: true,
|
||||
feedIncludeComments: true,
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeArticles: false,
|
||||
showArticles: false,
|
||||
showEvents: false,
|
||||
|
||||
Reference in New Issue
Block a user