replies timeline only, appview-less bluesky client
1<script lang="ts"> 2 import type { AtpClient } from '$lib/at/client'; 3 import { ok, err, type Result } from '$lib/result'; 4 import type { AppBskyFeedPost } from '@atcute/bluesky'; 5 import type { ResourceUri } from '@atcute/lexicons'; 6 import { generateColorForDid } from '$lib/accounts'; 7 8 interface Props { 9 client: AtpClient; 10 onPostSent: (uri: ResourceUri, post: AppBskyFeedPost.Main) => void; 11 } 12 13 const { client, onPostSent }: Props = $props(); 14 15 let color = $derived( 16 client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)' 17 ); 18 19 const post = async ( 20 text: string 21 ): Promise<Result<{ uri: ResourceUri; record: AppBskyFeedPost.Main }, string>> => { 22 const record: AppBskyFeedPost.Main = { 23 $type: 'app.bsky.feed.post', 24 text, 25 createdAt: new Date().toISOString() 26 }; 27 28 const res = await client.atcute?.post('com.atproto.repo.createRecord', { 29 input: { 30 collection: 'app.bsky.feed.post', 31 repo: client.didDoc!.did, 32 record 33 } 34 }); 35 36 if (!res) { 37 return err('failed to post: not logged in'); 38 } 39 40 if (!res.ok) { 41 return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`); 42 } 43 44 return ok({ 45 uri: res.data.uri, 46 record 47 }); 48 }; 49 50 let postText = $state(''); 51 let info = $state(''); 52 let isFocused = $state(false); 53 let textareaEl: HTMLTextAreaElement | undefined = $state(); 54 55 const doPost = () => { 56 if (postText.length === 0 || postText.length > 300) return; 57 58 post(postText).then((res) => { 59 if (res.ok) { 60 onPostSent(res.value.uri, res.value.record); 61 postText = ''; 62 info = 'posted!'; 63 setTimeout(() => (info = ''), 1000 * 3); 64 } else { 65 info = res.error; 66 } 67 }); 68 }; 69 70 $effect(() => { 71 if (isFocused && textareaEl) textareaEl.focus(); 72 }); 73</script> 74 75<div class="relative min-h-16"> 76 <!-- Spacer to maintain layout when focused --> 77 {#if isFocused} 78 <div class="min-h-16"></div> 79 {/if} 80 81 <div 82 class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300" 83 class:min-h-16={!isFocused} 84 class:items-center={!isFocused} 85 class:shadow-2xl={isFocused} 86 class:absolute={isFocused} 87 class:top-0={isFocused} 88 class:left-0={isFocused} 89 class:right-0={isFocused} 90 class:z-50={isFocused} 91 style="background: {isFocused 92 ? `color-mix(in srgb, var(--nucleus-bg) 80%, ${color} 20%)` 93 : `color-mix(in srgb, ${color} 9%, transparent)`}; 94 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 95 > 96 <div class="w-full p-2" class:py-3={isFocused}> 97 {#if info.length > 0} 98 <div 99 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 100 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 101 > 102 {info} 103 </div> 104 {:else} 105 <div class="flex flex-col gap-2"> 106 {#if isFocused} 107 <textarea 108 bind:this={textareaEl} 109 bind:value={postText} 110 onfocus={() => (isFocused = true)} 111 onblur={() => (isFocused = false)} 112 onkeydown={(event) => { 113 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 114 }} 115 placeholder="what's on your mind?" 116 rows="4" 117 class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100" 118 style="border-color: color-mix(in srgb, {color} 27%, transparent);" 119 ></textarea> 120 <div class="flex items-center gap-2"> 121 <div class="grow"></div> 122 <span 123 class="text-sm font-medium" 124 style="color: color-mix(in srgb, {postText.length > 300 125 ? '#ef4444' 126 : 'var(--nucleus-fg)'} 53%, transparent);" 127 > 128 {postText.length} / 300 129 </span> 130 <button 131 onclick={doPost} 132 disabled={postText.length === 0 || postText.length > 300} 133 class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100" 134 style="background: color-mix(in srgb, {color} 87%, transparent);" 135 > 136 post 137 </button> 138 </div> 139 {:else} 140 <input 141 bind:value={postText} 142 onfocus={() => (isFocused = true)} 143 onkeydown={(event) => { 144 if (event.key === 'Enter') doPost(); 145 }} 146 type="text" 147 placeholder="what's on your mind?" 148 class="single-line-input flex-1 bg-(--nucleus-bg)/40" 149 style="border-color: color-mix(in srgb, {color} 27%, transparent);" 150 /> 151 {/if} 152 </div> 153 {/if} 154 </div> 155 </div> 156</div>