Skip to content

Commit c4a9cbb

Browse files
committed
tweaks
1 parent 058b630 commit c4a9cbb

10 files changed

Lines changed: 397 additions & 30 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<script lang="ts">
2+
import { createEventDispatcher } from 'svelte';
3+
4+
export let url = '';
5+
export let alt = '';
6+
export let referrerpolicy: ReferrerPolicy | undefined = undefined;
7+
8+
const dispatch = createEventDispatcher<{ close: void }>();
9+
const minScale = 1;
10+
const maxScale = 5;
11+
const tapMovementThreshold = 8;
12+
13+
let viewerElement: HTMLDivElement;
14+
let scale = 1;
15+
let translateX = 0;
16+
let translateY = 0;
17+
let lastUrl = '';
18+
let lastDistance = 0;
19+
let lastCenter = { x: 0, y: 0 };
20+
let tapStart = { x: 0, y: 0 };
21+
let moved = false;
22+
let pointers = new Map<number, { x: number; y: number }>();
23+
24+
$: if (url && url !== lastUrl) {
25+
lastUrl = url;
26+
resetZoom();
27+
}
28+
29+
$: imageTransform = `translate3d(${translateX}px, ${translateY}px, 0) scale(${scale})`;
30+
31+
function close() {
32+
dispatch('close');
33+
}
34+
35+
function resetZoom() {
36+
scale = 1;
37+
translateX = 0;
38+
translateY = 0;
39+
lastDistance = 0;
40+
lastCenter = { x: 0, y: 0 };
41+
pointers = new Map();
42+
moved = false;
43+
}
44+
45+
function pointerDown(event: PointerEvent) {
46+
viewerElement?.setPointerCapture?.(event.pointerId);
47+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
48+
moved = false;
49+
50+
if (pointers.size === 1) {
51+
tapStart = { x: event.clientX, y: event.clientY };
52+
} else if (pointers.size === 2) {
53+
event.preventDefault();
54+
lastDistance = pointerDistance();
55+
lastCenter = pointerCenter();
56+
}
57+
}
58+
59+
function pointerMove(event: PointerEvent) {
60+
if (!pointers.has(event.pointerId)) return;
61+
const previous = pointers.get(event.pointerId) ?? { x: event.clientX, y: event.clientY };
62+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
63+
64+
if (Math.hypot(event.clientX - tapStart.x, event.clientY - tapStart.y) > tapMovementThreshold) moved = true;
65+
66+
if (pointers.size >= 2) {
67+
event.preventDefault();
68+
const nextDistance = pointerDistance();
69+
const nextCenter = pointerCenter();
70+
if (lastDistance > 0) zoomAround(nextCenter, scale * (nextDistance / lastDistance));
71+
translateX += nextCenter.x - lastCenter.x;
72+
translateY += nextCenter.y - lastCenter.y;
73+
constrainPan();
74+
lastDistance = nextDistance;
75+
lastCenter = nextCenter;
76+
return;
77+
}
78+
79+
if (scale > minScale) {
80+
event.preventDefault();
81+
translateX += event.clientX - previous.x;
82+
translateY += event.clientY - previous.y;
83+
constrainPan();
84+
}
85+
}
86+
87+
function pointerUp(event: PointerEvent) {
88+
viewerElement?.releasePointerCapture?.(event.pointerId);
89+
const wasTap = !moved && pointers.size === 1 && Math.hypot(event.clientX - tapStart.x, event.clientY - tapStart.y) <= tapMovementThreshold;
90+
pointers.delete(event.pointerId);
91+
92+
if (pointers.size < 2) {
93+
lastDistance = 0;
94+
lastCenter = { x: 0, y: 0 };
95+
}
96+
97+
if (scale <= minScale) {
98+
resetZoom();
99+
if (wasTap) close();
100+
}
101+
}
102+
103+
function pointerCancel(event: PointerEvent) {
104+
viewerElement?.releasePointerCapture?.(event.pointerId);
105+
pointers.delete(event.pointerId);
106+
if (pointers.size < 2) lastDistance = 0;
107+
}
108+
109+
function wheelZoom(event: WheelEvent) {
110+
if (!event.ctrlKey && Math.abs(event.deltaY) < 20) return;
111+
event.preventDefault();
112+
const zoomFactor = event.deltaY < 0 ? 1.12 : 0.88;
113+
zoomAround({ x: event.clientX, y: event.clientY }, scale * zoomFactor);
114+
constrainPan();
115+
}
116+
117+
function zoomAround(point: { x: number; y: number }, requestedScale: number) {
118+
const nextScale = clamp(requestedScale, minScale, maxScale);
119+
if (!viewerElement || nextScale === scale) return;
120+
const rect = viewerElement.getBoundingClientRect();
121+
const originX = point.x - rect.left - rect.width / 2;
122+
const originY = point.y - rect.top - rect.height / 2;
123+
const ratio = nextScale / scale;
124+
translateX = originX - (originX - translateX) * ratio;
125+
translateY = originY - (originY - translateY) * ratio;
126+
scale = nextScale;
127+
if (scale <= minScale) resetZoom();
128+
}
129+
130+
function pointerDistance() {
131+
const [first, second] = [...pointers.values()];
132+
if (!first || !second) return 0;
133+
return Math.hypot(second.x - first.x, second.y - first.y);
134+
}
135+
136+
function pointerCenter() {
137+
const [first, second] = [...pointers.values()];
138+
if (!first || !second) return { x: first?.x ?? 0, y: first?.y ?? 0 };
139+
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
140+
}
141+
142+
function constrainPan() {
143+
if (!viewerElement || scale <= minScale) return;
144+
const rect = viewerElement.getBoundingClientRect();
145+
const maxX = (rect.width * (scale - 1)) / 2 + 80;
146+
const maxY = (rect.height * (scale - 1)) / 2 + 80;
147+
translateX = clamp(translateX, -maxX, maxX);
148+
translateY = clamp(translateY, -maxY, maxY);
149+
}
150+
151+
function clamp(value: number, min: number, max: number) {
152+
return Math.min(max, Math.max(min, value));
153+
}
154+
</script>
155+
156+
<div class="dialog-backdrop image-viewer-backdrop" role="presentation" tabindex="-1" on:click={(event) => event.target === event.currentTarget && close()}>
157+
<div
158+
bind:this={viewerElement}
159+
class="image-viewer"
160+
class:zoomed={scale > 1}
161+
role="dialog"
162+
aria-modal="true"
163+
aria-label="Image preview"
164+
tabindex="0"
165+
on:pointerdown={pointerDown}
166+
on:pointermove={pointerMove}
167+
on:pointerup={pointerUp}
168+
on:pointercancel={pointerCancel}
169+
on:wheel={wheelZoom}
170+
on:keydown={(event) => event.key === 'Escape' && close()}
171+
>
172+
<img src={url} alt={alt} {referrerpolicy} style:transform={imageTransform} draggable="false" />
173+
</div>
174+
</div>

