Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type { Mutator } from '~/core/sync/use-mutate';
import type { Relation } from '~/core/types';

const DEFAULT_SHOWN_COLUMNS: { id: string; name: string }[] = [
{ id: SystemIds.TYPES_PROPERTY, name: 'Types' },
{ id: SystemIds.DESCRIPTION_PROPERTY, name: 'Description' },
];

Expand Down
33 changes: 32 additions & 1 deletion apps/web/core/blocks/ranking/use-ranking-entry-entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest';
import { EntityId } from '~/core/io/substream-schema';
import type { Relation, Value } from '~/core/types';

import { pickImage, pickRelationBySpace, pickValueBySpace } from './use-ranking-entry-entities';
import { pickImage, pickImageByPreference, pickRelationBySpace, pickValueBySpace } from './use-ranking-entry-entities';

// Real space IDs from getSpaceRank: Root=0, Crypto=2, Software=5.
const ROOT_SPACE = 'a19c345ab9866679b001d7d2138d88a1';
Expand Down Expand Up @@ -139,3 +139,34 @@ describe('pickImage', () => {
expect(pickImage([], UNRANKED_SPACE)).toBeNull();
});
});

describe('pickImageByPreference (cover)', () => {
it('prefers a current-space cover over a current-space avatar', () => {
const relations = [
relation(AVATAR_TYPE, UNRANKED_SPACE, 'ipfs://current-avatar'),
relation(COVER_TYPE, UNRANKED_SPACE, 'ipfs://current-cover'),
];
expect(pickImageByPreference(relations, UNRANKED_SPACE, 'cover')).toBe('ipfs://current-cover');
});

it('falls back to the avatar when there is no cover', () => {
const relations = [relation(AVATAR_TYPE, UNRANKED_SPACE, 'ipfs://current-avatar')];
expect(pickImageByPreference(relations, UNRANKED_SPACE, 'cover')).toBe('ipfs://current-avatar');
});

it('prefers a current-space cover over a ranked-space avatar', () => {
const relations = [
relation(AVATAR_TYPE, ROOT_SPACE, 'ipfs://root-avatar'),
relation(COVER_TYPE, UNRANKED_SPACE, 'ipfs://current-cover'),
];
expect(pickImageByPreference(relations, UNRANKED_SPACE, 'cover')).toBe('ipfs://current-cover');
});

it('matches the avatar-preferring pickImage when preference is avatar', () => {
const relations = [
relation(AVATAR_TYPE, ROOT_SPACE, 'ipfs://root-avatar'),
relation(COVER_TYPE, UNRANKED_SPACE, 'ipfs://current-cover'),
];
expect(pickImageByPreference(relations, UNRANKED_SPACE, 'avatar')).toBe(pickImage(relations, UNRANKED_SPACE));
});
});
44 changes: 27 additions & 17 deletions apps/web/core/blocks/ranking/use-ranking-entry-entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ export type RankingEntryDisplay = {
image: string | null;
};

export function useRankingEntryEntities(spaceId: string, entityIds: string[]) {
export type RankingImagePreference = 'avatar' | 'cover';

export function useRankingEntryEntities(
spaceId: string,
entityIds: string[],
imageProperty: RankingImagePreference = 'avatar'
) {
const entityIdsKey = entityIds.filter(Boolean).join('|');
const stableIds = React.useMemo(() => [...new Set(entityIdsKey ? entityIdsKey.split('|') : [])], [entityIdsKey]);

Expand All @@ -40,11 +46,11 @@ export function useRankingEntryEntities(spaceId: string, entityIds: string[]) {
entityId: id,
name: pickValueBySpace(entity.values, SystemIds.NAME_PROPERTY, spaceId) ?? 'Untitled',
description: pickValueBySpace(entity.values, SystemIds.DESCRIPTION_PROPERTY, spaceId),
image: pickImage(entity.relations, spaceId),
image: pickImageByPreference(entity.relations, spaceId, imageProperty),
};
})
.filter((e): e is RankingEntryDisplay => e != null),
[byId, stableIds, spaceId]
[byId, stableIds, spaceId, imageProperty]
);

