diff --git a/ci.json b/ci.json
index da85f79..d427d93 100644
--- a/ci.json
+++ b/ci.json
@@ -34,6 +34,13 @@
"source /home/ubuntu1/.nvm/nvm.sh && npm test"
]
},
+ {
+ "type": "putvarfile",
+ "id": "8472070d-c18a-4308-b804-0a4dac9e3510",
+ "files": [
+ "src/hooks/useAndroidBackButton.ts"
+ ]
+ },
{
"type": "cmd",
"id": "816eb02b-f211-4dd4-a62f-7f4b71a60f82",
@@ -113,6 +120,13 @@
"export JAVA_HOME=/tmp/jdk21 && export PATH=$JAVA_HOME/bin:$PATH && if [ -n \"${KEYSTORE_BASE64}\" ]; then echo \"${KEYSTORE_BASE64}\" | tr -d '\\n\\r ' | base64 -d > /tmp/release.keystore && mkdir -p public/.well-known && /tmp/jdk21/bin/keytool -list -v -keystore /tmp/release.keystore -alias ${KEYSTORE_ALIAS:-upload} -storepass \"${KEYSTORE_PASSWORD}\" 2>/dev/null | grep -oP 'SHA256: \\K[A-Fa-f0-9:]+' | head -1 > /tmp/sha256_fingerprint.txt && FINGERPRINT=$(cat /tmp/sha256_fingerprint.txt) && echo \"SHA-256 fingerprint: ${FINGERPRINT}\" && echo '[{\"relation\":[\"delegate_permission/common.handle_all_urls\"],\"target\":{\"namespace\":\"android_app\",\"package_name\":\"foxsky.jakefox.de\",\"sha256_cert_fingerprints\":[\"'\"${FINGERPRINT}\"'\"]}}]' > public/.well-known/assetlinks.json && echo \"Generated public/.well-known/assetlinks.json\" && cat public/.well-known/assetlinks.json; else echo 'No KEYSTORE_BASE64 - skipping assetlinks.json generation'; fi"
]
},
+ {
+ "type": "putvarfile",
+ "id": "a1b2c3d4-e5f6-4a78-b9c0-d1e2f3a4b5c8",
+ "files": [
+ "src/hooks/useAndroidBackButton.ts"
+ ]
+ },
{
"type": "cmd",
"id": "a2b3c4d5-e6f7-4a89-b0c1-d2e3f4a5b6c7",
diff --git a/og-server/lib/render.ts b/og-server/lib/render.ts
index 65752f2..d97b8ff 100644
--- a/og-server/lib/render.ts
+++ b/og-server/lib/render.ts
@@ -1,8 +1,16 @@
-/**
- * OG HTML renderer.
- * Generates HTML with OpenGraph / Twitter Card meta tags for social crawlers,
- * and includes a tiny JS redirect for regular browsers.
- */
+export interface OgImage {
+ url: string;
+ alt?: string;
+ width?: number;
+ height?: number;
+}
+
+export interface OgVideo {
+ url: string;
+ mimeType?: string;
+ width?: number;
+ height?: number;
+}
export interface OgData {
url: string;
@@ -10,6 +18,8 @@ export interface OgData {
description: string;
image?: string;
imageAlt?: string;
+ images?: OgImage[];
+ video?: OgVideo;
siteName?: string;
type?: 'website' | 'article' | 'profile';
}
@@ -17,14 +27,37 @@ export interface OgData {
export function renderOgHtml(data: OgData): string {
const siteName = data.siteName ?? 'Foxsky';
const type = data.type ?? 'website';
- const imageMeta = data.image
- ? `
-
-
-
- `
+
+ const allImages: OgImage[] = data.images?.length
+ ? data.images
+ : data.image
+ ? [{ url: data.image, alt: data.imageAlt }]
+ : [];
+
+ const imageMeta = allImages
+ .map((img) => {
+ let tag = ` `;
+ if (img.alt) tag += `\n `;
+ if (img.width) tag += `\n `;
+ if (img.height) tag += `\n `;
+ return tag;
+ })
+ .join('\n');
+
+ const twitterImage = allImages[0]
+ ? `\n \n `
: '';
+ const videoMeta = data.video
+ ? ` \n \n ${
+ data.video.width ? `\n ` : ''
+ }${data.video.height ? `\n ` : ''}\n \n \n `
+ : '';
+
+ let twitterCard = 'summary';
+ if (data.video) twitterCard = 'player';
+ else if (allImages.length > 0) twitterCard = 'summary_large_image';
+
return `
@@ -36,8 +69,9 @@ export function renderOgHtml(data: OgData): string {
- ${imageMeta}
-
+
+${imageMeta}${twitterImage ? '\n' + twitterImage : ''}${videoMeta ? '\n' + videoMeta : ''}
+
diff --git a/og-server/lib/resolver.ts b/og-server/lib/resolver.ts
index 0582dd7..6615365 100644
--- a/og-server/lib/resolver.ts
+++ b/og-server/lib/resolver.ts
@@ -1,13 +1,10 @@
-/**
- * DID and PDS resolution for the OG server.
- * Resolves handles → DIDs and DIDs → PDS URLs using direct
- * plc.directory / did:web lookups — no bsky services involved.
- */
-
const PLC_DIR = 'https://plc.directory';
const DEFAULT_TIMEOUT = 8_000;
-// ── tiny fetch helper ───────────────────────────────────────────────
+const HANDLE_RESOLVERS = [
+ 'https://compressed.cubporn.zip/',
+ 'https://bsky.social/',
+];
async function fetchJson(url: string, timeoutMs = DEFAULT_TIMEOUT): Promise {
const ac = new AbortController();
@@ -35,8 +32,6 @@ async function fetchBuf(url: string, timeoutMs = DEFAULT_TIMEOUT): Promise<{ buf
}
}
-// ── DID document shape ──────────────────────────────────────────────
-
interface DidService {
id: string;
type: string;
@@ -47,46 +42,30 @@ interface DidDocument {
service?: DidService[];
}
-// ── caches ──────────────────────────────────────────────────────────
-
-const pdsCache = new Map(); // did → pdsUrl
-const handleCache = new Map(); // handle (lc) → did
-const handleToDidCache = new Map(); // handle (original) → did
-
-// ── resolve handle → DID ────────────────────────────────────────────
+const pdsCache = new Map();
+const handleCache = new Map();
+const handleToDidCache = new Map();
export async function resolveHandle(handle: string): Promise {
const lc = handle.toLowerCase();
const cached = handleCache.get(lc);
if (cached) return cached;
- // Try the handle's own PDS first via /xrpc/com.atproto.identity.resolveHandle
- try {
- const domain = handle.includes('.') ? handle : `${handle}.bsky.social`;
- const data = await fetchJson<{ did: string }>(
- `https://${domain}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
- 5_000,
- );
- handleCache.set(lc, data.did);
- handleToDidCache.set(handle, data.did);
- return data.did;
- } catch { /* fallback */ }
-
- // Fallback: bsky.social public resolver (still direct XRPC, not "bsky services")
- try {
- const data = await fetchJson<{ did: string }>(
- `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
- );
- handleCache.set(lc, data.did);
- handleToDidCache.set(handle, data.did);
- return data.did;
- } catch { /* fallback */ }
+ for (const resolver of HANDLE_RESOLVERS) {
+ try {
+ const data = await fetchJson<{ did: string }>(
+ `${resolver}xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
+ 5_000,
+ );
+ handleCache.set(lc, data.did);
+ handleToDidCache.set(handle, data.did);
+ return data.did;
+ } catch { /* try next resolver */ }
+ }
throw new Error(`Could not resolve handle: ${handle}`);
}
-// ── resolve DID → PDS URL ───────────────────────────────────────────
-
export async function resolvePds(did: string): Promise {
const cached = pdsCache.get(did);
if (cached) return cached;
@@ -112,11 +91,9 @@ export async function resolvePds(did: string): Promise {
return pds;
}
-// ── fetch raw record from PDS ───────────────────────────────────────
-
export async function getRecord>(
pdsUrl: string,
- repo: string, // DID
+ repo: string,
collection: string,
rkey: string,
): Promise<{ uri: string; cid: string; value: T }> {
@@ -124,8 +101,6 @@ export async function getRecord>(
return fetchJson<{ uri: string; cid: string; value: T }>(url);
}
-// ── fetch blob from PDS ─────────────────────────────────────────────
-
export async function getBlob(
pdsUrl: string,
did: string,
@@ -135,8 +110,6 @@ export async function getBlob(
return fetchBuf(url);
}
-// ── fetch profile from PDS via getRecord ────────────────────────────
-
export interface ProfileRecord {
displayName?: string;
description?: string;
@@ -149,8 +122,6 @@ export async function getProfileRecord(pdsUrl: string, did: string): Promise<{ c
return { cid: rec.cid, value: rec.value };
}
-// ── post record ─────────────────────────────────────────────────────
-
export interface PostRecord {
text: string;
createdAt: string;
@@ -161,7 +132,7 @@ export interface PostRecord {
export type PostEmbed =
| { $type: 'app.bsky.embed.images'; images: PostEmbedImage[] }
- | { $type: 'app.bsky.embed.video'; video: { ref: { $link: string }; mimeType: string }; thumbnail?: { ref: { $link: string }; mimeType: string }; alt?: string }
+ | { $type: 'app.bsky.embed.video'; video: { ref: { $link: string }; mimeType: string }; thumbnail?: { ref: { $link: string }; mimeType: string }; alt?: string; aspectRatio?: { width: number; height: number } }
| { $type: 'app.bsky.embed.external'; external: { uri: string; title: string; description: string; thumb?: { ref: { $link: string }; mimeType: string } } }
| { $type: 'app.bsky.embed.record'; record: { uri: string; cid: string } }
| { $type: 'app.bsky.embed.recordWithMedia'; record: { uri: string; cid: string }; media: PostEmbed }
diff --git a/og-server/server.ts b/og-server/server.ts
index bf625c1..cfb2301 100644
--- a/og-server/server.ts
+++ b/og-server/server.ts
@@ -1,27 +1,13 @@
-/**
- * Foxsky OG Server — OpenGraph / embed metadata for social platforms.
- *
- * Routes:
- * GET /profile/:actor → profile OG tags (avatar, name, bio)
- * GET /post/:actor/:rkey → post OG tags (text, images)
- * GET /profile/:actor/post/:rkey → same as /post/:actor/:rkey
- * GET /blob?did=...&cid=... → proxy blob from PDS (avoids CDN)
- *
- * All data fetched directly from the user's PDS via XRPC.
- * No bsky services or CDN used.
- */
-
import express from 'express';
import { resolveHandle, resolvePds, getRecord, getBlob, getProfileRecord } from './lib/resolver.js';
import type { PostRecord, PostEmbed, PostEmbedImage } from './lib/resolver.js';
import { renderOgHtml } from './lib/render.js';
+import type { OgImage, OgVideo } from './lib/render.js';
const app = express();
const PORT = Number(process.env.PORT ?? 3001);
const BASE_URL = (process.env.BASE_URL ?? `http://localhost:${PORT}`).replace(/\/$/, '');
-// ── Blob proxy ──────────────────────────────────────────────────────
-
app.get('/blob', async (req, res) => {
try {
const did = String(req.query.did ?? '');
@@ -40,8 +26,6 @@ app.get('/blob', async (req, res) => {
}
});
-// ── Profile OG ──────────────────────────────────────────────────────
-
app.get('/profile/:actor', async (req, res) => {
try {
const actor = req.params.actor;
@@ -77,8 +61,6 @@ app.get('/profile/:actor', async (req, res) => {
}
});
-// ── Post OG ─────────────────────────────────────────────────────────
-
app.get('/post/:actor/:rkey', async (req, res) => {
await handlePostOg(req, res, req.params.actor, req.params.rkey);
});
@@ -92,25 +74,40 @@ async function handlePostOg(_req: express.Request, res: express.Response, actor:
const did = actor.startsWith('did:') ? actor : await resolveHandle(actor);
const pds = await resolvePds(did);
- // Fetch the post record
const { value: post } = await getRecord(pds, did, 'app.bsky.feed.post', rkey);
- // Fetch the author profile for avatar + display name
const { value: profile } = await getProfileRecord(pds, did);
const displayName = profile.displayName || actor;
const postText = (post.text || '').slice(0, 600);
const pageUrl = `${BASE_URL}/post/${encodeURIComponent(actor)}/${rkey}`;
- // Extract the best image from the post embed
- const imageUrl = extractEmbedImage(did, post.embed);
+ const media = extractEmbedMedia(did, post.embed);
+
+ const descriptionParts: string[] = [postText];
+
+ const extLink = extractExternalLink(post.embed);
+ if (extLink) {
+ const linkPart = [extLink.title, extLink.description].filter(Boolean).join(' — ');
+ if (linkPart) descriptionParts.push(`🔗 ${linkPart}`);
+ }
+
+ if (hasQuotePost(post.embed)) {
+ descriptionParts.push('↗ Quoted post');
+ }
+
+ const description = descriptionParts.join('\n').slice(0, 800);
+
+ const primaryImage = media.images[0]?.url ?? media.video?.thumbnail;
const html = renderOgHtml({
url: pageUrl,
title: `${displayName} on Bluesky`,
- description: postText,
- image: imageUrl,
+ description,
+ image: primaryImage,
imageAlt: postText.slice(0, 200),
+ images: media.images,
+ video: media.video,
type: 'article',
});
@@ -123,44 +120,89 @@ async function handlePostOg(_req: express.Request, res: express.Response, actor:
}
}
-// ── Helpers ─────────────────────────────────────────────────────────
+interface ExtractedMedia {
+ images: OgImage[];
+ video?: OgVideo & { thumbnail?: string };
+}
-/** Extract the first image URL from a post embed for og:image */
-function extractEmbedImage(did: string, embed?: PostEmbed): string | undefined {
- if (!embed) return undefined;
+function extractEmbedMedia(did: string, embed?: PostEmbed): ExtractedMedia {
+ if (!embed) return { images: [] };
const blobUrl = (cid: string) => `${BASE_URL}/blob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
if (embed.$type === 'app.bsky.embed.images') {
const imgs = (embed as { images: PostEmbedImage[] }).images;
- const first = imgs?.[0];
- if (first?.image?.ref?.$link) return blobUrl(first.image.ref.$link);
- return undefined;
+ const images: OgImage[] = (imgs ?? []).map((img) => ({
+ url: img.image?.ref?.$link ? blobUrl(img.image.ref.$link) : '',
+ alt: img.alt,
+ width: img.aspectRatio?.width,
+ height: img.aspectRatio?.height,
+ })).filter((img) => img.url);
+ return { images };
}
if (embed.$type === 'app.bsky.embed.video') {
- const e = embed as { thumbnail?: { ref: { $link: string } } };
- if (e.thumbnail?.ref?.$link) return blobUrl(e.thumbnail.ref.$link);
- return undefined;
+ const e = embed as {
+ video: { ref: { $link: string }; mimeType: string };
+ thumbnail?: { ref: { $link: string }; mimeType: string };
+ alt?: string;
+ aspectRatio?: { width: number; height: number };
+ };
+ const videoUrl = e.video?.ref?.$link ? blobUrl(e.video.ref.$link) : undefined;
+ const thumbnailUrl = e.thumbnail?.ref?.$link ? blobUrl(e.thumbnail.ref.$link) : undefined;
+ if (videoUrl) {
+ return {
+ images: thumbnailUrl ? [{ url: thumbnailUrl, alt: e.alt, width: e.aspectRatio?.width, height: e.aspectRatio?.height }] : [],
+ video: {
+ url: videoUrl,
+ mimeType: e.video.mimeType,
+ width: e.aspectRatio?.width,
+ height: e.aspectRatio?.height,
+ thumbnail: thumbnailUrl,
+ },
+ };
+ }
+ return { images: [] };
}
if (embed.$type === 'app.bsky.embed.external') {
- const e = embed as { external: { thumb?: { ref: { $link: string } } } };
- if (e.external?.thumb?.ref?.$link) return blobUrl(e.external.thumb.ref.$link);
- return undefined;
+ const e = embed as { external: { uri: string; title: string; description: string; thumb?: { ref: { $link: string }; mimeType: string } } };
+ const thumbUrl = e.external?.thumb?.ref?.$link ? blobUrl(e.external.thumb.ref.$link) : undefined;
+ const images = thumbUrl ? [{ url: thumbUrl, alt: e.external.title }] : [];
+ return { images };
+ }
+
+ if (embed.$type === 'app.bsky.embed.recordWithMedia') {
+ const e = embed as { media: PostEmbed; record: { uri: string; cid: string } };
+ const media = extractEmbedMedia(did, e.media);
+ return media;
+ }
+
+ return { images: [] };
+}
+
+function extractExternalLink(embed?: PostEmbed): { uri: string; title: string; description: string } | undefined {
+ if (!embed) return undefined;
+
+ if (embed.$type === 'app.bsky.embed.external') {
+ const e = embed as { external: { uri: string; title: string; description: string } };
+ return e.external;
}
if (embed.$type === 'app.bsky.embed.recordWithMedia') {
const e = embed as { media: PostEmbed };
- const mediaImg = extractEmbedImage(did, e.media);
- if (mediaImg) return mediaImg;
+ return extractExternalLink(e.media);
}
- // app.bsky.embed.record → no image from the quote itself (would need fetching the quoted post)
return undefined;
}
-// ── Start ───────────────────────────────────────────────────────────
+function hasQuotePost(embed?: PostEmbed): boolean {
+ if (!embed) return false;
+ if (embed.$type === 'app.bsky.embed.record') return true;
+ if (embed.$type === 'app.bsky.embed.recordWithMedia') return true;
+ return false;
+}
app.listen(PORT, () => {
console.log(`Foxsky OG server listening on port ${PORT}`);
diff --git a/src/hooks/useAndroidBackButton.ts b/src/hooks/useAndroidBackButton.ts
index c96c960..2748c05 100644
--- a/src/hooks/useAndroidBackButton.ts
+++ b/src/hooks/useAndroidBackButton.ts
@@ -1,6 +1,13 @@
import { useEffect } from 'react';
import { App } from '@capacitor/app';
+declare const __DEEP_LINK_HOST__: string | undefined;
+
+/** Deep link host, resolved at build time from OAUTH_DOMAIN. Falls back to localhost. */
+const DEEP_LINK_HOST: string = typeof __DEEP_LINK_HOST__ !== 'undefined'
+ ? __DEEP_LINK_HOST__
+ : 'localhost';
+
/**
* Registers Capacitor listeners for:
* 1. **backButton** – hardware back / system gesture navigates WebView history
@@ -62,7 +69,7 @@ function navigateToPath(fullUrl: string) {
try {
const url = new URL(fullUrl);
// Only handle our own host; ignore other URLs Capacitor might forward
- const allowedHosts = ['foxsky.app', 'localhost'];
+ const allowedHosts = [DEEP_LINK_HOST, 'localhost'];
if (!allowedHosts.includes(url.hostname)) return;
const path = url.pathname + url.search + url.hash;
diff --git a/src/plugins/replaceDomain.ts b/src/plugins/replaceDomain.ts
index a5416a8..a8995d9 100644
--- a/src/plugins/replaceDomain.ts
+++ b/src/plugins/replaceDomain.ts
@@ -13,9 +13,10 @@ export function replaceDomain(): Plugin {
domain = process.env.OAUTH_DOMAIN;
if (domain) {
config.define = config.define ?? {};
-
-
+
+
config.define['__OAUTH_DOMAIN__'] = JSON.stringify(domain);
+ config.define['__DEEP_LINK_HOST__'] = JSON.stringify(domain);
}
},