replies timeline only, appview-less bluesky client

feat: profile pictures, post composer now takes upon account color better, improve fetching efficiency

+10 -18
src/components/AccountSelector.svelte
···
import { AtpClient } from '$lib/at/client';
import type { Did, Handle } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
interface Props {
accounts: Array<Account>;
selectedDid?: Did | null;
onAccountSelected: (did: Did) => void;
···
}
let {
accounts = [],
selectedDid = $bindable(null),
onAccountSelected,
onLoginSucceed,
onLogout
}: Props = $props();
-
-
let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg);
let isDropdownOpen = $state(false);
let isLoginModalOpen = $state(false);
···
const closeDropdown = () => {
isDropdownOpen = false;
};
-
-
let selectedAccount = $derived(accounts.find((acc) => acc.did === selectedDid));
</script>
<svelte:window onclick={closeDropdown} />
···
<div class="relative">
<button
onclick={toggleDropdown}
-
class="group flex h-full items-center gap-2 rounded-sm border-2 px-2 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl"
-
style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);"
>
-
<span class="font-bold">
-
{selectedAccount ? `@${selectedAccount.handle}` : 'select account'}
-
</span>
-
<svg
-
class="h-4 w-4 transition-transform {isDropdownOpen ? 'rotate-180' : ''}"
-
style="color: {theme.accent};"
-
fill="none"
-
stroke="currentColor"
-
viewBox="0 0 24 24"
-
>
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
-
</svg>
</button>
{#if isDropdownOpen}
···
import { AtpClient } from '$lib/at/client';
import type { Did, Handle } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
+
import ProfilePicture from './ProfilePicture.svelte';
+
import PfpPlaceholder from './PfpPlaceholder.svelte';
interface Props {
+
client: AtpClient;
accounts: Array<Account>;
selectedDid?: Did | null;
onAccountSelected: (did: Did) => void;
···
}
let {
+
client,
accounts = [],
selectedDid = $bindable(null),
onAccountSelected,
onLoginSucceed,
onLogout
}: Props = $props();
let isDropdownOpen = $state(false);
let isLoginModalOpen = $state(false);
···
const closeDropdown = () => {
isDropdownOpen = false;
};
</script>
<svelte:window onclick={closeDropdown} />
···
<div class="relative">
<button
onclick={toggleDropdown}
+
class="flex h-16 w-16 items-center justify-center rounded-sm shadow-lg transition-all hover:scale-105 hover:shadow-xl"
>
+
{#if selectedDid}
+
<ProfilePicture {client} did={selectedDid} size={15} />
+
{:else}
+
<PfpPlaceholder color={theme.accent} size={15} />
+
{/if}
</button>
{#if isDropdownOpen}
+39 -19
src/components/BskyPost.svelte
···
<script lang="ts">
import type { AtpClient } from '$lib/at/client';
import { AppBskyFeedPost } from '@atcute/bluesky';
-
import type { ActorIdentifier, RecordKey } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
import { map, ok } from '$lib/result';
import { generateColorForDid } from '$lib/accounts';
interface Props {
client: AtpClient;
-
identifier: ActorIdentifier;
rkey: RecordKey;
// replyBacklinks?: Backlinks;
record?: AppBskyFeedPost.Main;
mini?: boolean;
}
-
const { client, identifier, rkey, record, mini /* replyBacklinks */ }: Props = $props();
-
const color = generateColorForDid(identifier) ?? theme.accent2;
-
let handle = $state(identifier);
client
-
.resolveDidDoc(identifier)
.then((res) => map(res, (data) => data.handle))
.then((res) => {
if (res.ok) handle = res.value;
});
const post = record
? Promise.resolve(ok(record))
-
: client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey);
// const replies = replyBacklinks
// ? Promise.resolve(ok(replyBacklinks))
// : client.getBacklinks(
···
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
-
if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`;
-
if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`;
-
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
-
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
-
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
-
if (seconds > 0) return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
return 'just now';
};
</script>
···
{:else}
{#await post}
<div
-
class="rounded-sm border-2 p-3 text-center backdrop-blur-sm"
style="background: {color}18; border-color: {color}66;"
>
<div
···
{#if post.ok}
{@const record = post.value}
<div
-
class="rounded-sm border-2 p-3 shadow-lg backdrop-blur-sm transition-all"
style="background: {color}18; border-color: {color}66;"
>
-
<div class="mb-3 flex items-center gap-1.5">
-
<span class="font-bold" style="color: {color};">
-
@{handle}
</span>
<!-- <span>·</span>
{#await replies}
<span style="color: {theme.fg}aa;">… replies</span>
···
{/if}
{/await} -->
<span>·</span>
-
<span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span>
</div>
<p class="leading-relaxed text-wrap" style="color: {theme.fg};">
{record.text}
···
<script lang="ts">
import type { AtpClient } from '$lib/at/client';
import { AppBskyFeedPost } from '@atcute/bluesky';
+
import type { ActorIdentifier, Did, RecordKey } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
import { map, ok } from '$lib/result';
import { generateColorForDid } from '$lib/accounts';
+
import ProfilePicture from './ProfilePicture.svelte';
interface Props {
client: AtpClient;
+
did: Did;
rkey: RecordKey;
// replyBacklinks?: Backlinks;
record?: AppBskyFeedPost.Main;
mini?: boolean;
}
+
const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props();
+
const color = generateColorForDid(did) ?? theme.accent2;
+
let handle: ActorIdentifier = $state(did);
client
+
.resolveDidDoc(did)
.then((res) => map(res, (data) => data.handle))
.then((res) => {
if (res.ok) handle = res.value;
});
const post = record
? Promise.resolve(ok(record))
+
: client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
// const replies = replyBacklinks
// ? Promise.resolve(ok(replyBacklinks))
// : client.getBacklinks(
···
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
+
if (years > 0) return `${years}y`;
+
if (months > 0) return `${months}m`;
+
if (days > 0) return `${days}d`;
+
if (hours > 0) return `${hours}h`;
+
if (minutes > 0) return `${minutes}m`;
+
if (seconds > 0) return `${seconds}s`;
return 'just now';
};
</script>
···
{:else}
{#await post}
<div
+
class="rounded-sm border-2 p-2 text-center backdrop-blur-sm"
style="background: {color}18; border-color: {color}66;"
>
<div
···
{#if post.ok}
{@const record = post.value}
<div
+
class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all"
style="background: {color}18; border-color: {color}66;"
>
+
<div
+
class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1"
+
style="background: {color}33;"
+
>
+
<ProfilePicture {client} {did} size={8} />
+
+
<span class="flex min-w-0 items-center gap-2 font-bold" style="color: {color};">
+
{#await client.getProfile(did)}
+
{handle}
+
{:then profile}
+
{#if profile.ok}
+
{@const profileValue = profile.value}
+
<span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
+
>{profileValue.displayName}</span
+
><span class="shrink-0 text-nowrap">(@{handle})</span>
+
{:else}
+
{handle}
+
{/if}
+
{/await}
</span>
+
<!-- <span>·</span>
{#await replies}
<span style="color: {theme.fg}aa;">… replies</span>
···
{/if}
{/await} -->
<span>·</span>
+
<span class="text-nowrap" style="color: {theme.fg}aa;"
+
>{getRelativeTime(new Date(record.createdAt))}</span
+
>
</div>
<p class="leading-relaxed text-wrap" style="color: {theme.fg};">
{record.text}
+21
src/components/PfpPlaceholder.svelte
···
···
+
<script lang="ts">
+
interface Props {
+
color: string;
+
size: number;
+
}
+
+
let { color, size }: Props = $props();
+
</script>
+
+
<svg
+
class="rounded-sm"
+
style="background: {color}44; color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
+
xmlns="http://www.w3.org/2000/svg"
+
width="24px"
+
height="24px"
+
viewBox="0 0 16 16"
+
><path
+
fill="currentColor"
+
d="M8 8a3 3 0 1 0 0-6a3 3 0 0 0 0 6m4.735 6c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139z"
+
/></svg
+
>
+15 -21
src/components/PostComposer.svelte
···
import type { AppBskyFeedPost } from '@atcute/bluesky';
import type { ResourceUri } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
interface Props {
client: AtpClient;
···
}
const { client, onPostSent }: Props = $props();
const post = async (
text: string
···
};
$effect(() => {
-
if (isFocused && textareaEl) {
-
textareaEl.focus();
-
}
});
</script>
···
{/if}
<div
-
class="flex max-w-full rounded-sm border-2 shadow-lg backdrop-blur-lg transition-all duration-300"
class:min-h-16={!isFocused}
class:items-center={!isFocused}
class:shadow-2xl={isFocused}
···
class:right-0={isFocused}
class:z-50={isFocused}
style="background: {isFocused
-
? `${theme.bg}f0`
-
: `${theme.accent}18`}; border-color: {theme.accent}{isFocused ? '' : '66'};"
>
<div class="w-full p-2" class:py-3={isFocused}>
{#if info.length > 0}
<div
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
-
style="background: {theme.accent}22; color: {theme.accent};"
>
{info}
</div>
···
bind:value={postText}
onfocus={() => (isFocused = true)}
onblur={() => (isFocused = false)}
-
oninput={(e) => {
-
const target = e.currentTarget;
-
if (target.value.length > 300) {
-
target.value = target.value.slice(0, 300);
-
postText = target.value;
-
}
-
}}
onkeydown={(event) => {
-
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
-
doPost();
-
}
}}
placeholder="what's on your mind?"
rows="4"
-
class="placeholder-opacity-50 w-full resize-none rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none"
-
style="background: {theme.bg}66; border-color: {theme.accent}44; color: {theme.fg};"
></textarea>
<div class="flex items-center gap-2">
<div class="grow"></div>
···
onclick={doPost}
disabled={postText.length === 0 || postText.length > 300}
class="rounded-sm border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
-
style="background: linear-gradient(120deg, {theme.accent}c0, {theme.accent2}c0); color: {theme.fg}f0;"
>
post
</button>
···
type="text"
placeholder="what's on your mind?"
class="placeholder-opacity-50 flex-1 rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none"
-
style="background: {theme.bg}66; border-color: {theme.accent}44; color: {theme.fg};"
/>
{/if}
</div>
···
import type { AppBskyFeedPost } from '@atcute/bluesky';
import type { ResourceUri } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
+
import { generateColorForDid } from '$lib/accounts';
interface Props {
client: AtpClient;
···
}
const { client, onPostSent }: Props = $props();
+
+
let color = $derived(
+
client.didDoc?.did ? (generateColorForDid(client.didDoc?.did) ?? theme.accent) : theme.accent
+
);
const post = async (
text: string
···
};
$effect(() => {
+
if (isFocused && textareaEl) textareaEl.focus();
});
</script>
···
{/if}
<div
+
class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300"
class:min-h-16={!isFocused}
class:items-center={!isFocused}
class:shadow-2xl={isFocused}
···
class:right-0={isFocused}
class:z-50={isFocused}
style="background: {isFocused
+
? `color-mix(in srgb, ${theme.bg} 80%, ${color} 20%)`
+
: `${color}18`}; border-color: {color}{isFocused ? '' : '66'};"
>
<div class="w-full p-2" class:py-3={isFocused}>
{#if info.length > 0}
<div
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
+
style="background: {color}22; color: {color};"
>
{info}
</div>
···
bind:value={postText}
onfocus={() => (isFocused = true)}
onblur={() => (isFocused = false)}
onkeydown={(event) => {
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
}}
placeholder="what's on your mind?"
rows="4"
+
class="placeholder-opacity-50 [field-sizing:content] w-full resize-none rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none"
+
style="background: {theme.bg}66; border-color: {color}44; color: {theme.fg};"
></textarea>
<div class="flex items-center gap-2">
<div class="grow"></div>
···
onclick={doPost}
disabled={postText.length === 0 || postText.length > 300}
class="rounded-sm border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
+
style="background: {color}dd; color: {theme.fg}f0;"
>
post
</button>
···
type="text"
placeholder="what's on your mind?"
class="placeholder-opacity-50 flex-1 rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none"
+
style="background: {theme.bg}66; border-color: {color}44; color: {theme.fg};"
/>
{/if}
</div>
+42
src/components/ProfilePicture.svelte
···
···
+
<script lang="ts">
+
import { generateColorForDid } from '$lib/accounts';
+
import type { AtpClient } from '$lib/at/client';
+
import { isBlob } from '@atcute/lexicons/interfaces';
+
import PfpPlaceholder from './PfpPlaceholder.svelte';
+
import { img } from '$lib/cdn';
+
import type { Did } from '@atcute/lexicons';
+
+
interface Props {
+
client: AtpClient;
+
did: Did;
+
size: number;
+
}
+
+
let { client, did, size }: Props = $props();
+
+
let color = $derived(generateColorForDid(did));
+
</script>
+
+
{#snippet missingPfp()}
+
<PfpPlaceholder {color} {size} />
+
{/snippet}
+
+
{#await client.getProfile(did)}
+
{@render missingPfp()}
+
{:then profile}
+
{#if profile.ok}
+
{@const record = profile.value}
+
{#if isBlob(record.avatar)}
+
<img
+
class="rounded-sm"
+
style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
+
alt="avatar for {did}"
+
src={img('avatar_thumbnail', did, record.avatar.ref.$link)}
+
/>
+
{:else}
+
{@render missingPfp()}
+
{/if}
+
{:else}
+
{@render missingPfp()}
+
{/if}
+
{/await}
+65 -15
src/lib/accounts.ts
···
import type { Did, Handle } from '@atcute/lexicons';
import { writable } from 'svelte/store';
-
import { createXXHash3, type IHasher } from 'hash-wasm';
export type Account = {
did: Did;
···
accounts.update((accounts) => [...accounts, account]);
};
-
// fucked up and evil (i hate promises :3)
-
const _initHasher = () => {
-
createXXHash3(90001, 8008135).then((s) => (hasher = s));
-
return null;
-
};
-
let hasher: IHasher | null = _initHasher();
-
export const generateColorForDid = (did: string): string | null => {
-
const h = hasher!;
-
h.init();
-
h.update(did);
-
const hex = h.digest();
-
const color = hex.slice(-6);
-
return `#${color}`;
-
};
···
import type { Did, Handle } from '@atcute/lexicons';
import { writable } from 'svelte/store';
export type Account = {
did: Did;
···
accounts.update((accounts) => [...accounts, account]);
};
+
export const generateColorForDid = (did: string) => hashColor(did);
+
+
function hashColor(input: string | number): string {
+
let hash = typeof input === 'string' ? stringToHash(input) : input;
+
hash ^= hash >>> 16;
+
hash = Math.imul(hash, 0x85ebca6b);
+
hash ^= hash >>> 13;
+
hash = Math.imul(hash, 0xb00b1355);
+
hash ^= hash >>> 16;
+
hash = hash >>> 0;
+
+
const hue = hash % 360;
+
const saturation = 0.7 + ((hash >>> 8) % 30) * 0.01;
+
const value = 0.6 + ((hash >>> 16) % 40) * 0.01;
+
+
const rgb = hsvToRgb(hue, saturation, value);
+
const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join('');
+
+
return `#${hex}`;
+
}
+
+
function stringToHash(str: string): number {
+
let hash = 0;
+
for (let i = 0; i < str.length; i++) {
+
hash = (Math.imul(hash << 5, 1) - hash + str.charCodeAt(i)) | 0;
+
}
+
return hash >>> 0;
+
}
+
+
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
+
const c = v * s;
+
const hPrime = h * 0.016666667;
+
const x = c * (1 - Math.abs((hPrime % 2) - 1));
+
const m = v - c;
+
+
let r: number, g: number, b: number;
+
+
if (h < 60) {
+
r = c;
+
g = x;
+
b = 0;
+
} else if (h < 120) {
+
r = x;
+
g = c;
+
b = 0;
+
} else if (h < 180) {
+
r = 0;
+
g = c;
+
b = x;
+
} else if (h < 240) {
+
r = 0;
+
g = x;
+
b = c;
+
} else if (h < 300) {
+
r = x;
+
g = 0;
+
b = c;
+
} else {
+
r = c;
+
g = 0;
+
b = x;
+
}
+
+
return [((r + m) * 255) | 0, ((g + m) * 255) | 0, ((b + m) * 255) | 0];
+
}
+39 -20
src/lib/at/client.ts
···
type ActorIdentifier,
type AtprotoDid,
type CanonicalResourceUri,
type Nsid,
type RecordKey,
type ResourceUri
···
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
import type { Records } from '@atcute/lexicons/ambient';
import { PersistedLRU } from '$lib/cache';
const cacheTtl = 1000 * 60 * 60 * 24;
const handleCache = new PersistedLRU<Handle, AtprotoDid>({
···
const cached = recordCache.get(cacheKey);
if (cached) return ok(cached.value as Output);
-
const result = await fetchMicrocosm(this.slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
-
repo,
-
collection,
-
rkey
-
});
if (!result.ok) return result;
-
// console.info(`fetched record:`, result.value);
-
const parsed = safeParse(schema, result.value.value);
-
if (!parsed.ok) return err(parsed.message);
-
recordCache.set(cacheKey, result.value);
-
-
return ok(parsed.value as Output);
}
async listRecords<Collection extends keyof Records>(
···
async resolveHandle(handle: Handle): Promise<Result<AtprotoDid, string>> {
const cached = handleCache.get(handle);
if (cached) return ok(cached);
-
const res = await fetchMicrocosm(
-
this.slingshotUrl,
-
ComAtprotoIdentityResolveHandle.mainSchema,
-
{
handle
-
}
-
);
const mapped = map(res, (data) => data.did as AtprotoDid);
···
async resolveDidDoc(handleOrDid: ActorIdentifier): Promise<Result<MiniDoc, string>> {
const cached = didDocCache.get(handleOrDid);
if (cached) return ok(cached);
-
const result = await fetchMicrocosm(this.slingshotUrl, MiniDocQuery, {
-
identifier: handleOrDid
-
});
if (result.ok) {
didDocCache.set(handleOrDid, result.value);
···
type ActorIdentifier,
type AtprotoDid,
type CanonicalResourceUri,
+
type Did,
type Nsid,
type RecordKey,
type ResourceUri
···
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
import type { Records } from '@atcute/lexicons/ambient';
import { PersistedLRU } from '$lib/cache';
+
import { AppBskyActorProfile } from '@atcute/bluesky';
const cacheTtl = 1000 * 60 * 60 * 24;
const handleCache = new PersistedLRU<Handle, AtprotoDid>({
···
const cached = recordCache.get(cacheKey);
if (cached) return ok(cached.value as Output);
+
const cachedSignal = recordCache.getSignal(cacheKey);
+
const result = await Promise.race([
+
fetchMicrocosm(this.slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
+
repo,
+
collection,
+
rkey
+
}).then((result): Result<Output, string> => {
+
if (!result.ok) return result;
+
+
const parsed = safeParse(schema, result.value.value);
+
if (!parsed.ok) return err(parsed.message);
+
+
recordCache.set(cacheKey, result.value);
+
+
return ok(parsed.value as Output);
+
}),
+
cachedSignal.then((d): Result<Output, string> => ok(d.value as Output))
+
]);
if (!result.ok) return result;
+
return ok(result.value as Output);
+
}
+
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
+
repo = repo ?? this.didDoc?.did;
+
if (!repo) return err('not authenticated');
+
return await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self');
}
async listRecords<Collection extends keyof Records>(
···
async resolveHandle(handle: Handle): Promise<Result<AtprotoDid, string>> {
const cached = handleCache.get(handle);
if (cached) return ok(cached);
+
const cachedSignal = handleCache.getSignal(handle);
+
const res = await Promise.race([
+
fetchMicrocosm(this.slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
handle
+
}),
+
cachedSignal.then((d): Result<{ did: Did }, string> => ok({ did: d }))
+
]);
const mapped = map(res, (data) => data.did as AtprotoDid);
···
async resolveDidDoc(handleOrDid: ActorIdentifier): Promise<Result<MiniDoc, string>> {
const cached = didDocCache.get(handleOrDid);
if (cached) return ok(cached);
+
const cachedSignal = didDocCache.getSignal(handleOrDid);
+
const result = await Promise.race([
+
fetchMicrocosm(this.slingshotUrl, MiniDocQuery, {
+
identifier: handleOrDid
+
}),
+
cachedSignal.then((d): Result<MiniDoc, string> => ok(d))
+
]);
if (result.ok) {
didDocCache.set(handleOrDid, result.value);
+13 -3
src/lib/cache.ts
···
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export class PersistedLRU<K extends string, V extends {}> {
private memory: LRUCache<K, V>;
-
private storage: Cache; // from wora/cache-persist
-
private prefix = ''; // or derive from options
constructor(opts: PersistedLRUOptions) {
this.memory = new LRUCache<K, V>({
···
});
this.storage = new Cache(opts.persistOptions);
this.prefix = opts.prefix ? `${opts.prefix}%` : '';
this.init();
}
···
get(key: K): V | undefined {
return this.memory.get(key);
}
set(key: K, value: V): void {
this.memory.set(key, value);
this.storage.set(this.prefixed(key), value);
this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly)
}
has(key: K): boolean {
···
delete(key: K): void {
this.memory.delete(key);
this.storage.delete(this.prefixed(key));
}
clear(): void {
this.memory.clear();
-
this.storage.purge(); // clears stored state
}
private prefixed(key: K): string {
···
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export class PersistedLRU<K extends string, V extends {}> {
private memory: LRUCache<K, V>;
+
private storage: Cache;
+
private signals: Map<K, (data: V) => void>;
+
private prefix = '';
constructor(opts: PersistedLRUOptions) {
this.memory = new LRUCache<K, V>({
···
});
this.storage = new Cache(opts.persistOptions);
this.prefix = opts.prefix ? `${opts.prefix}%` : '';
+
this.signals = new Map();
this.init();
}
···
get(key: K): V | undefined {
return this.memory.get(key);
}
+
getSignal(key: K): Promise<V> {
+
return new Promise<V>((resolve) => {
+
this.signals.set(key, resolve);
+
});
+
}
set(key: K, value: V): void {
this.memory.set(key, value);
this.storage.set(this.prefixed(key), value);
+
this.signals.get(key)?.(value);
this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly)
}
has(key: K): boolean {
···
delete(key: K): void {
this.memory.delete(key);
this.storage.delete(this.prefixed(key));
+
this.storage.flush();
}
clear(): void {
this.memory.clear();
+
this.storage.purge();
+
this.storage.flush();
}
private prefixed(key: K): string {
+9
src/lib/cdn.ts
···
···
+
import type { Did } from '@atcute/lexicons';
+
+
export const cdn = `https://cdn.bsky.app`;
+
+
export type ImageKind = 'avatar_thumbnail' | 'avatar' | 'feed_thumbnail' | 'feed_fullsize';
+
export type ImageFormat = 'webp' | 'png' | 'jpg';
+
+
export const img = (kind: ImageKind, did: Did, blob: string, format: ImageFormat = 'webp') =>
+
`${cdn}/img/${kind}/plain/${did}/${blob}@${format}`;
+6 -15
src/routes/+page.svelte
···
};
const handleLogout = async (did: Did) => {
-
$accounts = $accounts.filter((acc) => acc.did !== did);
clients.delete(did);
posts.delete(did);
cursors.delete(did);
-
selectedDid = $accounts[0]?.did;
};
const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => {
···
<div class="flex-shrink-0 space-y-4">
<div class="flex min-h-16 items-stretch gap-2">
<AccountSelector
accounts={$accounts}
bind:selectedDid
onAccountSelected={handleAccountSelected}
···
<span class="text-sm text-nowrap opacity-60" style="color: {theme.fg};"
>{reverseChronological ? '↱' : '↳'}</span
>
-
<BskyPost
-
mini
-
client={viewClient}
-
identifier={post.did}
-
rkey={post.rkey}
-
record={post.record}
-
/>
</div>
{/if}
{#each thread.posts as post (post.uri)}
<div class="mb-1.5">
-
<BskyPost
-
client={viewClient}
-
identifier={post.did}
-
rkey={post.rkey}
-
record={post.record}
-
/>
</div>
{/each}
</div>
···
};
const handleLogout = async (did: Did) => {
+
const newAccounts = $accounts.filter((acc) => acc.did !== did);
+
$accounts = newAccounts;
clients.delete(did);
posts.delete(did);
cursors.delete(did);
+
handleAccountSelected(newAccounts[0]?.did);
};
const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => {
···
<div class="flex-shrink-0 space-y-4">
<div class="flex min-h-16 items-stretch gap-2">
<AccountSelector
+
client={viewClient}
accounts={$accounts}
bind:selectedDid
onAccountSelected={handleAccountSelected}
···
<span class="text-sm text-nowrap opacity-60" style="color: {theme.fg};"
>{reverseChronological ? '↱' : '↳'}</span
>
+
<BskyPost mini client={viewClient} {...post} />
</div>
{/if}
{#each thread.posts as post (post.uri)}
<div class="mb-1.5">
+
<BskyPost client={viewClient} {...post} />
</div>
{/each}
</div>