Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
e5bae89930
  1. 52
      src/hooks/useProfileInteractionPartners.ts
  2. 10
      src/i18n/locales/de.ts
  3. 10
      src/i18n/locales/en.ts
  4. 17
      src/lib/follow-list-history.ts
  5. 108
      src/lib/profile-interaction-partners.ts
  6. 9
      src/pages/secondary/FollowingListPage/index.tsx
  7. 217
      src/pages/secondary/ProfileInteractionDiagramPage/index.tsx
  8. 4
      src/providers/FollowListProvider.tsx
  9. 4
      vite.config.ts

52
src/hooks/useProfileInteractionPartners.ts

@ -1,52 +0,0 @@ @@ -1,52 +0,0 @@
import {
buildInteractionPartnerStats,
mergeEventsById,
type TInteractionPartnerStat
} from '@/lib/profile-interaction-partners'
import { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { useCallback, useEffect, useState } from 'react'
import { kinds } from 'nostr-tools'
const INTERACTION_KINDS = [kinds.ShortTextNote, kinds.Repost, kinds.Reaction] as const
export function useProfileInteractionPartners(authorPubkey: string | undefined, refreshNonce = 0) {
const [partners, setPartners] = useState<TInteractionPartnerStat[]>([])
const [loading, setLoading] = useState(false)
const [archiveAuthorEvents, setArchiveAuthorEvents] = useState(0)
const [sessionEventCount, setSessionEventCount] = useState(0)
const run = useCallback(async () => {
const pk = authorPubkey?.trim().toLowerCase()
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) {
setPartners([])
setArchiveAuthorEvents(0)
setSessionEventCount(0)
return
}
setLoading(true)
try {
const kindsArr = [...INTERACTION_KINDS]
const sessionEv = eventService.listSessionEventsAuthoredBy(pk, { kinds: kindsArr, limit: 900 })
setSessionEventCount(sessionEv.length)
const idbEv = await indexedDb.scanEventArchiveByAuthorPubkey(pk, {
kinds: kindsArr,
maxRowsScanned: 14_000,
maxMatches: 450
})
setArchiveAuthorEvents(idbEv.length)
const merged = mergeEventsById([...sessionEv, ...idbEv])
setPartners(buildInteractionPartnerStats(merged, pk))
} finally {
setLoading(false)
}
}, [authorPubkey])
useEffect(() => {
void run()
}, [run, refreshNonce])
return { partners, loading, rescan: run, archiveAuthorEvents, sessionEventCount }
}

10
src/i18n/locales/de.ts