src/lib/components/MessagesView.svelte

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import { appPath } from '$lib/paths';
2525
import { findOrCreatePomegranateConnection } from '$lib/nostr/pomegranateAuth';
2626
import type { DirectMessage, MediaAttachment, NostrEvent, Profile } from '$lib/nostr/types';
27+
import ImageViewer from './ImageViewer.svelte';
2728
import QuotedNotePreview from './QuotedNotePreview.svelte';
2829
import VideoAttachment from './VideoAttachment.svelte';
2930
@@ -519,11 +520,7 @@
519520
{/if}
520521

521522
{#if openImage}
522-
<div class="dialog-backdrop image-viewer-backdrop" role="presentation" tabindex="-1" on:click={(event) => event.target === event.currentTarget && (openImage = null)}>
523-
<button class="image-viewer" on:click={() => (openImage = null)} aria-label="Close image">
524-
<img src={openImage.url} alt={openImage.alt ?? ''} />
525-
</button>
526-
</div>
523+
<ImageViewer url={openImage.url} alt={openImage.alt ?? ''} referrerpolicy="no-referrer" on:close={() => (openImage = null)} />
527524
{/if}
528525

529526
{#if chatLoginDialogOpen}

src/lib/components/NoteCard.svelte

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { saveRouteScrollState } from '$lib/stores/routeScroll';
1616
import { saveThreadReturnTarget, currentThreadReturnTarget } from '$lib/stores/threadNavigation';
1717
import { saveThreadSeed } from '$lib/stores/threadSeed';
18+
import ImageViewer from './ImageViewer.svelte';
1819
import QuotedNotePreview from './QuotedNotePreview.svelte';
1920
import VideoAttachment from './VideoAttachment.svelte';
2021
@@ -699,17 +700,5 @@
699700
{/if}
700701

701702
{#if openImage}
702-
<div class="dialog-backdrop image-viewer-backdrop" role="presentation" tabindex="-1" on:click={(event) => event.target === event.currentTarget && (openImage = null)}>
703-
<div
704-
class="image-viewer"
705-
role="dialog"
706-
aria-modal="true"
707-
aria-label="Image preview"
708-
tabindex="0"
709-
on:click={() => (openImage = null)}
710-
on:keydown={(event) => ['Enter', ' ', 'Escape'].includes(event.key) && (openImage = null)}
711-
>
712-
<img src={openImage.url} alt={openImage.alt ?? ''} referrerpolicy="no-referrer" />
713-
</div>
714-
</div>
703+
<ImageViewer url={openImage.url} alt={openImage.alt ?? ''} referrerpolicy="no-referrer" on:close={() => (openImage = null)} />
715704
{/if}

src/lib/nostr/cache.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import {
22
cacheDirectMessages,
33
cacheEvents,
4+
cacheEventStats,
45
cacheProfile,
56
cacheProfileEvents,
67
getCachedDirectMessages,
78
getCachedEvents,
9+
getCachedEventStats,
810
getCachedHashtagEvents,
911
getCachedProfileEvents,
1012
getCachedProfiles,
1113
resetCacheConnectionForTests
1214
} from './cache';
13-
import type { DirectMessage, NostrEvent } from './types';
15+
import type { DirectMessage, EventStats, NostrEvent } from './types';
1416

1517
function event(id: string, created_at: number, pubkey = 'a'.repeat(64), content = `event ${id}`, tags: string[][] = []): NostrEvent {
1618
return {
@@ -37,6 +39,19 @@ function dm(id: string, created_at: number, peer = 'b'.repeat(64)): DirectMessag
3739
};
3840
}
3941

42+
function stats(overrides: Partial<EventStats> = {}): EventStats {
43+
return {
44+
replies: 0,
45+
reposts: 0,
46+
likes: 0,
47+
zaps: 0,
48+
zapSats: 0,
49+
dislikes: 0,
50+
emoji: 0,
51+
...overrides
52+
};
53+
}
54+
4055
async function deleteDb() {
4156
await resetCacheConnectionForTests();
4257
await new Promise<void>((resolve, reject) => {
@@ -118,6 +133,31 @@ describe('IndexedDB cache', () => {
118133
expect((await getCachedHashtagEvents('topic5')).map((item) => item.id)).toEqual(['tagged-5']);
119134
});
120135

136+
it('stores event stats snapshots by event id', async () => {
137+
await cacheEventStats({
138+
['a'.repeat(64)]: stats({ replies: 2, likes: 5 }),
139+
['b'.repeat(64)]: stats({ reposts: 3, zapSats: 21 })
140+
});
141+
142+
expect(await getCachedEventStats(['b'.repeat(64), 'a'.repeat(64), 'c'.repeat(64)])).toEqual({
143+
['a'.repeat(64)]: stats({ replies: 2, likes: 5 }),
144+
['b'.repeat(64)]: stats({ reposts: 3, zapSats: 21 })
145+
});
146+
});
147+
148+
it('caps event stats snapshots independently of cached events', async () => {
149+
const nextStats: Record<string, EventStats> = {};
150+
Array.from({ length: 3005 }, (_, index) => {
151+
nextStats[index.toString(16).padStart(64, '0')] = stats({ likes: index });
152+
});
153+
154+
await cacheEventStats(nextStats);
155+
156+
const cached = await getCachedEventStats(Object.keys(nextStats));
157+
expect(Object.keys(cached)).toHaveLength(3000);
158+
expect(cached['0'.repeat(63) + '0']).toBeUndefined();
159+
}, 10_000);
160+
121161
it('caches direct messages per account and returns newest first', async () => {
122162
const alice = 'a'.repeat(64);
123163
const bob = 'b'.repeat(64);

src/lib/nostr/cache.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type { DirectMessage, NostrEvent, NotificationItem, Profile } from './types';
1+
import type { DirectMessage, EventStats, NostrEvent, NotificationItem, Profile } from './types';
22

33
const DB_NAME = 'nostr-social-cache';
4-
const DB_VERSION = 6;
4+
const DB_VERSION = 7;
55
const MAX_CACHED_EVENTS = 2400;
66
const MAX_CACHED_PROFILE_EVENTS = 1200;
77
const MAX_CACHED_HASHTAG_EVENTS = 1200;
8+
const MAX_CACHED_EVENT_STATS = 3000;
89
const MAX_CACHED_DIRECT_MESSAGES_PER_OWNER = 500;
910
const MAX_CACHED_NOTIFICATIONS_PER_OWNER = 240;
1011

@@ -36,6 +37,12 @@ interface CachedNotification {
3637
notification: NotificationItem;
3738
}
3839

40+
interface CachedEventStats {
41+
id: string;
42+
updated_at: number;
43+
stats: EventStats;
44+
}
45+
3946
let dbPromise: Promise<IDBDatabase> | undefined;
4047

4148
function openDb() {
@@ -65,6 +72,10 @@ function openDb() {
6572
if (!db.objectStoreNames.contains('hashtagEvents')) {
6673
createHashtagEventsStore(db);
6774
}
75+
if (!db.objectStoreNames.contains('eventStats')) {
76+
const eventStats = db.createObjectStore('eventStats', { keyPath: 'id' });
77+
eventStats.createIndex('updated_at', 'updated_at');
78+
}
6879
if (!db.objectStoreNames.contains('contacts')) db.createObjectStore('contacts', { keyPath: 'pubkey' });
6980
if (!db.objectStoreNames.contains('settings')) db.createObjectStore('settings', { keyPath: 'key' });
7081
if (!db.objectStoreNames.contains('directMessages')) {
@@ -127,10 +138,53 @@ export async function cacheProfileEvents(events: NostrEvent[]) {
127138
}).catch(() => undefined);
128139
}
129140

141+
export async function cacheEventStats(statsById: Record<string, EventStats>) {
142+
const entries = Object.entries(statsById).filter(([id]) => /^[0-9a-f]{64}$/i.test(id));
143+
if (!entries.length) return;
144+
const updated_at = Date.now();
145+
await withStore<void>('eventStats', 'readwrite', (store) => {
146+
entries.forEach(([id, stats]) =>
147+
store.put({
148+
id,
149+
updated_at,
150+
stats
151+
} satisfies CachedEventStats)
152+
);
153+
pruneOldEventStats(store, MAX_CACHED_EVENT_STATS);
154+
}).catch(() => undefined);
155+
}
156+
157+
export async function getCachedEventStats(ids: string[]) {
158+
const uniqueIds = [...new Set(ids)].filter((id) => /^[0-9a-f]{64}$/i.test(id));
159+
if (!uniqueIds.length) return {};
160+
return withStore<Record<string, EventStats>>('eventStats', 'readonly', (store) => {
161+
const stats: Record<string, EventStats> = {};
162+
let remaining = uniqueIds.length;
163+
const finish = () => {
164+
remaining -= 1;
165+
if (remaining <= 0) (store as IDBObjectStore & { __setResult(value: Record<string, EventStats>): void }).__setResult(stats);
166+
};
167+
168+
uniqueIds.forEach((id) => {
169+
const request = store.get(id);
170+
request.onsuccess = () => {
171+
const cached = request.result as CachedEventStats | undefined;
172+
if (cached?.stats) stats[id] = cached.stats;
173+
finish();
174+
};
175+
request.onerror = () => finish();
176+
});
177+
}).catch(() => ({}));
178+
}
179+
130180
function pruneOldEvents(store: IDBObjectStore, maxEvents: number) {
131181
pruneOverflow(store.index('created_at'), maxEvents);
132182
}
133183

184+
function pruneOldEventStats(store: IDBObjectStore, maxStats: number) {
185+
pruneOverflow(store.index('updated_at'), maxStats);
186+
}
187+
134188
function pruneOldProfileEvents(store: IDBObjectStore, pubkey: string, maxEvents: number) {
135189
const range = IDBKeyRange.bound([pubkey, 0], [pubkey, Number.MAX_SAFE_INTEGER]);
136190
pruneOverflow(store.index('pubkey_created_at'), maxEvents, range);
@@ -398,6 +452,7 @@ export async function clearEventCache() {
398452
withStore<void>('events', 'readwrite', (store) => store.clear()),
399453
withStore<void>('profileEvents', 'readwrite', (store) => store.clear()),
400454
withStore<void>('hashtagEvents', 'readwrite', (store) => store.clear()),
455+
withStore<void>('eventStats', 'readwrite', (store) => store.clear()),
401456
withStore<void>('notifications', 'readwrite', (store) => store.clear())
402457
]).catch(() => undefined);
403458
}

0 commit comments

Comments
 (0)