feat(map): show the user's own Nostr avatar on the "you are here" pin

The live-location marker used the SkateSpots brand logo. It now uses the
logged-in user's Nostr profile picture (their npub's logo), falling back to the
brand logo when there is none.

- SpotMap takes an optional userPicture; createUserLocationIcon layers the
  avatar over the brand logo (CSS multiple backgrounds) so a broken/expired
  avatar URL just reveals the logo — no inline JS, CSP-safe.
- The icon refreshes when the picture loads (or changes) without moving the pin.
- MapPage passes the current user's metadata picture.
This commit is contained in:
SkateSpots 2026-10-03 16:23:25 +07:00
parent 7aeda11041
commit e1a2b91241
2 changed files with 27 additions and 7 deletions

View file

@ -193,11 +193,22 @@ const createShopIcon = () => L.divIcon({
// "You are here" user-location marker — SkateSpots logo in a circular badge // "You are here" user-location marker — SkateSpots logo in a circular badge
// with a pulsing accuracy halo (Google-Maps-style, brand-flavored). // with a pulsing accuracy halo (Google-Maps-style, brand-flavored).
const createUserLocationIcon = () => { const createUserLocationIcon = (picture?: string | null) => {
// BASE_URL resolves correctly for both web ('/') and the Capacitor APK ('./'). // BASE_URL resolves correctly for both web ('/') and the Capacitor APK ('./').
// Reuse the small (~24 KB) brand logo for the marker instead of the old // Reuse the small (~24 KB) brand logo for the marker instead of the old
// 2.9 MB user-location.png — same artwork, ~99% smaller payload. // 2.9 MB user-location.png — same artwork, ~99% smaller payload.
const logoUrl = `${import.meta.env.BASE_URL}images/logo.png`; const logoUrl = `${import.meta.env.BASE_URL}images/logo.png`;
// Show the user's own Nostr profile picture (their npub's "logo") when set.
// Layered CSS backgrounds mean a broken/expired avatar URL silently reveals
// the brand logo underneath — no inline JS (CSP-safe).
const safeAvatar = picture ? sanitizeUrl(picture) : null;
const avatarCss = safeAvatar
? encodeURI(safeAvatar).replace(/[()'"]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
: '';
const backgroundImage = avatarCss ? `url('${avatarCss}'), url('${logoUrl}')` : `url('${logoUrl}')`;
const backgroundSize = avatarCss ? 'cover, cover' : 'cover';
return L.divIcon({ return L.divIcon({
className: 'user-location-marker', className: 'user-location-marker',
html: ` html: `
@ -212,15 +223,15 @@ const createUserLocationIcon = () => {
background: rgba(255,0,64,0.25); background: rgba(255,0,64,0.25);
animation: ssPulse 2s ease-out infinite; animation: ssPulse 2s ease-out infinite;
"></span> "></span>
<!-- logo badge --> <!-- avatar / logo badge -->
<div style=" <div style="
position: absolute; position: absolute;
left: 50%; top: 50%; left: 50%; top: 50%;
width: 40px; height: 40px; width: 40px; height: 40px;
margin: -20px 0 0 -20px; margin: -20px 0 0 -20px;
border-radius: 50%; border-radius: 50%;
background-image: url('${logoUrl}'); background-image: ${backgroundImage};
background-size: cover; background-size: ${backgroundSize};
background-position: center; background-position: center;
background-color: #fff; background-color: #fff;
border: 3px solid #ff0040; border: 3px solid #ff0040;
@ -527,6 +538,8 @@ interface SpotMapProps {
flyTo?: { lat: number; lng: number; zoom?: number } | null; flyTo?: { lat: number; lng: number; zoom?: number } | null;
/** The current user's location — renders a "you are here" pin when set. */ /** The current user's location — renders a "you are here" pin when set. */
userLocation?: { lat: number; lng: number } | null; userLocation?: { lat: number; lng: number } | null;
/** The current user's Nostr profile picture, used on the "you are here" pin. */
userPicture?: string | null;
} }
function createPopupContent( function createPopupContent(
@ -793,6 +806,7 @@ export function SpotMap({
showBtcMap = false, showBtcMap = false,
flyTo = null, flyTo = null,
userLocation = null, userLocation = null,
userPicture = null,
}: SpotMapProps) { }: SpotMapProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
@ -1041,12 +1055,14 @@ export function SpotMap({
} }
if (userMarkerRef.current) { if (userMarkerRef.current) {
// Move the existing marker to the new position. // Move the existing marker to the new position and refresh its icon
// (the profile picture can arrive after the marker was first created).
userMarkerRef.current.setLatLng([userLocation.lat, userLocation.lng]); userMarkerRef.current.setLatLng([userLocation.lat, userLocation.lng]);
userMarkerRef.current.setIcon(createUserLocationIcon(userPicture));
} else { } else {
// Create it, on top of everything else. // Create it, on top of everything else.
userMarkerRef.current = L.marker([userLocation.lat, userLocation.lng], { userMarkerRef.current = L.marker([userLocation.lat, userLocation.lng], {
icon: createUserLocationIcon(), icon: createUserLocationIcon(userPicture),
zIndexOffset: 1000, zIndexOffset: 1000,
interactive: true, interactive: true,
keyboard: false, keyboard: false,
@ -1054,7 +1070,7 @@ export function SpotMap({
.addTo(map) .addTo(map)
.bindPopup(`<b>${escapeHtml(t('mapUi.youAreHere'))}</b>`); .bindPopup(`<b>${escapeHtml(t('mapUi.youAreHere'))}</b>`);
} }
}, [userLocation, t]); }, [userLocation, userPicture, t]);
// Update markers // Update markers
useEffect(() => { useEffect(() => {

View file

@ -541,6 +541,9 @@ export default function MapPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
// The user's own profile picture, shown on the "you are here" pin.
const meAuthor = useAuthor(user?.pubkey || '');
const userPicture = meAuthor.data?.metadata?.picture ?? null;
const { data: followingList } = useFollowing(user?.pubkey); const { data: followingList } = useFollowing(user?.pubkey);
const followCount = followingList?.length ?? 0; const followCount = followingList?.length ?? 0;
const { data: spots, isLoading: spotsLoading, refetch: refetchSpots } = useSkateSpots(2000); const { data: spots, isLoading: spotsLoading, refetch: refetchSpots } = useSkateSpots(2000);
@ -1674,6 +1677,7 @@ export default function MapPage() {
showBtcMap={showBtcMap} showBtcMap={showBtcMap}
flyTo={flyTo} flyTo={flyTo}
userLocation={isMobile && userLocation ? { lat: userLocation.lat, lng: userLocation.lon } : null} userLocation={isMobile && userLocation ? { lat: userLocation.lat, lng: userLocation.lon } : null}
userPicture={userPicture}
/> />
</Suspense> </Suspense>
{/* Loading indicator — a slim, honest top bar. The map stays {/* Loading indicator — a slim, honest top bar. The map stays