diff --git a/package-lock.json b/package-lock.json index aa1c96d4..a3edbab4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "22.5.0", + "version": "22.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "22.5.0", + "version": "22.5.1", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 47352137..07655b3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "22.5.0", + "version": "22.5.1", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 5439af87..0e1afe38 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -37,7 +37,7 @@ import { applyImwaldAttributionTags, mergeUploadImetaTagsInto } from '@/lib/draft-event' -import { ExtendedKind } from '@/constants' +import { ExtendedKind, MAX_PUBLISH_RELAYS } from '@/constants' import { cn, isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useFeed } from '@/providers/FeedProvider' @@ -78,6 +78,7 @@ import { nip94PairsToImetaTag } from '@/lib/upload-nip94-imeta' import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' +import type { TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap' import { successfulPublishRelayUrls, type TRelayPublishStatus } from '@/lib/publish-relay-urls' import client, { eventService } from '@/services/client.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' @@ -219,6 +220,12 @@ export default function PostContent({ ) const [isProtectedEvent, setIsProtectedEvent] = useState(false) const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) + /** When set, too many relays are checked vs the per-publish cap; publish stays disabled until unchecking. */ + const [relayCapBlockInfo, setRelayCapBlockInfo] = useState<{ + outboxSlotsInPublish: number + selectedContacted: number + selectedTotal: number + } | null>(null) const [isHighlight, setIsHighlight] = useState(!!initialHighlightData) const [highlightData, setHighlightData] = useState( initialHighlightData || { @@ -386,6 +393,22 @@ export default function PostContent({ isNsfw ]) + const handleRelayPublishCapChange = useCallback((preview: TPrePublishRelayCapPreview) => { + if (preview.blocksPublish) { + setRelayCapBlockInfo({ + outboxSlotsInPublish: preview.outboxSlotsInPublish, + selectedContacted: preview.selectedContacted, + selectedTotal: preview.selectedTotal + }) + } else { + setRelayCapBlockInfo(null) + } + }, []) + + useEffect(() => { + if (isPoll) setRelayCapBlockInfo(null) + }, [isPoll]) + const canPost = useMemo(() => { const discussionOk = !isDiscussionThread || @@ -413,7 +436,8 @@ export default function PostContent({ (!isCitationInternal || !!citationInternalCTag.trim()) && (!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && (!isCitationHardcopy || !!citationAccessedOn.trim()) && - (!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) + (!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) && + relayCapBlockInfo === null ) return result @@ -447,7 +471,8 @@ export default function PostContent({ threadIsReadingGroup, threadReadingAuthor, threadReadingSubject, - threadSelectedGroup + threadSelectedGroup, + relayCapBlockInfo ]) // Clear highlight data when initialHighlightData changes or is removed @@ -3104,12 +3129,29 @@ export default function PostContent({ + {relayCapBlockInfo && ( +

+ {relayCapBlockInfo.outboxSlotsInPublish > 0 + ? t('Publish relay cap hint with outbox first', { + max: MAX_PUBLISH_RELAYS, + reservedSlots: relayCapBlockInfo.outboxSlotsInPublish, + selected: relayCapBlockInfo.selectedTotal, + selectedContacted: relayCapBlockInfo.selectedContacted + }) + : t('Publish relay cap hint', { + max: MAX_PUBLISH_RELAYS, + selected: relayCapBlockInfo.selectedTotal, + selectedContacted: relayCapBlockInfo.selectedContacted + })} +

