Fix campaign reorders silently reverting when moving downward
The first reorder implementation encoded list position directly in the moderation label's `created_at` and republished the same axis label with a chosen timestamp. That fights the fold's newest-event-per-(coord,axis) rule the moment a moderator tries to lower a campaign's position: the new label has an older `created_at` than the existing one and the fold rejects it. The relay accepts the publish, but every subsequent read folds back to the higher-`created_at` predecessor and the move appears to revert. Move-up worked (its new `created_at` was strictly newer); move-down, drag-down, and any drag-to-midpoint that landed below an existing neighbor silently no-op'd. Anything dragged into the middle of an already-old list also picked a past timestamp that some relays reject for being too far behind "now". The fix decouples sort key from event recency: - Reorder publishes always use `created_at = now`, so the fold's newest-wins rule always picks them up. - The chosen position is encoded as a `["rank", "<integer>"]` tag on the label. - `foldModerationLabels` extracts the rank with a `created_at` fallback, so labels published before this change (and any normal approve / hide / feature actions that don't carry a rank) still sort by `created_at` exactly as they used to. Ranks are sourced from `Date.now() * 1000` (microseconds since epoch), so: - Fresh "feature" / "approve" publishes always sit above legacy labels whose effective rank is a seconds-since-epoch value. - Midpoint inserts have ~1000x headroom per second of inter-rank gap, comfortably enough for thousands of reorders before any renumbering would matter. - Headroom against `Number.MAX_SAFE_INTEGER` is ~150 years. Callers downstream (CampaignsPage, CampaignsDiscoverySection, PledgesDiscoverySection, useFeaturedOrganizations) still consume `featuredOrder` / `approvedOrder` as `Map<coord, number>` sorted descending — the map names and shapes are unchanged, only the value computation is now "rank ?? created_at" instead of "created_at". NIP.md updated to document the rank tag, the fallback semantics, and the reorder operations in terms of ranks.
This commit is contained in:
@@ -533,15 +533,15 @@ Surfacing rules (hide always wins):
|
||||
|
||||
**Campaigns**
|
||||
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
|
||||
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above). Ordered newest-`created_at`-of-`approved`-label first (same mechanic as the Featured row, with `approved` as the order axis instead of `featured`).
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank (see Moderator-driven Ordering), descending. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
|
||||
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above). Ordered by the approved label's effective rank, descending (same mechanic as the Featured row, with `approved` as the order axis instead of `featured`).
|
||||
- **Discover shelf** — iff approved AND not hidden.
|
||||
- **Moderator-only "Pending"** — iff neither approved nor hidden.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
|
||||
**Organizations**
|
||||
|
||||
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first.
|
||||
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank, descending (see Moderator-driven Ordering).
|
||||
- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state.
|
||||
- **Moderator-only "Needs review"** — iff `t:agora` AND not featured AND not hidden. Surfaces orgs minted through Agora's create flow that haven't been triaged into Featured or Hidden yet.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
@@ -556,19 +556,23 @@ Surfacing rules (hide always wins):
|
||||
|
||||
#### Moderator-driven Ordering
|
||||
|
||||
The Featured row and Community Campaigns grid are sorted by the `created_at` of the moderator's latest label on the relevant axis (`featured` for the Featured row, `approved` for the Community grid), newest first. This is intentional: it doubles as the protocol-level reordering mechanism, with no new tags or kinds required.
|
||||
The Featured row and Community Campaigns grid are sorted by the **effective rank** of the moderator's latest label on the relevant axis (`featured` for the Featured row, `approved` for the Community grid), descending.
|
||||
|
||||
A moderator MAY reorder either list by republishing the same axis label for a campaign with a chosen `created_at`. Three operations cover the common cases:
|
||||
A label's effective rank is the numeric value of its `["rank", "<number>"]` tag if present, falling back to the label's `created_at` when no rank tag is set. Labels published before this feature existed — and any normal approve / hide / feature actions that don't carry a rank — surface with their `created_at` as the effective rank, so newer feature/approval actions naturally float to the top, exactly as if no ordering scheme existed.
|
||||
|
||||
- **Move to top** — publish with `created_at = max(now, currentTopLabel.created_at + 1)`. The `max` guard handles a (rare) clock-skewed existing label whose `created_at` is already at or beyond `now`.
|
||||
- **Move up by one** — publish with `created_at = neighborAbove.created_at + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
|
||||
- **Move down by one** — publish with `created_at = neighborBelow.created_at - 1`. Only the moved campaign's label is republished; the neighbor below is untouched, it simply ends up sorted above the moved campaign because its `created_at` is now larger.
|
||||
The fold rule per `(coord, axis)` is unchanged: the newest event by `created_at` wins. Encoding order in the `created_at` itself would conflict with that rule the moment a moderator tried to lower a campaign's position — the new label would have an older `created_at` than the existing one and lose the fold. The rank tag decouples sort key from event recency so reorder publishes always use `created_at = now` and the fold always picks them up.
|
||||
|
||||
A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemented by computing the two new neighbors of the moved campaign in the rearranged list and choosing any `created_at` strictly between their timestamps. When the gap is too tight (`prev.created_at - next.created_at < 2`), clients SHOULD pick `next.created_at + 1` and accept that the rendered list may briefly be off by sub-second until the new label propagates — refetching the labels resolves the sort.
|
||||
A moderator MAY reorder either list by republishing the same axis label for a campaign with a `rank` tag carrying a chosen integer. Three operations cover the common cases:
|
||||
|
||||
- **Move to top** — publish with `rank = max(freshRank, currentTopRank + 1)`, where `freshRank` is a strictly-monotonic integer the client SHOULD source from current wall-clock time at sub-second resolution (Agora uses `Date.now() * 1000`). The `max` guard handles a (rare) clock-skewed existing rank that's already above `freshRank`.
|
||||
- **Move up by one** — publish with `rank = neighborAbove.rank + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
|
||||
- **Move down by one** — publish with `rank = neighborBelow.rank - 1`. Only the moved campaign's label is republished; the neighbor below is untouched.
|
||||
|
||||
A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemented by computing the two new neighbors of the moved campaign in the rearranged list and choosing any integer rank strictly between their ranks. When the gap is too tight (`prev.rank - next.rank < 2`), clients SHOULD pick `next.rank + 1` and accept that the rendered list may briefly be off by one until the next reorder leaves a wider gap. Using a sub-second-resolution `freshRank` keeps inter-rank gaps wide enough for many midpoint inserts before any renumbering is needed.
|
||||
|
||||
The conflict model matches the rest of the moderation namespace: the newest label per `(coord, axis)` from any moderator wins. Concurrent reorders by two moderators resolve to whoever's publish lands later; clients SHOULD refetch labels after a reorder publish to surface the authoritative order.
|
||||
|
||||
This scheme is unobservable to non-moderation clients. Anyone reading the labels — including non-Agora clients — sees only the axis state (approved / hidden / featured); the ordering is a property of how Agora's UI consumes the timestamps.
|
||||
Reorder labels remain valid moderation labels in every other respect. Clients that don't recognize the `rank` tag simply read the label's axis state and ignore the rank — the labels are not a separate kind, not a separate namespace, and not a new tag namespace. Non-Agora clients see exactly the same approve / hide / feature state they always have.
|
||||
|
||||
#### Event Structure
|
||||
|
||||
@@ -622,6 +626,26 @@ Required tags:
|
||||
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
|
||||
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
|
||||
|
||||
Optional tags:
|
||||
|
||||
- `rank` — single string element parsed as an integer. Used only on `approved` and `featured` labels to position the target within Agora's moderator-curated ordered lists; see Moderator-driven Ordering above. Labels without this tag sort by `created_at` (descending), which is the correct behavior for all non-reorder uses.
|
||||
|
||||
A label with a rank tag looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
|
||||
["rank", "1700000000123000"],
|
||||
["alt", "Campaign moderation: featured"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Trust Model
|
||||
|
||||
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
|
||||
|
||||
@@ -87,37 +87,48 @@ export function useCampaignModeration() {
|
||||
mutationFn: async ({
|
||||
coord,
|
||||
action,
|
||||
createdAt,
|
||||
rank,
|
||||
}: {
|
||||
coord: string;
|
||||
action: ModerationLabel;
|
||||
/**
|
||||
* Optional explicit `created_at` for the label event. Used by
|
||||
* Optional explicit rank for the label, written into a
|
||||
* `["rank", "<number>"]` tag on the event. Used by
|
||||
* `useReorderCampaign` to position a campaign within the
|
||||
* featured row or Community grid — both sort by the latest
|
||||
* label's `created_at` on the relevant axis, newest first, so
|
||||
* picking a timestamp between two neighbors lands the item
|
||||
* between them.
|
||||
* featured row or Community grid — the moderation fold uses
|
||||
* the rank as the sort key (descending), falling back to
|
||||
* `created_at` when no rank tag is present.
|
||||
*
|
||||
* Omit for normal approve / hide / feature actions and the
|
||||
* publisher will stamp `now` as usual.
|
||||
* The event itself is always signed with `created_at = now`
|
||||
* so the fold's "newest event per (coord, axis)" rule picks
|
||||
* up the reorder publish even when the chosen rank is lower
|
||||
* than the current label's rank — without that, moving a
|
||||
* campaign downward would be silently rejected by the fold.
|
||||
*
|
||||
* Omit for normal approve / hide / feature actions.
|
||||
*/
|
||||
createdAt?: number;
|
||||
rank?: number;
|
||||
}) => {
|
||||
// Quick parse-check on the coord so we don't sign garbage.
|
||||
if (!coord.startsWith(`${CAMPAIGN_KIND}:`)) {
|
||||
throw new Error(`Coordinate must start with ${CAMPAIGN_KIND}:`);
|
||||
}
|
||||
const tags: string[][] = [
|
||||
['L', AGORA_MODERATION_NAMESPACE],
|
||||
['l', action, AGORA_MODERATION_NAMESPACE],
|
||||
['a', coord],
|
||||
['alt', `Campaign moderation: ${action}`],
|
||||
];
|
||||
if (rank !== undefined && Number.isFinite(rank)) {
|
||||
// Store as a plain integer string. The fold parses with
|
||||
// `Number(...)` so a non-numeric value would degrade to the
|
||||
// `created_at` fallback rather than throwing.
|
||||
tags.push(['rank', String(Math.trunc(rank))]);
|
||||
}
|
||||
return publishEvent({
|
||||
kind: LABEL_KIND,
|
||||
content: '',
|
||||
tags: [
|
||||
['L', AGORA_MODERATION_NAMESPACE],
|
||||
['l', action, AGORA_MODERATION_NAMESPACE],
|
||||
['a', coord],
|
||||
['alt', `Campaign moderation: ${action}`],
|
||||
],
|
||||
...(createdAt !== undefined ? { created_at: createdAt } : {}),
|
||||
tags,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
+116
-115
@@ -6,7 +6,10 @@ import type { ModerationLabel } from '@/lib/agoraModeration';
|
||||
/**
|
||||
* Reordering axis. Featured uses the `featured` axis; the Community
|
||||
* Campaigns grid uses the `approval` axis. Both surfaces sort by the
|
||||
* `created_at` of the latest label on their axis, newest first.
|
||||
* effective rank of the latest label on their axis, descending — see
|
||||
* `useCampaignModeration` / `foldModerationLabels` for the rank
|
||||
* extraction (explicit `["rank", N]` tag falling back to
|
||||
* `created_at`).
|
||||
*/
|
||||
export type ReorderAxis = 'featured' | 'approval';
|
||||
|
||||
@@ -16,96 +19,105 @@ function axisToLabel(axis: ReorderAxis): ModerationLabel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reordering implemented entirely on top of the existing kind-1985
|
||||
* moderation labels (no new tags, no new kind). The sort key is the
|
||||
* label's `created_at` newest-first; "moving" a campaign means
|
||||
* republishing its label with a chosen `created_at` that lands the
|
||||
* campaign at the desired position in the displayed list.
|
||||
* Multiplier that lifts a freshly-stamped rank into the
|
||||
* microseconds-since-epoch range. Reorder publishes start from
|
||||
* `Date.now() * RANK_SCALE`, which is several orders of magnitude
|
||||
* above any legacy `created_at` fallback (seconds-since-epoch) — so
|
||||
* a newly-reordered campaign always sits above un-reordered legacy
|
||||
* neighbors when they share the same axis state. The fine-grained
|
||||
* sub-second resolution also leaves ample room for inserting
|
||||
* midpoint ranks during drag-to-position without exhausting the
|
||||
* integer gap.
|
||||
*
|
||||
* All operations take the **currently displayed ordered list** as
|
||||
* input and reference it to compute the new `created_at`:
|
||||
* Headroom check: `Date.now() * 1000 ≈ 1.7e15`; `Number.MAX_SAFE_INTEGER
|
||||
* ≈ 9e15`. ~150 years before overflow concerns.
|
||||
*/
|
||||
const RANK_SCALE = 1_000;
|
||||
|
||||
/** A fresh "place at the top" rank, in the scaled space. */
|
||||
function freshRank(): number {
|
||||
return Date.now() * RANK_SCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reordering is implemented via a `["rank", "<number>"]` tag on
|
||||
* kind 1985 moderation labels. The fold reads the rank as the sort
|
||||
* key (descending), falling back to `created_at` when the rank tag
|
||||
* is absent — so labels published before this feature existed (and
|
||||
* any normal approve / hide / feature actions that don't carry a
|
||||
* rank) continue to sort sensibly.
|
||||
*
|
||||
* - `moveToTop` — publish with `now`, or `max(orderTimestamps) + 1` if
|
||||
* any existing label is somehow already at `now` or beyond.
|
||||
* - `moveUp` — publish with `prevNeighbor.t + 1`. The "+1" is what
|
||||
* crosses the boundary; the previous neighbor's timestamp is the
|
||||
* smallest one strictly greater than ours, so beating it by one
|
||||
* second is sufficient.
|
||||
* - `moveDown` — publish with `nextNeighbor.t - 1`. Same logic
|
||||
* inverted; we want to fall just below the item directly beneath us.
|
||||
* - `moveTo(toIndex)` — generalizes the three: figures out the new
|
||||
* neighbors after the move, picks a timestamp between them, and
|
||||
* publishes once.
|
||||
* Why a tag and not the label's `created_at` directly: the fold
|
||||
* always picks the newest-`created_at` event per `(coord, axis)`.
|
||||
* If we encoded order in `created_at`, "move down" — which needs a
|
||||
* lower sort key than the existing label — would have to publish a
|
||||
* label with an *older* `created_at`. That label would lose the
|
||||
* fold to the existing one and the move would silently revert.
|
||||
* Decoupling the sort key from `created_at` lets reorders always
|
||||
* publish with `created_at = now` so the fold always picks them up.
|
||||
*
|
||||
* **Conflict model** matches the rest of the axis: the newest label
|
||||
* per `(coord, axis)` wins regardless of moderator. If two mods
|
||||
* reorder concurrently, whoever's publish lands later "wins" — same
|
||||
* trust model the rest of the moderation system already uses.
|
||||
* Operations:
|
||||
*
|
||||
* **Why timestamps and not a rank tag.** Encoding the order in the
|
||||
* label's `created_at` keeps the protocol surface unchanged: every
|
||||
* relay, every reader, every existing label cache already sorts
|
||||
* correctly without learning a new tag. The downside is that we burn
|
||||
* 1-second resolution per reorder operation; for a moderator-driven
|
||||
* UI with handful-of-items lists this is comfortably below the rate
|
||||
* at which reorders happen.
|
||||
* - `moveToTop` — publish with `rank = max(now_scaled, topRank + 1)`.
|
||||
* The `max` guard handles the (rare) clock-skewed neighbor whose
|
||||
* stored rank is somehow already above `now_scaled`.
|
||||
* - `moveUp` — publish with `rank = aboveNeighbor.rank + 1`. The
|
||||
* "+1" is what crosses the boundary; the neighbor above already
|
||||
* has the smallest rank strictly greater than ours so beating it
|
||||
* by one is sufficient.
|
||||
* - `moveDown` — publish with `rank = belowNeighbor.rank - 1`.
|
||||
* Inverse of moveUp.
|
||||
* - `moveTo(toIndex)` — pick a rank between the new neighbors. With
|
||||
* millisecond-scaled ranks there's almost always plenty of gap;
|
||||
* for legacy seconds-scaled neighbors the gap is still wide
|
||||
* enough for many midpoint inserts.
|
||||
*
|
||||
* Conflict model: identical to the rest of the moderation
|
||||
* namespace. The newest label per `(coord, axis)` from any
|
||||
* moderator wins. Concurrent reorders resolve to whoever's publish
|
||||
* lands later.
|
||||
*/
|
||||
export function useReorderCampaign() {
|
||||
const { moderate, data: moderation } = useCampaignModeration();
|
||||
|
||||
/**
|
||||
* Returns the `created_at` of the latest label on `axis` for `coord`,
|
||||
* or `undefined` if no such label exists yet (the campaign was never
|
||||
* approved / featured before).
|
||||
*/
|
||||
const orderTimestamp = useCallback(
|
||||
(coord: string, axis: ReorderAxis): number | undefined => {
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
return map.get(coord);
|
||||
},
|
||||
const orderMap = useCallback(
|
||||
(axis: ReorderAxis) =>
|
||||
axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder,
|
||||
[moderation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Publishes a label on `axis` for `coord` with an explicit
|
||||
* `created_at`. We piggyback on `useCampaignModeration().moderate`
|
||||
* which already handles the relay invalidation and the campaign
|
||||
* coord prefix check.
|
||||
* Publishes a label on `axis` for `coord` carrying an explicit
|
||||
* rank. `useCampaignModeration().moderate` handles the relay
|
||||
* invalidations and the campaign coord-prefix check; the rank is
|
||||
* written into a `["rank", "<number>"]` tag on the label event.
|
||||
*/
|
||||
const publishAt = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, createdAt: number) => {
|
||||
const publishWithRank = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, rank: number) => {
|
||||
await moderate.mutateAsync({
|
||||
coord,
|
||||
action: axisToLabel(axis),
|
||||
createdAt,
|
||||
rank,
|
||||
});
|
||||
},
|
||||
[moderate],
|
||||
);
|
||||
|
||||
/**
|
||||
* Move `coord` to the top of the displayed list.
|
||||
*
|
||||
* `displayedList` is the ordered coords currently rendered to the
|
||||
* user. We need it to defend against a clock-skewed label that's
|
||||
* somehow already in the future — we always end up strictly above
|
||||
* the current top.
|
||||
*/
|
||||
/** Move `coord` to position 0 of the displayed list. */
|
||||
const moveToTop = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
const map = orderMap(axis);
|
||||
const topCoord = displayedList[0];
|
||||
const topTs = topCoord && topCoord !== coord ? map.get(topCoord) ?? 0 : 0;
|
||||
const newTs = Math.max(now, topTs + 1);
|
||||
await publishAt(coord, axis, newTs);
|
||||
const topRank = topCoord && topCoord !== coord ? map.get(topCoord) ?? 0 : 0;
|
||||
const newRank = Math.max(freshRank(), topRank + 1);
|
||||
await publishWithRank(coord, axis, newRank);
|
||||
},
|
||||
[moderation, publishAt],
|
||||
[orderMap, publishWithRank],
|
||||
);
|
||||
|
||||
/**
|
||||
* Move `coord` up by one position in the displayed list. No-op when
|
||||
* the item is already at the top.
|
||||
* Move `coord` up by one position. No-op when the item is already
|
||||
* at the top.
|
||||
*/
|
||||
const moveUp = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
|
||||
@@ -116,53 +128,38 @@ export function useReorderCampaign() {
|
||||
await moveToTop(coord, axis, displayedList);
|
||||
return;
|
||||
}
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
const map = orderMap(axis);
|
||||
const aboveCoord = displayedList[idx - 1];
|
||||
const aboveTs = map.get(aboveCoord);
|
||||
if (aboveTs === undefined) {
|
||||
const aboveRank = map.get(aboveCoord);
|
||||
if (aboveRank === undefined) {
|
||||
// Shouldn't happen for items currently in the displayed list,
|
||||
// but degrade to "move to top" rather than throw.
|
||||
await moveToTop(coord, axis, displayedList);
|
||||
return;
|
||||
}
|
||||
await publishAt(coord, axis, aboveTs + 1);
|
||||
await publishWithRank(coord, axis, aboveRank + 1);
|
||||
},
|
||||
[moderation, publishAt, moveToTop],
|
||||
[orderMap, publishWithRank, moveToTop],
|
||||
);
|
||||
|
||||
/**
|
||||
* Move `coord` down by one position. No-op when already at the
|
||||
* bottom.
|
||||
*/
|
||||
/** Move `coord` down by one position. */
|
||||
const moveDown = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
|
||||
const idx = displayedList.indexOf(coord);
|
||||
if (idx < 0 || idx >= displayedList.length - 1) return;
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
const map = orderMap(axis);
|
||||
const belowCoord = displayedList[idx + 1];
|
||||
const belowTs = map.get(belowCoord);
|
||||
if (belowTs === undefined) return;
|
||||
// Subtract 1s to land strictly below the next item. The next
|
||||
// item's existing neighbor timestamp is some other value (or
|
||||
// nothing) — we don't need to touch it because we're only
|
||||
// crossing one boundary.
|
||||
await publishAt(coord, axis, belowTs - 1);
|
||||
const belowRank = map.get(belowCoord);
|
||||
if (belowRank === undefined) return;
|
||||
await publishWithRank(coord, axis, belowRank - 1);
|
||||
},
|
||||
[moderation, publishAt],
|
||||
[orderMap, publishWithRank],
|
||||
);
|
||||
|
||||
/**
|
||||
* General-purpose move: relocate `coord` to `toIndex` in the
|
||||
* displayed list. Used by drag-and-drop. Computes a timestamp
|
||||
* between the new neighbors (if any) and publishes a single label.
|
||||
*
|
||||
* The chosen timestamp is `min(prev.t, now) - 1` when there's no
|
||||
* `next`, or `next.t + 1` when there's no `prev`, or
|
||||
* `Math.floor((prev.t + next.t) / 2)` when both exist and the gap
|
||||
* is at least 2 seconds. If the gap is too tight (< 2s) we fall
|
||||
* back to `prev.t + 1` and accept the off-by-one (only the moved
|
||||
* item ends up out of position by sub-second, which the next render
|
||||
* fixes when the new label arrives).
|
||||
* Generalized move: relocate `coord` to `toIndex` in the displayed
|
||||
* list. Used by drag-and-drop. Picks a rank between the new
|
||||
* neighbors and publishes once.
|
||||
*/
|
||||
const moveTo = useCallback(
|
||||
async (
|
||||
@@ -177,38 +174,43 @@ export function useReorderCampaign() {
|
||||
if (clamped === fromIndex) return;
|
||||
|
||||
// Build the list without `coord`, then identify the items that
|
||||
// will sit directly above and below `coord` after insertion at
|
||||
// `clamped`.
|
||||
// sit directly above and below `coord` after insertion.
|
||||
const without = displayedList.filter((c) => c !== coord);
|
||||
const prevCoord = clamped > 0 ? without[clamped - 1] : undefined;
|
||||
const nextCoord = clamped < without.length ? without[clamped] : undefined;
|
||||
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const prevTs = prevCoord ? map.get(prevCoord) : undefined;
|
||||
const nextTs = nextCoord ? map.get(nextCoord) : undefined;
|
||||
const map = orderMap(axis);
|
||||
const prevRank = prevCoord ? map.get(prevCoord) : undefined;
|
||||
const nextRank = nextCoord ? map.get(nextCoord) : undefined;
|
||||
|
||||
let newTs: number;
|
||||
if (prevTs === undefined && nextTs === undefined) {
|
||||
newTs = now;
|
||||
} else if (prevTs === undefined) {
|
||||
let newRank: number;
|
||||
if (prevRank === undefined && nextRank === undefined) {
|
||||
newRank = freshRank();
|
||||
} else if (prevRank === undefined) {
|
||||
// Moving to position 0: stay above current top.
|
||||
newTs = Math.max(now, (nextTs ?? 0) + 1);
|
||||
} else if (nextTs === undefined) {
|
||||
// Moving to bottom: stay below current bottom but ≥ 1.
|
||||
newTs = Math.max(1, prevTs - 1);
|
||||
} else if (prevTs - nextTs >= 2) {
|
||||
newTs = Math.floor((prevTs + nextTs) / 2);
|
||||
newRank = Math.max(freshRank(), (nextRank ?? 0) + 1);
|
||||
} else if (nextRank === undefined) {
|
||||
// Moving to last slot: stay below current bottom but keep
|
||||
// headroom — `prevRank - 1` is safe because ranks are
|
||||
// milliseconds-of-publish-time (so a 1-unit step is a
|
||||
// sub-millisecond fraction of the typical inter-rank gap).
|
||||
newRank = prevRank - 1;
|
||||
} else if (prevRank - nextRank >= 2) {
|
||||
// Pick the midpoint. With millisecond-scaled ranks the
|
||||
// typical gap is millions; midpoint is exact integer
|
||||
// arithmetic with plenty of room for future inserts.
|
||||
newRank = Math.floor((prevRank + nextRank) / 2);
|
||||
} else {
|
||||
// Tight gap: nudge above next. The displayed list refreshes
|
||||
// from the new label, so any sub-second mis-ordering self-
|
||||
// corrects on the next render.
|
||||
newTs = nextTs + 1;
|
||||
// Tight gap (1 unit). Nudge above next and accept the
|
||||
// off-by-one — the fold's refetch will surface the new
|
||||
// ordering and any subsequent moves recompute from the
|
||||
// refreshed map.
|
||||
newRank = nextRank + 1;
|
||||
}
|
||||
|
||||
await publishAt(coord, axis, newTs);
|
||||
await publishWithRank(coord, axis, newRank);
|
||||
},
|
||||
[moderation, publishAt],
|
||||
[orderMap, publishWithRank],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -216,7 +218,6 @@ export function useReorderCampaign() {
|
||||
moveUp,
|
||||
moveDown,
|
||||
moveTo,
|
||||
orderTimestamp,
|
||||
isPending: moderate.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
+55
-15
@@ -36,6 +36,21 @@ interface AxisDecision {
|
||||
pubkey: string;
|
||||
/** Created-at of the latest label. */
|
||||
createdAt: number;
|
||||
/**
|
||||
* Optional explicit rank from a `["rank", "<number>"]` tag on the
|
||||
* event. Reorder operations publish this so the sort key is
|
||||
* independent of `created_at` — the fold's "newest event per
|
||||
* (coord, axis)" rule would otherwise reject a label that
|
||||
* attempts to move a campaign downward (lower `created_at` than
|
||||
* the current label).
|
||||
*
|
||||
* `undefined` for labels published before the reorder feature
|
||||
* shipped, or for normal approve / hide / feature actions that
|
||||
* don't carry a rank. Callers compute an effective sort key with
|
||||
* `rank ?? createdAt`, giving legacy labels a sensible default
|
||||
* while letting reorder labels override.
|
||||
*/
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
/** Per-coordinate rollup of approval + hide + featured state. */
|
||||
@@ -60,20 +75,26 @@ export interface ModerationData {
|
||||
/** Coordinates where the latest featured label is `featured`. */
|
||||
featuredCoords: Set<string>;
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `featured` label. Used to
|
||||
* sort featured rows newest-first.
|
||||
* Map of `coord` -> sort key for the featured row, descending.
|
||||
*
|
||||
* Moderators reorder a featured campaign by republishing its `featured`
|
||||
* label with a chosen `created_at` (see `useReorderCampaign`) — the
|
||||
* sort key is the label's timestamp, not the campaign's own, so
|
||||
* re-featuring an old campaign bumps it to the top.
|
||||
* The value is the rank carried by the latest `featured` label's
|
||||
* `["rank", "<number>"]` tag, falling back to the label's
|
||||
* `created_at` when no rank tag is present. Moderators reorder
|
||||
* featured campaigns by republishing the `featured` label with a
|
||||
* chosen rank (see `useReorderCampaign`); the fold always picks
|
||||
* the newest-`created_at` label per `(coord, axis)`, so reorder
|
||||
* publishes carry both a fresh `created_at = now` AND an explicit
|
||||
* rank that controls the sort.
|
||||
*
|
||||
* The fallback to `created_at` makes legacy labels (published
|
||||
* before the rank tag existed) sort sensibly — newer features
|
||||
* float to the top, exactly as before the rank tag landed.
|
||||
*/
|
||||
featuredOrder: Map<string, number>;
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `approved` label. Used
|
||||
* to sort the Community Campaigns grid newest-approved first, so
|
||||
* moderators can promote a campaign within the grid by re-approving
|
||||
* it (same mechanic as `featuredOrder` on the featured axis).
|
||||
* Map of `coord` -> sort key for the Community Campaigns grid.
|
||||
* Same shape and rules as `featuredOrder`, but tracks the
|
||||
* `approved` axis.
|
||||
*/
|
||||
approvedOrder: Map<string, number>;
|
||||
/** Pubkeys that were considered moderators when the query ran. */
|
||||
@@ -102,6 +123,21 @@ function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
|
||||
return value === 'featured' || value === 'unfeatured';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the rank value from a `["rank", "<number>"]` tag if present,
|
||||
* otherwise `undefined`. The value is parsed as a finite Number — a
|
||||
* non-numeric rank tag is treated as if it wasn't there so callers can
|
||||
* fall back to `created_at` cleanly.
|
||||
*/
|
||||
function extractRank(event: NostrEvent): number | undefined {
|
||||
const tag = event.tags.find(([n]) => n === 'rank');
|
||||
if (!tag) return undefined;
|
||||
const raw = tag[1];
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a flat list of label events into per-coordinate rollups by axis.
|
||||
* The newest event per `(coord, axis)` wins.
|
||||
@@ -131,18 +167,19 @@ export function foldModerationLabels(
|
||||
)?.[1];
|
||||
if (!aTag) continue;
|
||||
|
||||
const rank = extractRank(event);
|
||||
const state = byCoord.get(aTag) ?? {};
|
||||
if (isApprovalLabel(value)) {
|
||||
if (!state.approval || event.created_at > state.approval.createdAt) {
|
||||
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at, rank };
|
||||
}
|
||||
} else if (isHideLabel(value)) {
|
||||
if (!state.hide || event.created_at > state.hide.createdAt) {
|
||||
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at, rank };
|
||||
}
|
||||
} else if (isFeaturedLabel(value)) {
|
||||
if (!state.featured || event.created_at > state.featured.createdAt) {
|
||||
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at, rank };
|
||||
}
|
||||
}
|
||||
byCoord.set(aTag, state);
|
||||
@@ -156,12 +193,15 @@ export function foldModerationLabels(
|
||||
for (const [coord, state] of byCoord) {
|
||||
if (state.approval?.label === 'approved') {
|
||||
approvedCoords.add(coord);
|
||||
approvedOrder.set(coord, state.approval.createdAt);
|
||||
// Effective sort key: explicit rank tag wins, falling back to
|
||||
// the label's created_at so labels published before the rank
|
||||
// tag existed still sort correctly (newest-approved first).
|
||||
approvedOrder.set(coord, state.approval.rank ?? state.approval.createdAt);
|
||||
}
|
||||
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
|
||||
if (state.featured?.label === 'featured') {
|
||||
featuredCoords.add(coord);
|
||||
featuredOrder.set(coord, state.featured.createdAt);
|
||||
featuredOrder.set(coord, state.featured.rank ?? state.featured.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user