@ -45,6 +45,16 @@ export default { @@ -45,6 +45,16 @@ export default {
"In gecachten Notizen noch keine markierten Personen. Timeline öffnen oder Feeds lesen, damit das Archiv füllt.",
interactionMapRefresh: "Cache erneut scannen",
interactionMapCellTitle: "{{count}} Erwähnungen · zuletzt {{when}}",
interactionMapIncludeFollows: "Alle meine Follows einblenden",
interactionMapIncludeFollowsHint:
"Zeigt deine komplette Follow-Liste zusammen mit Personen aus ihren gecachten Tags. Bei langen Listen scrollen.",
interactionMapIncludeFollowsBreakdown:
"{{total}} angezeigt — {{fromTags}} aus ihren gecachten Tags, {{fromFollowsOnly}} nur aus deiner Follow-Liste",
interactionMapCellTitleFollowOnly: "Nicht in ihren lokalen Tags — nur deine Follow-Liste",
interactionMapFollowingCheckbox: "Folge ich",
interactionMapMentionsShort: "×{{count}}",
interactionMapRecencyUnknown: "—",
interactionMapScore: "Score {{score}}",
followings: "Folgekonten",
boosted: "geboostet",
"Boosted by:": "Geboostet von:",

10
src/i18n/locales/en.ts

@ -43,6 +43,16 @@ export default { @@ -43,6 +43,16 @@ export default {
"No tagged people found in cached notes yet. Open their timeline or browse feeds so notes land in the archive.",
interactionMapRefresh: "Rescan cache",
interactionMapCellTitle: "{{count}} mentions · last {{when}}",
interactionMapIncludeFollows: "Include everyone I follow",
interactionMapIncludeFollowsHint:
"Shows your full follow list merged with people from their cached tags. Scroll when the list is long.",
interactionMapIncludeFollowsBreakdown:
"{{total}} shown — {{fromTags}} from their cached tags, {{fromFollowsOnly}} from your follows only",
interactionMapCellTitleFollowOnly: "Not in their cached tags — your follow list only",
interactionMapFollowingCheckbox: "Following",
interactionMapMentionsShort: "×{{count}}",
interactionMapRecencyUnknown: "—",
interactionMapScore: "Score {{score}}",
followings: "followings",
boosted: "boosted",
"Boosted by:": "Boosted by:",

17
src/lib/follow-list-history.ts

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
import { FOLLOWS_HISTORY_RELAY_URLS } from '@/constants'
import { createFollowListDraftEvent } from '@/lib/draft-event'
import type { Event } from 'nostr-tools'
import type { TDraftEvent, TPublishOptions } from '@/types'
/**
* Append-only snapshot of the current kind-3 contacts list to follows-history relays (same practice as
* {@link FollowingListPage} before destructive edits). Call immediately before publishing a new contacts state.
*/
export async function publishFollowListPreimageToHistory(
publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise<Event>,
tags: string[][],
content: string | undefined
): Promise<void> {
const draft = createFollowListDraftEvent(tags, content ?? '')
await publish(draft, { specifiedRelayUrls: [...FOLLOWS_HISTORY_RELAY_URLS] })
}

108
src/lib/profile-interaction-partners.ts

@ -33,6 +33,88 @@ export type TInteractionPartnerStat = { @@ -33,6 +33,88 @@ export type TInteractionPartnerStat = {
lastReferencedAt: number
}
/** Same recency horizon as the interaction map UI (≈ half a year). */
export const INTERACTION_MAP_RECENCY_MAX_AGE_SEC = 180 * 86400
export type TRankedInteractionPartner = {
stat: TInteractionPartnerStat
/** 0–100: more mentions and more recent references rank higher (matches map “heat” weights). */
score: number
}
/**
* Sort by combined frequency + recency. Uses `nowSec` and `maxAgeSec` like the map card shading
* (55% mention density vs max in list, 45% recency within the age window).
*/
export function rankInteractionPartnersByRecencyAndFrequency(
partners: TInteractionPartnerStat[],
nowSec: number,
maxAgeSec: number = INTERACTION_MAP_RECENCY_MAX_AGE_SEC
): TRankedInteractionPartner[] {
if (partners.length === 0) return []
const age = Math.max(1, maxAgeSec)
const maxM = Math.max(1, ...partners.map((p) => p.mentionCount))
const scoreFor = (p: TInteractionPartnerStat): number => {
const countNorm = Math.min(1, p.mentionCount / maxM)
const recencyNorm =
p.lastReferencedAt > 0
? 1 - Math.min(1, Math.max(0, nowSec - p.lastReferencedAt) / age)
: 0
return 100 * (0.55 * countNorm + 0.45 * recencyNorm)
}
return [...partners]
.map((stat) => ({ stat, score: scoreFor(stat) }))
.sort(
(a, b) =>
b.score - a.score ||
b.stat.mentionCount - a.stat.mentionCount ||
b.stat.lastReferencedAt - a.stat.lastReferencedAt ||
a.stat.pubkey.localeCompare(b.stat.pubkey)
)
}
/**
* Rows for the interaction map grid: ranked by frequency/recency, with optional merge of the viewers
* follows. When `includeAllFollows` is true, returns **every** merged row (no row cap): people from cached
* tags first (by score), then everyone who appears only from your follow list (stable pubkey order).
*/
export function rankInteractionMapGridRows(
partners: TInteractionPartnerStat[],
opts: {
includeAllFollows: boolean
followings: string[]
nowSec: number
maxAgeSec?: number
/** Max rows when `includeAllFollows` is false (interaction-only view). Ignored when including follows. */
gridCap?: number
}
): TRankedInteractionPartner[] {
const {
includeAllFollows,
followings,
nowSec,
maxAgeSec = INTERACTION_MAP_RECENCY_MAX_AGE_SEC,
gridCap = 72
} = opts
if (!includeAllFollows) {
return rankInteractionPartnersByRecencyAndFrequency(partners, nowSec, maxAgeSec).slice(0, gridCap)
}
const merged = mergeInteractionPartnersWithFollowings(partners, followings)
if (merged.length === 0) return []
const tagged = merged.filter((p) => p.mentionCount > 0 || p.lastReferencedAt > 0)
const followOnly = merged.filter((p) => p.mentionCount === 0 && p.lastReferencedAt === 0)
const rankedTagged = rankInteractionPartnersByRecencyAndFrequency(tagged, nowSec, maxAgeSec)
const extrasSorted = [...followOnly].sort((a, b) => a.pubkey.localeCompare(b.pubkey))
const extraRows: TRankedInteractionPartner[] = extrasSorted.map((stat) => ({ stat, score: 0 }))
return [...rankedTagged, ...extraRows]
}
export function buildInteractionPartnerStats(events: Event[], authorPubkey: string): TInteractionPartnerStat[] {
const author = authorPubkey.trim().toLowerCase()
if (!HEX64.test(author)) return []
@ -68,3 +150,29 @@ export function mergeEventsById(events: Event[]): Event[] { @@ -68,3 +150,29 @@ export function mergeEventsById(events: Event[]): Event[] {
}
return [...m.values()]
}
/** Adds follow pubkeys not already present so the viewer can manage follows from the interaction grid. */
export function mergeInteractionPartnersWithFollowings(
partners: TInteractionPartnerStat[],
followedPubkeys: string[]
): TInteractionPartnerStat[] {
const map = new Map<string, TInteractionPartnerStat>()
for (const p of partners) {
const k = p.pubkey.trim().toLowerCase()
if (!HEX64.test(k)) continue
map.set(k, { pubkey: k, mentionCount: p.mentionCount, lastReferencedAt: p.lastReferencedAt })
}
for (const raw of followedPubkeys) {
const k = raw.trim().toLowerCase()
if (!HEX64.test(k)) continue
if (!map.has(k)) {
map.set(k, { pubkey: k, mentionCount: 0, lastReferencedAt: 0 })
}
}
return [...map.values()].sort(
(a, b) =>
b.mentionCount - a.mentionCount ||
b.lastReferencedAt - a.lastReferencedAt ||
a.pubkey.localeCompare(b.pubkey)
)
}

9
src/pages/secondary/FollowingListPage/index.tsx

@ -22,8 +22,8 @@ import { useFetchFollowings, useFetchProfile } from '@/hooks' @@ -22,8 +22,8 @@ import { useFetchFollowings, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { FOLLOWS_HISTORY_RELAY_URLS } from '@/constants'
import { createFollowListDraftEvent } from '@/lib/draft-event'
import { publishFollowListPreimageToHistory } from '@/lib/follow-list-history'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import dayjs from 'dayjs'
@ -81,8 +81,11 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? @@ -81,8 +81,11 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
})
if (followListEvent) {
const historyDraft = createFollowListDraftEvent(followListEvent.tags ?? [], followListEvent.content ?? '')
await publish(historyDraft, { specifiedRelayUrls: FOLLOWS_HISTORY_RELAY_URLS })
await publishFollowListPreimageToHistory(
publish,
followListEvent.tags ?? [],
followListEvent.content ?? undefined
)
}
const draft = createFollowListDraftEvent([], '')

217
src/pages/secondary/ProfileInteractionDiagramPage/index.tsx

@ -2,21 +2,81 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -2,21 +2,81 @@ import { RefreshButton } from '@/components/RefreshButton'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { useSecondaryPage } from '@/contexts/secondary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useProfileInteractionPartners } from '@/hooks/useProfileInteractionPartners'
import {
buildInteractionPartnerStats,
INTERACTION_MAP_RECENCY_MAX_AGE_SEC,
mergeEventsById,
mergeInteractionPartnersWithFollowings,
rankInteractionMapGridRows,
type TInteractionPartnerStat
} from '@/lib/profile-interaction-partners'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfile } from '@/lib/link'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useFollowListOptional } from '@/providers/follow-list-context'
import { useNostr } from '@/providers/NostrProvider'
import { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { kinds } from 'nostr-tools'
import type { TPageRef } from '@/types'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
const INTERACTION_KINDS = [kinds.ShortTextNote, kinds.Repost, kinds.Reaction] as const
/** Co-located with this lazy page so dev/build chunks share one `react` instance (avoids invalid hook call). */
function useProfileInteractionPartners(authorPubkey: string | undefined, refreshNonce = 0) {
const [partners, setPartners] = useState<TInteractionPartnerStat[]>([])
const [loading, setLoading] = useState(false)
const [archiveAuthorEvents, setArchiveAuthorEvents] = useState(0)
const [sessionEventCount, setSessionEventCount] = useState(0)
const run = useCallback(async () => {
const pk = authorPubkey?.trim().toLowerCase()
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) {
setPartners([])
setArchiveAuthorEvents(0)
setSessionEventCount(0)
return
}
setLoading(true)
try {
const kindsArr = [...INTERACTION_KINDS]
const sessionEv = eventService.listSessionEventsAuthoredBy(pk, { kinds: kindsArr, limit: 900 })
setSessionEventCount(sessionEv.length)
const idbEv = await indexedDb.scanEventArchiveByAuthorPubkey(pk, {
kinds: kindsArr,
maxRowsScanned: 14_000,
maxMatches: 450
})
setArchiveAuthorEvents(idbEv.length)
const merged = mergeEventsById([...sessionEv, ...idbEv])
setPartners(buildInteractionPartnerStats(merged, pk))
} finally {
setLoading(false)
}
}, [authorPubkey])
useEffect(() => {
void run()
}, [run, refreshNonce])
return { partners, loading, rescan: run, archiveAuthorEvents, sessionEventCount }
}
const ProfileInteractionDiagramPage = forwardRef<
TPageRef,
{ id?: string; index?: number; hideTitlebar?: boolean }
@ -24,14 +84,64 @@ const ProfileInteractionDiagramPage = forwardRef< @@ -24,14 +84,64 @@ const ProfileInteractionDiagramPage = forwardRef<
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const followList = useFollowListOptional()
const { profile } = useFetchProfile(id)
const [refreshNonce, setRefreshNonce] = useState(0)
const [includeAllFollows, setIncludeAllFollows] = useState(false)
const [followBusyPubkey, setFollowBusyPubkey] = useState<string | null>(null)
const bump = useCallback(() => setRefreshNonce((n) => n + 1), [])
const { partners, loading, rescan, archiveAuthorEvents, sessionEventCount } = useProfileInteractionPartners(
profile?.pubkey,
refreshNonce
)
const rankedPartners = useMemo(
() =>
rankInteractionMapGridRows(partners, {
includeAllFollows,
followings: followList?.followings ?? [],
nowSec: dayjs().unix(),
maxAgeSec: INTERACTION_MAP_RECENCY_MAX_AGE_SEC,
gridCap: 72
}),
[partners, includeAllFollows, followList?.followings]
)
const includeFollowsBreakdown = useMemo(() => {
if (!includeAllFollows) return null
const merged = mergeInteractionPartnersWithFollowings(partners, followList?.followings ?? [])
const fromTags = merged.filter((p) => p.mentionCount > 0 || p.lastReferencedAt > 0).length
return {
total: merged.length,
fromTags,
fromFollowsOnly: merged.length - fromTags
}
}, [includeAllFollows, partners, followList?.followings])
const showFollowControls = Boolean(followList && accountPubkey)
const handleFollowToggle = useCallback(
(targetPubkey: string, nextChecked: boolean) => {
if (!followList || !accountPubkey) return
if (targetPubkey.toLowerCase() === accountPubkey.toLowerCase()) return
checkLogin(async () => {
setFollowBusyPubkey(targetPubkey)
try {
if (nextChecked) await followList.follow(targetPubkey)
else await followList.unfollow(targetPubkey)
} catch (err) {
toast.error(
(nextChecked ? t('Follow failed') : t('Unfollow failed')) + ': ' + (err as Error).message
)
} finally {
setFollowBusyPubkey(null)
}
})
},
[followList, accountPubkey, checkLogin, t]
)
const layoutRef = useRef<TPageRef>(null)
useImperativeHandle(
@ -58,10 +168,6 @@ const ProfileInteractionDiagramPage = forwardRef< @@ -58,10 +168,6 @@ const ProfileInteractionDiagramPage = forwardRef<
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, rescan, bump])
const nowSec = dayjs().unix()
const maxCount = partners[0]?.mentionCount ?? 1
const maxAgeSec = Math.max(1, 180 * 86400)
return (
<SecondaryPageLayout
ref={layoutRef}
@ -72,7 +178,37 @@ const ProfileInteractionDiagramPage = forwardRef< @@ -72,7 +178,37 @@ const ProfileInteractionDiagramPage = forwardRef<
displayScrollToTopButton
>
<div className="px-4 pb-8 space-y-4">
<p className="text-sm text-muted-foreground">{t('interactionMapSubtitle')}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<p className="text-sm text-muted-foreground flex-1 min-w-0">{t('interactionMapSubtitle')}</p>
{showFollowControls ? (
<div className="flex flex-col gap-1.5 shrink-0 sm:max-w-[min(100%,20rem)] sm:text-right">
<div className="flex items-center gap-2 sm:justify-end">
<Label htmlFor="interaction-map-include-follows" className="text-sm font-normal cursor-pointer">
{t('interactionMapIncludeFollows')}
</Label>
<Switch
id="interaction-map-include-follows"
checked={includeAllFollows}
onCheckedChange={setIncludeAllFollows}
/>
</div>
{includeAllFollows ? (
<>
<p className="text-xs text-muted-foreground sm:text-right">{t('interactionMapIncludeFollowsHint')}</p>
{includeFollowsBreakdown ? (
<p className="text-xs text-muted-foreground sm:text-right tabular-nums">
{t('interactionMapIncludeFollowsBreakdown', {
total: includeFollowsBreakdown.total,
fromTags: includeFollowsBreakdown.fromTags,
fromFollowsOnly: includeFollowsBreakdown.fromFollowsOnly
})}
</p>
) : null}
</>
) : null}
</div>
) : null}
</div>
<div className="text-xs text-muted-foreground flex flex-wrap gap-x-3 gap-y-1">
<span>{t('interactionMapSessionNotes', { count: sessionEventCount })}</span>
<span>{t('interactionMapArchiveNotes', { count: archiveAuthorEvents })}</span>
@ -84,44 +220,81 @@ const ProfileInteractionDiagramPage = forwardRef< @@ -84,44 +220,81 @@ const ProfileInteractionDiagramPage = forwardRef<
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
) : partners.length === 0 ? (
) : rankedPartners.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">{t('interactionMapEmpty')}</div>
) : (
<div
className={
includeAllFollows
? 'max-h-[min(70vh,720px)] overflow-y-auto overscroll-contain pr-1 -mr-1'
: undefined
}
>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
{partners.slice(0, 72).map((p) => {
const countNorm = Math.min(1, p.mentionCount / maxCount)
const age = Math.max(0, nowSec - p.lastReferencedAt)
const recencyNorm = 1 - Math.min(1, age / maxAgeSec)
const heat = 0.55 * countNorm + 0.45 * recencyNorm
{rankedPartners.map(({ stat: p, score }) => {
const heat = score / 100
const bgAlpha = 0.12 + heat * 0.55
const borderAlpha = 0.25 + heat * 0.65
const scoreRounded = Math.round(score)
const following = Boolean(
followList?.followings.some((f) => f.toLowerCase() === p.pubkey.toLowerCase())
)
const selfCard = accountPubkey?.toLowerCase() === p.pubkey.toLowerCase()
const cellTitle =
p.mentionCount > 0 && p.lastReferencedAt > 0
? `${t('interactionMapScore', { score: scoreRounded })} · ${t('interactionMapCellTitle', {
count: p.mentionCount,
when: dayjs.unix(p.lastReferencedAt).fromNow()
})}`
: `${t('interactionMapScore', { score: scoreRounded })} · ${t('interactionMapCellTitleFollowOnly')}`
return (
<button
<div
key={p.pubkey}
type="button"
className="rounded-lg border border-border p-2 flex flex-col items-center gap-1 min-w-0 text-left transition hover:opacity-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="relative rounded-lg border border-border min-w-0 transition hover:opacity-95"
style={{
backgroundColor: `hsl(var(--primary) / ${bgAlpha})`,
borderColor: `hsl(var(--primary) / ${borderAlpha})`
}}
>
{showFollowControls && !selfCard ? (
<div className="absolute top-1.5 right-1.5 z-10 flex items-center gap-1 rounded bg-background/80 px-1 py-0.5 border border-border/60 shadow-sm">
<Checkbox
id={`interaction-follow-${p.pubkey}`}
checked={following}
disabled={followBusyPubkey === p.pubkey}
aria-label={t('interactionMapFollowingCheckbox')}
onCheckedChange={(v) => {
if (v === 'indeterminate') return
handleFollowToggle(p.pubkey, Boolean(v))
}}
/>
</div>
) : null}
<button
type="button"
className={`w-full min-w-0 flex flex-col items-center gap-1 text-left rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring p-2 ${showFollowControls && !selfCard ? 'pt-7' : ''}`}
title={cellTitle}
onClick={() => push(toProfile(p.pubkey))}
title={t('interactionMapCellTitle', {
count: p.mentionCount,
when: dayjs.unix(p.lastReferencedAt).fromNow()
})}
>
<UserAvatar userId={p.pubkey} className="h-10 w-10 shrink-0" />
<div className="w-full min-w-0 text-center">
<Username userId={p.pubkey} className="text-xs truncate block" withoutSkeleton />
</div>
<div className="text-[10px] text-muted-foreground tabular-nums">×{p.mentionCount}</div>
<div className="text-[10px] font-medium tabular-nums text-primary">
{t('interactionMapScore', { score: scoreRounded })}
</div>
<div className="text-[10px] text-muted-foreground tabular-nums">
{t('interactionMapMentionsShort', { count: p.mentionCount })}
</div>
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{dayjs.unix(p.lastReferencedAt).fromNow()}
{p.lastReferencedAt > 0 ? dayjs.unix(p.lastReferencedAt).fromNow() : t('interactionMapRecencyUnknown')}
</div>
</button>
</div>
)
})}
</div>
</div>
)}
<div className="flex justify-center pt-2">

4
src/providers/FollowListProvider.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { createFollowListDraftEvent } from '@/lib/draft-event'
import { publishFollowListPreimageToHistory } from '@/lib/follow-list-history'
import {
dedupePTagsAppendPubkey,
fetchLatestReplaceableListEvent,
@ -51,6 +52,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) @@ -51,6 +52,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
if (!accountPubkey) return
const base = await mergeLatestFollowTags()
if (base === null) return
await publishFollowListPreimageToHistory(publish, base.tags, base.content)
const mergedTags = dedupePTagsAppendPubkey(base.tags, pubkey)
const newFollowListDraftEvent = createFollowListDraftEvent(mergedTags, base.content)
const newFollowListEvent = await publish(newFollowListDraftEvent)
@ -63,6 +65,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) @@ -63,6 +65,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
if (unique.length === 0) return
const base = await mergeLatestFollowTags()
if (base === null) return
await publishFollowListPreimageToHistory(publish, base.tags, base.content)
let mergedTags = base.tags
for (const pk of unique) {
mergedTags = dedupePTagsAppendPubkey(mergedTags, pk)
@ -83,6 +86,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) @@ -83,6 +86,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
}
if (!latest) return
await publishFollowListPreimageToHistory(publish, latest.tags, latest.content)
const newFollowListDraftEvent = createFollowListDraftEvent(
removePubkeyFromPTags(latest.tags, pubkey),
latest.content

4
vite.config.ts

@ -127,7 +127,9 @@ export default defineConfig(({ mode }) => { @@ -127,7 +127,9 @@ export default defineConfig(({ mode }) => {
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
/** Avoid invalid hook call / `dispatcher is null` when lazy chunks resolve a second `react` copy. */
dedupe: ['react', 'react-dom']
},
server: {
// OG/link preview uses `/sites/?url=…`. Without this, Vite serves `index.html` and WebService parses the app shell.

Loading…
Cancel
Save