+ )} {isDiscussionThread && threadErrors.relay && (

{threadErrors.relay}

)} diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 2c1af151..24eee9f9 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -1,13 +1,6 @@ -import { - ExtendedKind, - isSocialKindBlockedKind, - MAX_PUBLISH_RELAYS, - READ_ONLY_RELAY_URLS, - SOCIAL_KIND_BLOCKED_RELAY_URLS -} from '@/constants' +import { ExtendedKind, isSocialKindBlockedKind, MAX_PUBLISH_RELAYS, SOCIAL_KIND_BLOCKED_RELAY_URLS } from '@/constants' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' -import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' -import { simplifyUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' +import { simplifyUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -25,15 +18,31 @@ import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import logger from '@/lib/logger' +import { computePrePublishRelayCapPreview, type TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap' /** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */ const NO_MENTIONS: string[] = [] +/** Keep auto-selection within {@link MAX_PUBLISH_RELAYS}, preserving {@link selectableRelaysOrder} (top of list first). */ +function capAutoSelectedRelays(selectableRelaysOrder: string[], selectedWithCache: string[]): string[] { + const norm = (u: string) => normalizeAnyRelayUrl(u) || u + const selectedNormSet = new Set(selectedWithCache.map(norm)) + const ordered: string[] = [] + for (const url of selectableRelaysOrder) { + if (selectedNormSet.has(norm(url))) ordered.push(url) + } + for (const url of selectedWithCache) { + if (!ordered.some((u) => norm(u) === norm(url))) ordered.push(url) + } + return ordered.slice(0, MAX_PUBLISH_RELAYS) +} + export default function PostRelaySelector({ parentEvent: _parentEvent, openFrom, setIsProtectedEvent, setAdditionalRelayUrls, + onRelayPublishCapChange, content: postContent = '', isPublicMessage = false, mentions = NO_MENTIONS @@ -42,6 +51,8 @@ export default function PostRelaySelector({ openFrom?: string[] setIsProtectedEvent: Dispatch> setAdditionalRelayUrls: Dispatch> + /** Notifies the post form when the relay cap prevents honoring every checked relay (so the form can disable publish and show a banner). */ + onRelayPublishCapChange?: (preview: TPrePublishRelayCapPreview) => void content?: string isPublicMessage?: boolean mentions?: string[] @@ -91,61 +102,29 @@ export default function PostRelaySelector({ return false }, [_parentEvent]) - /** - * Same merge order as {@link ClientService.publishEvent}: NIP-65 write list first, then relays checked here, - * then cap at {@link MAX_PUBLISH_RELAYS}. Drives the cap hint so users see reserved “prepended” slots. - */ - const publishCapPreview = useMemo(() => { - const applySocialOutboxFilter = - !isPublicMessage && - (_parentEvent == null || - isDiscussionReply || - (_parentEvent != null && isSocialKindBlockedKind(_parentEvent.kind))) - - const wsOut = (relayList?.write ?? []) - .map((u) => normalizeUrl(u) || u) - .filter((u): u is string => !!u) - const httpOut = (relayList?.httpWrite ?? []) - .map((u) => normalizeHttpRelayUrl(u) || u) - .filter((u): u is string => !!u) - let outbox = dedupeNormalizeRelayUrlsOrdered([...httpOut, ...wsOut]) - const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u)) - const socialBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - outbox = dedupeNormalizeRelayUrlsOrdered( - outbox.filter((url) => { - const n = normalizeAnyRelayUrl(url) || url - if (readOnlySet.has(n)) return false - if (applySocialOutboxFilter && socialBlockedSet.has(n)) return false - return true - }) - ) - - const merged = dedupeNormalizeRelayUrlsOrdered([...outbox, ...selectedRelayUrls]) - const capped = merged.slice(0, MAX_PUBLISH_RELAYS) - const outboxNormSet = new Set(outbox) - const outboxSlotsInPublish = capped.filter((u) => outboxNormSet.has(u)).length - const selectedNorm = selectedRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u) - const selectedContacted = selectedNorm.filter((u) => capped.includes(u)).length - - const showCapHint = - merged.length > MAX_PUBLISH_RELAYS || - selectedRelayUrls.length >= MAX_PUBLISH_RELAYS || - selectedContacted < selectedRelayUrls.length + const publishCapPreview = useMemo( + () => + computePrePublishRelayCapPreview({ + relayListWrite: relayList?.write, + relayListHttpWrite: relayList?.httpWrite, + selectedRelayUrls, + isPublicMessage, + parentEvent: _parentEvent, + isDiscussionReply + }), + [ + relayList?.write, + relayList?.httpWrite, + selectedRelayUrls, + isPublicMessage, + _parentEvent, + isDiscussionReply + ] + ) - return { - outboxSlotsInPublish, - selectedContacted, - selectedTotal: selectedRelayUrls.length, - showCapHint - } - }, [ - relayList?.write, - relayList?.httpWrite, - selectedRelayUrls, - isPublicMessage, - _parentEvent, - isDiscussionReply - ]) + useEffect(() => { + onRelayPublishCapChange?.(publishCapPreview) + }, [publishCapPreview, onRelayPublishCapChange]) /** * Relay selection only cares about nostr:… mentions in the draft (see relay-selection.service). @@ -242,8 +221,9 @@ export default function PostRelaySelector({ if (!hasManualSelection || selectableRelaysChanged) { const cacheRelays = result.selectableRelays.filter(url => isLocalNetworkUrl(url)) const selectedWithCache = Array.from(new Set([...result.selectedRelays, ...cacheRelays])) - setSelectedRelayUrls(selectedWithCache) - setDescription(describeRelaySelection(selectedWithCache)) + const capped = capAutoSelectedRelays(result.selectableRelays, selectedWithCache) + setSelectedRelayUrls(capped) + setDescription(describeRelaySelection(capped)) if (selectableRelaysChanged && hasManualSelection) { setHasManualSelection(false) } @@ -395,6 +375,7 @@ export default function PostRelaySelector({ const capHintEl = publishCapPreview.showCapHint && + !publishCapPreview.blocksPublish && (publishCapPreview.outboxSlotsInPublish > 0 ? ( {t('Publish relay cap hint with outbox first', { diff --git a/src/constants.ts b/src/constants.ts index de9f4288..f3b75524 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -317,7 +317,8 @@ export const READ_ONLY_RELAY_URLS = [ 'wss://relay.noswhere.com', 'wss://search.nos.today', 'wss://trending.nostr.wine', - 'wss://relay.nip46.com' + 'wss://relay.nip46.com', + 'wss://filter.nostr.wine' ] /** diff --git a/src/lib/pre-publish-relay-cap.ts b/src/lib/pre-publish-relay-cap.ts new file mode 100644 index 00000000..9d44ceef --- /dev/null +++ b/src/lib/pre-publish-relay-cap.ts @@ -0,0 +1,84 @@ +import { + isSocialKindBlockedKind, + MAX_PUBLISH_RELAYS, + READ_ONLY_RELAY_URLS, + SOCIAL_KIND_BLOCKED_RELAY_URLS +} from '@/constants' +import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' +import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' +import type { NostrEvent } from 'nostr-tools' + +export type TPrePublishRelayCapPreview = { + outboxSlotsInPublish: number + selectedContacted: number + selectedTotal: number + showCapHint: boolean + /** True when not every checked relay in the picker can be included in the capped publish list. */ + blocksPublish: boolean +} + +/** + * Pre-publish preview: mirrors merge + cap order in {@link ClientService.publishEvent}: NIP-65 write list first, then + * relays checked in the post relay picker, capped at {@link MAX_PUBLISH_RELAYS}. + */ +export function computePrePublishRelayCapPreview({ + relayListWrite, + relayListHttpWrite, + selectedRelayUrls, + isPublicMessage, + parentEvent, + isDiscussionReply +}: { + relayListWrite?: string[] + relayListHttpWrite?: string[] + selectedRelayUrls: string[] + isPublicMessage: boolean + parentEvent?: NostrEvent + isDiscussionReply: boolean +}): TPrePublishRelayCapPreview { + const applySocialOutboxFilter = + !isPublicMessage && + (parentEvent == null || + isDiscussionReply || + (parentEvent != null && isSocialKindBlockedKind(parentEvent.kind))) + + const wsOut = (relayListWrite ?? []) + .map((u) => normalizeUrl(u) || u) + .filter((u): u is string => !!u) + const httpOut = (relayListHttpWrite ?? []) + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter((u): u is string => !!u) + let outbox = dedupeNormalizeRelayUrlsOrdered([...httpOut, ...wsOut]) + const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u)) + const socialBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + outbox = dedupeNormalizeRelayUrlsOrdered( + outbox.filter((url) => { + const n = normalizeAnyRelayUrl(url) || url + if (readOnlySet.has(n)) return false + if (applySocialOutboxFilter && socialBlockedSet.has(n)) return false + return true + }) + ) + + const merged = dedupeNormalizeRelayUrlsOrdered([...outbox, ...selectedRelayUrls]) + const capped = merged.slice(0, MAX_PUBLISH_RELAYS) + const outboxNormSet = new Set(outbox) + const outboxSlotsInPublish = capped.filter((u) => outboxNormSet.has(u)).length + const selectedNorm = selectedRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u) + const selectedContacted = selectedNorm.filter((u) => capped.includes(u)).length + + const showCapHint = + merged.length > MAX_PUBLISH_RELAYS || + selectedRelayUrls.length >= MAX_PUBLISH_RELAYS || + selectedContacted < selectedRelayUrls.length + + const blocksPublish = selectedContacted < selectedRelayUrls.length + + return { + outboxSlotsInPublish, + selectedContacted, + selectedTotal: selectedRelayUrls.length, + showCapHint, + blocksPublish + } +} diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index aff9ea31..bb9d9c64 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -1,5 +1,5 @@ import { Event, kinds } from 'nostr-tools' -import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants' +import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT, READ_ONLY_RELAY_URLS } from '@/constants' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import client from '@/services/client.service' import { eventService } from '@/services/client.service' @@ -175,12 +175,18 @@ class RelaySelectionService { } const deduplicatedRelays = order.map((o) => o.url) - const filtered = this.filterBlockedRelays(deduplicatedRelays, context.blockedRelays) + const filtered = this.filterReadOnlyRelays( + this.filterBlockedRelays(deduplicatedRelays, context.blockedRelays) + ) const relayTypes: Record = {} order.forEach(({ url, type }) => { if (filtered.includes(url)) relayTypes[url] = type }) - return { relays: filtered, relayTypes, randomRelayUrls } + return { + relays: filtered, + relayTypes, + randomRelayUrls: this.filterReadOnlyRelays(randomRelayUrls) + } } /** @@ -429,8 +435,7 @@ class RelaySelectionService { selectedRelays = Array.from(new Set(selectedRelays)) } - // Filter out blocked relays - return this.filterBlockedRelays(selectedRelays, context.blockedRelays) + return this.filterReadOnlyRelays(this.filterBlockedRelays(selectedRelays, context.blockedRelays)) } /** @@ -799,6 +804,20 @@ class RelaySelectionService { } } + /** + * Strip relays that never accept writes ({@link READ_ONLY_RELAY_URLS}) so they do not appear in the publish picker. + * Same set as `ClientService` uses when filtering publish targets. + */ + private filterReadOnlyRelays(relays: string[]): string[] { + const readOnlySet = new Set( + READ_ONLY_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) + ) + return relays.filter((relay) => { + const n = normalizeAnyRelayUrl(relay) || relay + return !readOnlySet.has(n) + }) + } + /** * Filter out blocked relays from a list */