return { entries, isLoading: isLoading && !isFetched };
Expand Down Expand Up @@ -75,23 +81,27 @@ export function pickRelationBySpace(relations: Relation[], typeId: string, curre
);
}

/**
* Resolve an image URL for a ranking card. Order:
* current-space avatar → current-space cover → ranked-space avatar → ranked-space cover.
*
* Relations whose target image has no URL value are skipped at every step so a
* placeholder avatar in the current space can't mask a usable image from another space.
*/
export function pickImage(relations: Relation[], currentSpaceId: string): string | null {
export function pickImageByPreference(
relations: Relation[],
currentSpaceId: string,
preference: RankingImagePreference
): string | null {
const withValue = relations.filter(r => r.toEntity.value);

const avatar = pickRelationBySpace(withValue, EntityId(ContentIds.AVATAR_PROPERTY), currentSpaceId);
if (avatar?.spaceId === currentSpaceId) return avatar.toEntity.value;
const preferredType = preference === 'cover' ? SystemIds.COVER_PROPERTY : ContentIds.AVATAR_PROPERTY;
const otherType = preference === 'cover' ? ContentIds.AVATAR_PROPERTY : SystemIds.COVER_PROPERTY;

const cover = pickRelationBySpace(withValue, EntityId(SystemIds.COVER_PROPERTY), currentSpaceId);
if (cover?.spaceId === currentSpaceId) return cover.toEntity.value;
const preferred = pickRelationBySpace(withValue, EntityId(preferredType), currentSpaceId);
if (preferred?.spaceId === currentSpaceId) return preferred.toEntity.value;

if (avatar) return avatar.toEntity.value;
if (cover) return cover.toEntity.value;
const other = pickRelationBySpace(withValue, EntityId(otherType), currentSpaceId);
if (other?.spaceId === currentSpaceId) return other.toEntity.value;

if (preferred) return preferred.toEntity.value;
if (other) return other.toEntity.value;
return null;
}

export function pickImage(relations: Relation[], currentSpaceId: string): string | null {
return pickImageByPreference(relations, currentSpaceId, 'avatar');
}
95 changes: 95 additions & 0 deletions apps/web/partials/blocks/table/ranking-card-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { ContentIds, SystemIds } from '@geoprotocol/geo-sdk/lite';

import { describe, expect, it } from 'vitest';

import type { Property } from '~/core/types';

import {
buildRankingCardConfig,
selectRankingCardImageProperty,
selectRankingCardProperties,
} from './ranking-card-config';

function prop(id: string, name: string | null = null): Property {
return { id, name, dataType: 'RELATION' };
}

function dashed(id: string): string {
return [id.slice(0, 8), id.slice(8, 12), id.slice(12, 16), id.slice(16, 20), id.slice(20)].join('-');
}

describe('selectRankingCardProperties', () => {
const custom = prop('custom-prop', 'Custom');

it('drops name, description, cover and avatar', () => {
const properties = [
prop(SystemIds.NAME_PROPERTY),
prop(SystemIds.DESCRIPTION_PROPERTY),
prop(SystemIds.COVER_PROPERTY),
prop(ContentIds.AVATAR_PROPERTY),
custom,
];

expect(selectRankingCardProperties(properties)).toEqual([custom]);
});

it('drops excluded properties whose ids arrive dashed', () => {
const properties = [prop(dashed(SystemIds.COVER_PROPERTY)), prop(dashed(ContentIds.AVATAR_PROPERTY)), custom];

expect(selectRankingCardProperties(properties)).toEqual([custom]);
});
});

describe('selectRankingCardImageProperty', () => {
it('prefers cover when the cover column is shown', () => {
expect(selectRankingCardImageProperty([SystemIds.COVER_PROPERTY])).toBe('cover');
expect(selectRankingCardImageProperty([dashed(SystemIds.COVER_PROPERTY)])).toBe('cover');
});

it('falls back to avatar otherwise', () => {
expect(selectRankingCardImageProperty([ContentIds.AVATAR_PROPERTY])).toBe('avatar');
expect(selectRankingCardImageProperty([])).toBe('avatar');
});
});

describe('selector agreement', () => {
it.each([SystemIds.COVER_PROPERTY, dashed(SystemIds.COVER_PROPERTY)])('cover id %s is never both', coverId => {
expect(selectRankingCardImageProperty([coverId])).toBe('cover');
expect(selectRankingCardProperties([prop(coverId)])).toEqual([]);
});
});

describe('buildRankingCardConfig', () => {
const source = { type: 'GEO' } as const;
const custom = prop('custom-prop', 'Custom');

it('applies both selectors and passes source through', () => {
expect(
buildRankingCardConfig({
properties: [prop(SystemIds.COVER_PROPERTY), custom],
shownColumnIds: [SystemIds.COVER_PROPERTY, custom.id],
source,
})
).toEqual({ properties: [custom], source, imageProperty: 'cover' });
});

it('falls back to avatar when no cover column is shown', () => {
expect(buildRankingCardConfig({ properties: [custom], shownColumnIds: [custom.id], source }).imageProperty).toBe(
'avatar'
);
});

it.each([SystemIds.COVER_PROPERTY, dashed(SystemIds.COVER_PROPERTY)])(
'never emits the image property as a field (%s)',
coverId => {
const config = buildRankingCardConfig({
properties: [prop(coverId), custom],
shownColumnIds: [coverId, custom.id],
source,
});

expect(config.imageProperty).toBe('cover');
expect(config.properties).toEqual([custom]);
}
);
});
73 changes: 73 additions & 0 deletions apps/web/partials/blocks/table/ranking-card-config.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client';

import { ContentIds, SystemIds } from '@geoprotocol/geo-sdk/lite';

import * as React from 'react';

import type { Source } from '~/core/blocks/data/source';
import { ID } from '~/core/id';
import type { Property } from '~/core/types';

/**
* Normalized so membership matches `ID.equals` semantics
*/
const RANKING_CARD_EXCLUDED_PROPERTY_IDS = new Set<string>(
[SystemIds.NAME_PROPERTY, SystemIds.DESCRIPTION_PROPERTY, SystemIds.COVER_PROPERTY, ContentIds.AVATAR_PROPERTY].map(
id => ID.uuidToHex(id)
)
);

export type RankingCardImageProperty = 'avatar' | 'cover';

export type RankingCardConfig = {
properties: Property[];
source: Source;
imageProperty: RankingCardImageProperty;
};

export function selectRankingCardProperties(properties: Property[]): Property[] {
return properties.filter(property => !RANKING_CARD_EXCLUDED_PROPERTY_IDS.has(ID.uuidToHex(property.id)));
}

export function selectRankingCardImageProperty(shownColumnIds: string[]): RankingCardImageProperty {
return shownColumnIds.some(id => ID.equals(id, SystemIds.COVER_PROPERTY)) ? 'cover' : 'avatar';
}

/**
* Single definition of a ranking card's config. Callers already hold the data block fields
*/
export function buildRankingCardConfig({
properties,
shownColumnIds,
source,
}: {
properties: Property[];
shownColumnIds: string[];
source: Source;
}): RankingCardConfig {
return {
properties: selectRankingCardProperties(properties),
source,
imageProperty: selectRankingCardImageProperty(shownColumnIds),
};
}

const RankingCardConfigContext = React.createContext<RankingCardConfig>({
properties: [],
source: { type: 'GEO' },
imageProperty: 'avatar',
});

export function RankingCardConfigProvider({
value,
children,
}: {
value: RankingCardConfig;
children: React.ReactNode;
}) {
return <RankingCardConfigContext.Provider value={value}>{children}</RankingCardConfigContext.Provider>;
}

export function useRankingCardConfig(): RankingCardConfig {
return React.useContext(RankingCardConfigContext);
}
23 changes: 17 additions & 6 deletions apps/web/partials/blocks/table/ranking-compose-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type { SearchResult } from '~/core/types';

import { stepAtom } from '~/partials/onboarding/dialog';

import { RankingCardConfigProvider, buildRankingCardConfig } from './ranking-card-config';
import { RankingComposeCreateEntityPanel } from './ranking-compose-create-entity-panel';
import { RankingComposeEntitySheet } from './ranking-compose-entity-sheet';
import { RankingComposeFullscreen } from './ranking-compose-fullscreen';
Expand All @@ -67,11 +68,16 @@ export function RankingComposeScreen({ spaceId, rankingStartDate = '', rankingEn
const searchParams = useSearchParams();
const parentEntityId = searchParams?.get('parentEntityId') ?? '';
const relationId = searchParams?.get('relationId') ?? '';
const { name, entityId, filterState } = useDataBlock();
const { name, entityId, filterState, properties, shownColumnIds, source } = useDataBlock();
const displayName = name?.trim() || 'Untitled ranking';

const createNewSpaceId = React.useMemo(() => resolveRankingSingleTargetSpaceId(filterState), [filterState]);

const cardConfig = React.useMemo(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rebuilds the same card config inline instead of reusing useRankingShownProperties. Works fine, but it could drift from the hook over time if one changes.

() => buildRankingCardConfig({ properties, shownColumnIds, source }),
[properties, shownColumnIds, source]
);
const cardImageProperty = cardConfig.imageProperty;
const { showOnboarding } = useOnboarding();
const composeAccessSpaceId = createNewSpaceId ?? spaceId;
const {
Expand Down Expand Up @@ -189,7 +195,8 @@ export function RankingComposeScreen({ spaceId, rankingStartDate = '', rankingEn

const { entries: rankableEntries, isLoading: isLoadingRankableEntries } = useRankingEntryEntities(
spaceId,
allRankableEntityIds
allRankableEntityIds,
cardImageProperty
);

const [orderedIds, setOrderedIds] = React.useState<string[]>(mySubmission?.orderedEntityIds ?? []);
Expand Down Expand Up @@ -262,7 +269,11 @@ export function RankingComposeScreen({ spaceId, rankingStartDate = '', rankingEn
const filteredRankedIds = isSearchActive ? searchRankedIds : browseRankedIds;
const filteredUnrankedIds = isSearchActive ? searchUnrankedIds : browseUnrankedIds;

const { entries: searchEntries } = useRankingEntryEntities(spaceId, isSearchActive ? searchEntityIds : []);
const { entries: searchEntries } = useRankingEntryEntities(
spaceId,
isSearchActive ? searchEntityIds : [],
cardImageProperty
);

const mySubmissionIdsKey = (mySubmission?.orderedEntityIds ?? []).join('|');

Expand All @@ -278,7 +289,7 @@ export function RankingComposeScreen({ spaceId, rankingStartDate = '', rankingEn

const displayMyEntityIds = orderedIds;

const { entries: myEntries } = useRankingEntryEntities(spaceId, displayMyEntityIds);
const { entries: myEntries } = useRankingEntryEntities(spaceId, displayMyEntityIds, cardImageProperty);
const myEntriesById = React.useMemo(() => new Map(myEntries.map(e => [e.entityId, e])), [myEntries]);

const rankableEntriesByIdRaw = React.useMemo(
Expand Down Expand Up @@ -636,7 +647,7 @@ export function RankingComposeScreen({ spaceId, rankingStartDate = '', rankingEn
}

return (
<>
<RankingCardConfigProvider value={cardConfig}>
<RankingComposeCreateEntityPanel onFinished={addToMyRanking} rankingName={displayName} />
<RankingComposeEntitySheet target={entitySheetTarget} onClose={() => setEntitySheetTarget(null)} />
<RankingComposeFullscreen coverNavbar={isMobile}>
Expand Down Expand Up @@ -681,6 +692,6 @@ export function RankingComposeScreen({ spaceId, rankingStartDate = '', rankingEn
</div>
)}
</RankingComposeFullscreen>
</>
</RankingCardConfigProvider>
);
}
Loading
Loading