diff --git a/README.md b/README.md index 48520fe..a94cf8a 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,17 @@ svelte bsky client, wip ## posts - show some link embeds as their own thing (e.g. youtube, tenor) -- max height for embeds -- show embeds in quoted posts (except second level quotes?) -- allow clicking on videos and images (play/show in fullscreen) +- show fullscreen images on click - fix handles (space between avatar and handle sometimes) - make everything tighter - add action to reply button and repost/quote button - limit title in external embed to 2 lines + +## notifications + +- combine multiple notifications +- show posts inline + +## settings + +- allow setting theme (auto/dark/light) and theme colors (base and accent color) \ No newline at end of file diff --git a/package.json b/package.json index 6daeada..2209039 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,19 @@ }, "license": "MIT", "dependencies": { + "@atcute/bluesky-richtext-segmenter": "^3.0.0", "@atcute/jetstream": "^1.1.2", "@foxui/core": "^0.8.2", "@foxui/social": "^0.8.4", + "@foxui/text": "^0.8.2", "@foxui/time": "^0.8.2", "@lucide/svelte": "^1.7.0", + "@use-gesture/vanilla": "^10.3.1", + "dexie": "^4.3.0", + "emoji-picker-element": "^1.29.1", + "hls.js": "^1.6.15", + "is-emoji-supported": "^0.0.5", + "plyr": "^3.8.4", "valibot": "^1.3.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8d3aca..60a13ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@atcute/bluesky-richtext-segmenter': + specifier: ^3.0.0 + version: 3.0.0 '@atcute/jetstream': specifier: ^1.1.2 version: 1.1.2 @@ -17,12 +20,33 @@ importers: '@foxui/social': specifier: ^0.8.4 version: 0.8.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.2(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.2(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(highlight.js@11.11.1)(svelte@5.55.0)(tailwindcss@4.2.2) + '@foxui/text': + specifier: ^0.8.2 + version: 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.2(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.2(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(highlight.js@11.11.1)(svelte@5.55.0)(tailwindcss@4.2.2) '@foxui/time': specifier: ^0.8.2 version: 0.8.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.2(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.2(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(tailwindcss@4.2.2) '@lucide/svelte': specifier: ^1.7.0 version: 1.7.0(svelte@5.55.0) + '@use-gesture/vanilla': + specifier: ^10.3.1 + version: 10.3.1 + dexie: + specifier: ^4.3.0 + version: 4.3.0 + emoji-picker-element: + specifier: ^1.29.1 + version: 1.29.1 + hls.js: + specifier: ^1.6.15 + version: 1.6.15 + is-emoji-supported: + specifier: ^0.0.5 + version: 0.0.5 + plyr: + specifier: ^3.8.4 + version: 3.8.4 valibot: specifier: ^1.3.1 version: 1.3.1(typescript@6.0.2) @@ -1468,6 +1492,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==, tarball: https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz} + dexie@4.3.0: + resolution: {integrity: sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==, tarball: https://registry.npmjs.org/dexie/-/dexie-4.3.0.tgz} + dompurify@3.3.3: resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==, tarball: https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz} @@ -3645,6 +3672,8 @@ snapshots: dependencies: dequal: 2.0.3 + dexie@4.3.0: {} + dompurify@3.3.3: optionalDependencies: '@types/trusted-types': 2.0.7 diff --git a/src/lib/atproto/auth.svelte.ts b/src/lib/atproto/auth.svelte.ts index 2859236..b45043e 100644 --- a/src/lib/atproto/auth.svelte.ts +++ b/src/lib/atproto/auth.svelte.ts @@ -70,6 +70,14 @@ export async function logout() { console.error('Error logging out:', e); } + // Clear user-specific cached data + try { + const { stateStore, messageStore } = await import('$lib/db.svelte'); + await Promise.all([stateStore.clear(), messageStore.clear()]); + } catch { + // ignore + } + // Full reload to clear server session state window.location.href = '/'; } diff --git a/src/lib/atproto/methods.ts b/src/lib/atproto/methods.ts index 39c7d3a..7d1879e 100644 --- a/src/lib/atproto/methods.ts +++ b/src/lib/atproto/methods.ts @@ -40,18 +40,16 @@ export async function resolveHandle({ handle }: { handle: Handle }) { return data; } -import { identityCache } from '$lib/cache.svelte'; +import { getDid } from '$lib/db.svelte'; /** * Returns a DID given a handle or DID string. */ export async function actorToDid(actor: string): Promise { - if (isDid(actor)) return actor; - const cached = identityCache.get(actor); - if (cached) return cached as Did; - const did = await resolveHandle({ handle: actor as Handle }); - identityCache.set(actor, did); - return did; + return getDid(actor, async (handle) => { + const did = await resolveHandle({ handle: handle as Handle }); + return did; + }); } const didResolver = new CompositeDidDocumentResolver({ diff --git a/src/lib/cache.svelte.ts b/src/lib/cache.svelte.ts index 274139f..2da98e6 100644 --- a/src/lib/cache.svelte.ts +++ b/src/lib/cache.svelte.ts @@ -1,81 +1,95 @@ /** - * Unified client-side cache for the app. - * Persists across navigations within the same session. + * Reactive caches for feed, chat, and notifications. + * Post/profile/identity persistence is handled by $lib/db.svelte.ts (Dexie). + * Bulk reactive state (feed list, convo list, notifications) is persisted via the state store. */ +import { browser } from '$app/environment'; import type { ChatBskyConvoDefs } from '@atcute/bluesky'; import type { AppBskyNotificationListNotifications } from '@atcute/bluesky'; import { getPostThread, loadFeed } from '$lib/atproto/server/feed.remote'; import { listConvos, getMessages } from '$lib/atproto/server/chat.remote'; import { listNotifications, getUnreadCount } from '$lib/atproto/server/notifications.remote'; +import { + postStore, + threadStore, + profileStore, + messageStore, + stateStore, + cachePost, + cachePosts, + cacheProfile +} from '$lib/db.svelte'; type ConvoView = ChatBskyConvoDefs.ConvoView; type Notification = AppBskyNotificationListNotifications.Notification; -// --------------------------------------------------------------------------- -// Identity: handle <-> DID resolution cache -// --------------------------------------------------------------------------- - -export const identityCache = new Map(); +// Re-export db helpers so existing imports still work +export { cachePost, cachePosts, cacheProfile }; // --------------------------------------------------------------------------- -// Profiles: actor (handle or DID) -> profile data +// State persistence helpers // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any -const _profiles = new Map(); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function cacheProfile(profile: any) { - if (profile?.handle) _profiles.set(profile.handle, profile); - if (profile?.did) _profiles.set(profile.did, profile); - if (profile?.handle && profile?.did) { - identityCache.set(profile.handle, profile.did); - } +function saveState(key: string, value: any) { + stateStore.set(key, value).catch(() => {}); } +// --------------------------------------------------------------------------- +// Profiles (async wrappers around db) +// --------------------------------------------------------------------------- + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getCachedProfile(actor: string): any | undefined { - return _profiles.get(actor); +export async function getCachedProfile(actor: string): Promise { + return profileStore.get(actor); } // --------------------------------------------------------------------------- -// Posts: URI -> post view, URI -> thread +// Posts & Threads (async wrappers around db) // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any -const _posts = new Map(); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const _threads = new Map(); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function cachePost(post: any) { - if (!post?.uri) return; - _posts.set(post.uri, post); - if (post.author) cacheProfile(post.author); +export async function getCachedPost(uri: string): Promise { + return postStore.get(uri); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getCachedPost(uri: string): any | undefined { - return _posts.get(uri); +export async function getCachedThread(uri: string): Promise { + return threadStore.get(uri); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getCachedThread(uri: string): any | undefined { - return _threads.get(uri); +export async function getThreadAge(uri: string): Promise { + return threadStore.getAge(uri); } export function prefetchThread(uri: string) { - if (_threads.has(uri)) return; - getPostThread({ uri }) - .then((data) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((data as any).thread?.$type === 'app.bsky.feed.defs#threadViewPost') { + threadStore.get(uri).then((cached) => { + if (cached) return; + getPostThread({ uri }) + .then((data) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - _threads.set(uri, (data as any).thread); - } - }) - .catch(() => {}); + const thread = (data as any).thread; + if (thread?.$type === 'app.bsky.feed.defs#threadViewPost') { + threadStore.set(uri, thread).catch(() => {}); + } + }) + .catch(() => {}); + }).catch(() => {}); +} + +// --------------------------------------------------------------------------- +// Messages (async wrappers around db) +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getCachedMessages(convoId: string): Promise { + return messageStore.get(convoId); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function setCachedMessages(convoId: string, messages: any[]) { + await messageStore.set(convoId, messages); } // --------------------------------------------------------------------------- @@ -90,7 +104,7 @@ let _feedScrollY = $state(0); export const feedCache = { get posts() { return _feedPosts; }, - set posts(v) { _feedPosts = v; }, + set posts(v) { _feedPosts = v; saveState('feed', { posts: v, cursor: _feedCursor }); }, get cursor() { return _feedCursor; }, set cursor(v) { _feedCursor = v; }, get loaded() { return _feedLoaded; }, @@ -116,16 +130,13 @@ export function setFeedUri(uri: string) { async function pollFeed() { if (!_feedUri) return; - console.log('[poll] refreshing feed'); + console.log('[poll] feed'); try { const result = await loadFeed({ feedUri: _feedUri }); _pendingFeedPosts = JSON.parse(JSON.stringify(result.posts)); _pendingFeedCursor = result.cursor; _hasPendingFeed = true; - // Cache post authors for instant profile/DID resolution - for (const fp of _pendingFeedPosts) { - if (fp.post) cachePost(fp.post); - } + cachePosts(_pendingFeedPosts); } catch { // silent } @@ -138,6 +149,10 @@ export function applyPendingFeed() { _feedLoaded = true; _feedScrollY = 0; _hasPendingFeed = false; + saveState('feed', { posts: _feedPosts, cursor: _feedCursor }); + for (const fp of _feedPosts) { + if (fp.post) prefetchThread(fp.post.uri); + } } export function startFeedPoll() { @@ -153,7 +168,7 @@ export function stopFeedPoll() { } // --------------------------------------------------------------------------- -// Chat: reactive convo list + per-convo message cache +// Chat: reactive convo list // --------------------------------------------------------------------------- let _acceptedConvos = $state([]); @@ -162,35 +177,13 @@ let _convoListLoaded = $state(false); export const convoCache = { get acceptedConvos() { return _acceptedConvos; }, - set acceptedConvos(v) { _acceptedConvos = v; }, + set acceptedConvos(v) { _acceptedConvos = v; saveState('convos', { accepted: v, requests: _requestConvos }); }, get requestConvos() { return _requestConvos; }, - set requestConvos(v) { _requestConvos = v; }, + set requestConvos(v) { _requestConvos = v; saveState('convos', { accepted: _acceptedConvos, requests: v }); }, get loaded() { return _convoListLoaded; }, set loaded(v) { _convoListLoaded = v; } }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const _messages = new Map(); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getCachedMessages(convoId: string): any[] | undefined { - return _messages.get(convoId); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function setCachedMessages(convoId: string, messages: any[]) { - _messages.set(convoId, messages); -} - -export function markConvoRead(convoId: string) { - const convo = _acceptedConvos.find((c) => c.id === convoId); - if (convo && convo.unreadCount > 0) { - convo.unreadCount = 0; - _acceptedConvos = [..._acceptedConvos]; // trigger reactivity - updateChatUnreadCount(); - } -} - let _chatPrefetching = false; let _chatPollInterval: ReturnType | null = null; @@ -205,8 +198,17 @@ function updateChatUnreadCount() { _unreadChatCount = _acceptedConvos.reduce((sum, c) => sum + (c.unreadCount ?? 0), 0); } +export function markConvoRead(convoId: string) { + const convo = _acceptedConvos.find((c) => c.id === convoId); + if (convo && convo.unreadCount > 0) { + convo.unreadCount = 0; + _acceptedConvos = [..._acceptedConvos]; + updateChatUnreadCount(); + } +} + async function pollChats() { - console.log('[poll] refreshing chats'); + console.log('[poll] chats'); try { const [accepted, requests] = await Promise.all([ listConvos({ status: 'accepted' }), @@ -216,6 +218,7 @@ async function pollChats() { _requestConvos = requests.convos as ConvoView[]; _convoListLoaded = true; updateChatUnreadCount(); + saveState('convos', { accepted: _acceptedConvos, requests: _requestConvos }); } catch { // silent } @@ -246,12 +249,14 @@ export async function prefetchChats() { _requestConvos = requests.convos as ConvoView[]; _convoListLoaded = true; updateChatUnreadCount(); + saveState('convos', { accepted: _acceptedConvos, requests: _requestConvos }); // Prefetch messages for top 10 accepted convos for (const convo of _acceptedConvos.slice(0, 10)) { - if (_messages.has(convo.id)) continue; + const cached = await getCachedMessages(convo.id); + if (cached) continue; getMessages({ convoId: convo.id }) - .then((res) => _messages.set(convo.id, res.messages)) + .then((res) => setCachedMessages(convo.id, res.messages)) .catch(() => {}); } } catch { @@ -273,7 +278,7 @@ let _seenAt = $state(null); export const notificationsCache = { get notifications() { return _notifications; }, - set notifications(v) { _notifications = v; }, + set notifications(v) { _notifications = v; saveState('notifications', { notifications: v, cursor: _notifCursor, seenAt: _seenAt }); }, get loaded() { return _notifLoaded; }, set loaded(v) { _notifLoaded = v; }, get unreadCount() { return _unreadCount; }, @@ -288,7 +293,7 @@ let _notifPrefetching = false; let _pollInterval: ReturnType | null = null; async function pollUnread() { - console.log('[poll] refreshing notification count'); + console.log('[poll] notifications'); try { const result = await getUnreadCount({}); _unreadCount = result.count; @@ -323,9 +328,48 @@ export async function prefetchNotifications() { _seenAt = notifResult.seenAt; _unreadCount = countResult.count; _notifLoaded = true; + saveState('notifications', { notifications: _notifications, cursor: _notifCursor, seenAt: _seenAt }); } catch { // silent fail } finally { _notifPrefetching = false; } } + +// --------------------------------------------------------------------------- +// Hydrate: restore reactive state from Dexie on app start +// --------------------------------------------------------------------------- + +export async function hydrateFromDb() { + if (!browser) return; + + try { + const [feedState, convosState, notifState] = await Promise.all([ + stateStore.get('feed'), + stateStore.get('convos'), + stateStore.get('notifications') + ]); + + if (feedState?.posts?.length) { + _feedPosts = feedState.posts; + _feedCursor = feedState.cursor ?? null; + _feedLoaded = true; + } + + if (convosState?.accepted?.length || convosState?.requests?.length) { + _acceptedConvos = convosState.accepted ?? []; + _requestConvos = convosState.requests ?? []; + _convoListLoaded = true; + updateChatUnreadCount(); + } + + if (notifState?.notifications?.length) { + _notifications = notifState.notifications; + _notifCursor = notifState.cursor ?? null; + _seenAt = notifState.seenAt ?? null; + _notifLoaded = true; + } + } catch (e) { + console.warn('[cache] Failed to hydrate from db:', e); + } +} diff --git a/src/lib/components/action-buttons/ActionButtons.svelte b/src/lib/components/action-buttons/ActionButtons.svelte index 7c96684..582dfb6 100644 --- a/src/lib/components/action-buttons/ActionButtons.svelte +++ b/src/lib/components/action-buttons/ActionButtons.svelte @@ -19,7 +19,7 @@ {#if reply || repost || like || bookmark || customActions}
diff --git a/src/lib/components/action-buttons/LikeButton.svelte b/src/lib/components/action-buttons/LikeButton.svelte index 3d1c7ee..9584285 100644 --- a/src/lib/components/action-buttons/LikeButton.svelte +++ b/src/lib/components/action-buttons/LikeButton.svelte @@ -11,7 +11,7 @@ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" - class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-500 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" + class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-500 -m-1 size-6 rounded-full p-1 transition-all duration-100" > diff --git a/src/lib/components/action-buttons/ReplyButton.svelte b/src/lib/components/action-buttons/ReplyButton.svelte index 1a0d61f..c3021ed 100644 --- a/src/lib/components/action-buttons/ReplyButton.svelte +++ b/src/lib/components/action-buttons/ReplyButton.svelte @@ -12,7 +12,7 @@ viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" - class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1 size-6 rounded-full p-1 transition-all duration-100" > {@render icon()} {:else if href} {@render icon()} {:else} - + {@render icon()} {/if} diff --git a/src/lib/components/action-buttons/RepostButton.svelte b/src/lib/components/action-buttons/RepostButton.svelte index 69fdcc8..cfeaf9a 100644 --- a/src/lib/components/action-buttons/RepostButton.svelte +++ b/src/lib/components/action-buttons/RepostButton.svelte @@ -12,7 +12,7 @@ viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" - class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1 size-6 rounded-full p-1 transition-all duration-100" > {@render icon()} {:else if href} {@render icon()} {:else} - + {@render icon()} {/if} diff --git a/src/lib/components/embed/Embed.svelte b/src/lib/components/embed/Embed.svelte index f54df8e..235fa5d 100644 --- a/src/lib/components/embed/Embed.svelte +++ b/src/lib/components/embed/Embed.svelte @@ -14,7 +14,7 @@ } = $props(); -
+
{#if embed.type === 'images'} {:else if embed.type === 'external' && embed.external} diff --git a/src/lib/components/embed/Image.svelte b/src/lib/components/embed/Image.svelte index dc29569..46e3f35 100644 --- a/src/lib/components/embed/Image.svelte +++ b/src/lib/components/embed/Image.svelte @@ -7,26 +7,23 @@ class: className }: { image: ImageData; - onclick?: (image: ImageData) => void; + onclick?: () => void; class?: string; } = $props(); + + const imgClass = $derived([ + 'border-base-500/20 dark:border-base-400/20 accent:border-accent-900 max-h-96 max-w-full rounded-2xl border', + className + ]); {#if onclick} - {:else} @@ -34,14 +31,6 @@ loading="lazy" src={image.thumb} alt={image.alt} - width={image.aspectRatio?.width} - height={image.aspectRatio?.height} - style={image.aspectRatio - ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` - : 'aspect-ratio: 1 / 1'} - class={[ - 'border-base-500/20 dark:border-base-400/20 accent:border-accent-900 max-h-160 w-full max-w-full rounded-2xl border object-cover', - className - ]} + class={imgClass} /> {/if} diff --git a/src/lib/components/embed/ImageLightbox.svelte b/src/lib/components/embed/ImageLightbox.svelte new file mode 100644 index 0000000..3241cd5 --- /dev/null +++ b/src/lib/components/embed/ImageLightbox.svelte @@ -0,0 +1,87 @@ + + + + + + +{#if lightboxState.open && image} + + +{/if} diff --git a/src/lib/components/embed/Images.svelte b/src/lib/components/embed/Images.svelte index c30853f..8683937 100644 --- a/src/lib/components/embed/Images.svelte +++ b/src/lib/components/embed/Images.svelte @@ -1,6 +1,7 @@ {#if record.onclick} - +
{:else}
{/if} diff --git a/src/lib/components/embed/Video.svelte b/src/lib/components/embed/Video.svelte index b8f0d66..353dc57 100644 --- a/src/lib/components/embed/Video.svelte +++ b/src/lib/components/embed/Video.svelte @@ -38,7 +38,7 @@ } new Plyr(element, { - controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'], + controls: ['play-large', 'progress', 'current-time', 'mute', 'fullscreen'], ratio: data.video.aspectRatio ? `${data.video.aspectRatio.width}:${data.video.aspectRatio.height}` : '16:9' @@ -47,7 +47,7 @@ element.src = src; const { default: Plyr } = await import('plyr'); new Plyr(element, { - controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'], + controls: ['play-large', 'progress', 'current-time', 'mute', 'fullscreen'], ratio: data.video.aspectRatio ? `${data.video.aspectRatio.width}:${data.video.aspectRatio.height}` : '16:9' @@ -78,10 +78,7 @@ {:else}
@@ -92,4 +89,7 @@ * { --plyr-color-main: var(--color-accent-500); } + div :global(.plyr) { + max-height: 24rem; + } diff --git a/src/lib/components/embed/index.ts b/src/lib/components/embed/index.ts index f7000f7..7027a26 100644 --- a/src/lib/components/embed/index.ts +++ b/src/lib/components/embed/index.ts @@ -15,3 +15,32 @@ export { default as EmbedExternal } from './External.svelte'; export { default as EmbedVideo } from './Video.svelte'; export { default as EmbedQuotedPost } from './QuotedPost.svelte'; export { default as EmbedRouter } from './Embed.svelte'; + +import type { Embed } from './types'; + +/** + * Attaches onclick/onclickhandle callbacks to record embeds (quoted posts). + * `navigate` is called with (handle, rkey) extracted from the quoted post's href. + * `navigateToProfile` is called with a handle. + */ +export function wireEmbedClicks( + embeds: Embed[], + navigate: (handle: string, rkey: string) => void, + navigateToProfile: (handle: string) => void +): Embed[] { + for (const embed of embeds) { + if (embed.type === 'record' && embed.record) { + embed.record.onclick = (_data, href) => { + if (href) { + const parts = href.split('/'); + const handle = parts[parts.length - 3]; + const rkey = parts[parts.length - 1]; + navigate(handle, rkey); + } + }; + embed.record.onclickhandle = (handle) => navigateToProfile(handle); + embed.record.handleHref = (handle) => `/p/${handle}`; + } + } + return embeds; +} diff --git a/src/lib/components/embed/types.ts b/src/lib/components/embed/types.ts index eb7e9ea..746b682 100644 --- a/src/lib/components/embed/types.ts +++ b/src/lib/components/embed/types.ts @@ -52,6 +52,7 @@ export type EmbedRecordData = { embed?: Embed; onclick?: (data: EmbedRecordData, href?: string) => void; onclickhandle?: (handle: string, href?: string) => void; + handleHref?: (handle: string) => string; }; export type EmbedRecord = { diff --git a/src/lib/components/post/Post.svelte b/src/lib/components/post/Post.svelte index a7a3e17..8763751 100644 --- a/src/lib/components/post/Post.svelte +++ b/src/lib/components/post/Post.svelte @@ -17,7 +17,9 @@ actions, + href, onclickhandle, + handleHref, onclickavatar, timestamp, @@ -35,10 +37,14 @@
+ {#if href} + + {/if} + {#if data.reposted} -
+
reposted by {#if onclickhandle} - + {:else} {/if} {#if data.replyTo} -
+
replying to {#if onclickhandle} - + {:else} {#if showAvatar} {@const avatarClass = compact ? 'size-7' : 'size-10'} - {#if onclickavatar} - - {:else if onclickhandle} - - {:else} - - - - {/if} +
+ {#if onclickavatar} + + {:else if onclickhandle} + { e.preventDefault(); onclickhandle(data.author.handle, data.author.href); }}> + + + {:else} + + + + {/if} +
{/if}
- - -
+
+ +
+ +
{#if data.htmlContent} {@html sanitize(data.htmlContent, { ADD_ATTR: ['target'] })} {:else} @@ -149,22 +165,26 @@
{#if embeds?.length} - {#each embeds as embed} - - {/each} +
+ {#each embeds as embed} + + {/each} +
{/if} {#if extraEmbeds} -
+
{@render extraEmbeds()}
{/if} {#if actions} - +
+ +
{/if}
@@ -173,6 +193,8 @@