···
import { type AtpClient } from '$lib/at/client';
···
import BskyPost from './BskyPost.svelte';
import Icon from '@iconify/svelte';
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
26
-
import { postActions, pulsingPostId, type PostActions } from '$lib/state.svelte';
27
+
import { clients, postActions, posts, pulsingPostId, type PostActions } from '$lib/state.svelte';
import * as TID from '@atcute/tid';
import type { PostWithUri } from '$lib/at/fetch';
import { onMount } from 'svelte';
30
-
import type { AtprotoDid } from '@atcute/lexicons/syntax';
31
+
import { isActorIdentifier, type AtprotoDid } from '@atcute/lexicons/syntax';
import { derived } from 'svelte/store';
import Device from 'svelte-device-info';
import Dropdown from './Dropdown.svelte';
···
const selectedDid = $derived(client.user?.did ?? null);
65
+
const actionClient = $derived(clients.get(did as AtprotoDid));
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
const color = generateColorForDid(did);
···
? Promise.resolve(ok(data))
: client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
78
+
let profile: AppBskyActorProfile.Main | null = $state(null);
79
+
onMount(async () => {
80
+
const p = await client.getProfile(did);
83
+
console.log(profile.description);
// const replies = replyBacklinks
// ? Promise.resolve(ok(replyBacklinks))
// : client.getBacklinks(
···
actionsPos = { x: event.clientX, y: event.clientY };
235
+
event.stopPropagation();
238
+
let deleteState: 'waiting' | 'confirm' | 'deleted' = $state('waiting');
240
+
if (deleteState === 'confirm' && !actionsOpen) deleteState = 'waiting';
243
+
const deletePost = () => {
244
+
if (deleteState === 'deleted') return;
245
+
if (deleteState === 'waiting') {
246
+
deleteState = 'confirm';
250
+
actionClient?.atcute
251
+
?.post('com.atproto.repo.deleteRecord', {
253
+
collection: 'app.bsky.feed.post',
258
+
.then((result) => {
259
+
if (!result.ok) return;
260
+
posts.get(did)?.delete(aturi);
261
+
deleteState = 'deleted';
263
+
actionsOpen = false;
266
+
let profileOpen = $state(false);
267
+
let profilePopoutShowDid = $state(false);
{#snippet embedBadge(embed: AppBskyEmbeds)}
···
282
+
{#snippet profileInline()}
285
+
flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''}
286
+
rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
288
+
style="color: {color};"
289
+
onclick={() => (profileOpen = !profileOpen)}
291
+
<ProfilePicture {client} {did} size={8} />
294
+
<span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
295
+
>{profile.displayName}</span
296
+
><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span>
303
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
304
+
{#snippet profilePopout()}
305
+
{@const profileDesc = profile?.description?.trim() ?? ''}
307
+
class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!"
308
+
style="background: {color}36; border-color: {color}99;"
309
+
bind:isOpen={profileOpen}
310
+
trigger={profileInline}
312
+
<div class="flex items-center gap-2">
313
+
<ProfilePicture {client} {did} size={20} />
315
+
<div class="flex flex-col items-start overflow-hidden overflow-ellipsis">
316
+
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
317
+
{profile?.displayName ?? handle}
318
+
{#if profile?.pronouns}
319
+
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
323
+
oncontextmenu={(e) => {
324
+
const node = e.target as Node;
325
+
const selection = window.getSelection() ?? new Selection();
326
+
const range = document.createRange();
327
+
range.selectNodeContents(node);
328
+
selection.removeAllRanges();
329
+
selection.addRange(range);
330
+
e.stopPropagation();
332
+
onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)}
333
+
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
335
+
{profilePopoutShowDid ? did : `@${handle}`}
337
+
{#if profile?.website}
340
+
rel="noopener noreferrer"
341
+
href={profile.website}
342
+
class="text-sm text-nowrap opacity-60">{profile.website}</a
348
+
{#if profileDesc.length > 0}
349
+
<p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
350
+
{#each profileDesc.split(/(\s)/) as line, idx (idx)}
351
+
{#if line === '\n'}
353
+
{:else if isActorIdentifier(line.replace(/^@/, ''))}
356
+
rel="noopener noreferrer"
357
+
class="text-(--nucleus-accent2)"
358
+
href={`${$settings.socialAppUrl}/profile/${line.replace(/^@/, '')}`}>{line}</a
360
+
{:else if line.startsWith('https://')}
363
+
rel="noopener noreferrer"
364
+
class="text-(--nucleus-accent2)"
365
+
href={line}>{line.replace(/https?:\/\//, '')}</a
<div class="text-sm opacity-60">
···
300
-
mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1
435
+
mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1
style="background: {color}33;"
304
-
<ProfilePicture {client} {did} size={8} />
439
+
{@render profilePopout()}
308
-
flex min-w-0 items-center gap-2 font-bold
309
-
{isOnPostComposer ? 'contrast-200' : ''}
311
-
style="color: {color};"
442
+
title={new Date(record.createdAt).toLocaleString()}
443
+
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
313
-
{#await client.getProfile(did)}
317
-
{@const profileValue = profile.value}
318
-
<span class="w-min min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
319
-
>{profileValue.displayName}</span
320
-
><span class="text-nowrap opacity-70">(@{handle})</span>
445
+
{getRelativeTime(new Date(record.createdAt))}
327
-
<span class="text-nowrap text-(--nucleus-fg)/67"
328
-
>{getRelativeTime(new Date(record.createdAt))}</span
<p class="leading-normal text-wrap wrap-break-word">
···
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
360
-
{#if embed.$type === 'app.bsky.embed.images'}
361
-
<!-- todo: improve how images are displayed, and pop out on click -->
362
-
{#each embed.images as image (image.image)}
363
-
{#if isBlob(image.image)}
366
-
src={img('feed_thumbnail', did, image.image.ref.$link)}
371
-
{:else if embed.$type === 'app.bsky.embed.video'}
372
-
{#if isBlob(embed.video)}
373
-
{#await didDoc then didDoc}
375
-
<!-- svelte-ignore a11y_media_has_caption -->
378
-
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
477
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
478
+
<div oncontextmenu={(e) => e.stopPropagation()}>
479
+
{#if embed.$type === 'app.bsky.embed.images'}
480
+
<!-- todo: improve how images are displayed, and pop out on click -->
481
+
{#each embed.images as image (image.image)}
482
+
{#if isBlob(image.image)}
484
+
class="w-full rounded-sm"
485
+
src={img('feed_thumbnail', did, image.image.ref.$link)}
490
+
{:else if embed.$type === 'app.bsky.embed.video'}
491
+
{#if isBlob(embed.video)}
492
+
{#await didDoc then didDoc}
494
+
<!-- svelte-ignore a11y_media_has_caption -->
497
+
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
{#snippet embedPost(uri: ResourceUri)}
···
484
-
class="flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60"
604
+
class="post-dropdown"
style="background: {color}36; border-color: {color}99;"
bind:isOpen={actionsOpen}
bind:position={actionsPos}
608
+
placement="bottom-end"
{@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () =>
navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`)
···
{@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () =>
navigator.clipboard.writeText(post.uri)
495
-
<div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
496
-
{@render dropdownItem('heroicons:clipboard', 'copy post text', () =>
616
+
{@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () =>
navigator.clipboard.writeText(post.record.text)
620
+
<div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
621
+
{@render dropdownItem(
622
+
deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
623
+
deleteState === 'confirm' ? 'are you sure?' : 'delete post',
626
+
deleteState === 'confirm' ? 'text-red-500' : ''
···
520
-
{#snippet dropdownItem(icon: string, label: string, onClick: () => void)}
650
+
{#snippet dropdownItem(
653
+
onClick: () => void,
654
+
autoClose: boolean = true,
655
+
extraClass: string = ''
flex items-center justify-between rounded-sm px-2 py-1.5
transition-all duration-100 hover:[backdrop-filter:brightness(120%)]
528
-
actionsOpen = false;
665
+
if (autoClose) actionsOpen = false;
<span class="font-bold">{label}</span>
<Icon class="h-6 w-6" {icon} />
674
+
@reference "../app.css";
676
+
:global(.post-dropdown) {
677
+
@apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;