replies timeline only, appview-less bluesky client
1<script lang="ts"> 2 import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 import { AtpClient } from '$lib/at/client'; 4 import type { Handle } from '@atcute/lexicons'; 5 import ProfilePicture from './ProfilePicture.svelte'; 6 import PfpPlaceholder from './PfpPlaceholder.svelte'; 7 import Popup from './Popup.svelte'; 8 import Dropdown from './Dropdown.svelte'; 9 import { flow } from '$lib/at/oauth'; 10 import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 11 import Icon from '@iconify/svelte'; 12 13 interface Props { 14 client: AtpClient; 15 accounts: Array<Account>; 16 selectedDid?: AtprotoDid | null; 17 onAccountSelected: (did: AtprotoDid) => void; 18 onLogout: (did: AtprotoDid) => void; 19 } 20 21 let { 22 client, 23 accounts = [], 24 selectedDid = $bindable(null), 25 onAccountSelected, 26 onLogout 27 }: Props = $props(); 28 29 let isDropdownOpen = $state(false); 30 let isLoginModalOpen = $state(false); 31 let loginHandle = $state(''); 32 let loginError = $state(''); 33 let isLoggingIn = $state(false); 34 35 const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen); 36 const closeDropdown = () => (isDropdownOpen = false); 37 38 const selectAccount = (did: AtprotoDid) => { 39 onAccountSelected(did); 40 closeDropdown(); 41 }; 42 43 const openLoginModal = () => { 44 isLoginModalOpen = true; 45 closeDropdown(); 46 loginHandle = ''; 47 loginError = ''; 48 // HACK: i hate this but it works so it doesnt really matter 49 setTimeout(() => document.getElementById('handle')?.focus(), 100); 50 }; 51 52 const closeLoginModal = () => { 53 document.getElementById('handle')?.blur(); 54 isLoginModalOpen = false; 55 loginHandle = ''; 56 loginError = ''; 57 }; 58 59 const handleLogin = async () => { 60 try { 61 if (!loginHandle) throw 'please enter handle'; 62 63 isLoggingIn = true; 64 loginError = ''; 65 66 let handle: Handle; 67 if (isHandle(loginHandle)) handle = loginHandle; 68 else throw 'handle is invalid'; 69 70 let did = await client.resolveHandle(handle); 71 if (!did.ok) throw did.error; 72 73 await initiateLogin(did.value, handle); 74 } catch (error) { 75 loginError = `login failed: ${error}`; 76 loggingIn.set(null); 77 } finally { 78 isLoggingIn = false; 79 } 80 }; 81 82 const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => { 83 loggingIn.set({ did, handle }); 84 const result = await flow.start(handle ?? did); 85 if (!result.ok) throw result.error; 86 }; 87 88 const handleKeydown = (event: KeyboardEvent) => { 89 if (event.key === 'Enter' && !isLoggingIn) handleLogin(); 90 }; 91</script> 92 93<Dropdown 94 class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" 95 bind:isOpen={isDropdownOpen} 96> 97 {#snippet trigger()} 98 <button 99 onclick={toggleDropdown} 100 class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150" 101 > 102 {#if selectedDid} 103 <ProfilePicture {client} did={selectedDid} size={13} /> 104 {:else} 105 <PfpPlaceholder color="var(--nucleus-accent)" size={13} /> 106 {/if} 107 </button> 108 {/snippet} 109 110 {#if accounts.length > 0} 111 <div class="p-2"> 112 {#each accounts as account (account.did)} 113 {@const color = generateColorForDid(account.did)} 114 {#snippet action(name: string, icon: string, onClick: () => void)} 115 <!-- svelte-ignore a11y_click_events_have_key_events --> 116 <!-- svelte-ignore a11y_no_static_element_interactions --> 117 <div 118 title={name} 119 onclick={onClick} 120 class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 121 > 122 <Icon class="h-5 w-5" {icon} /> 123 </div> 124 {/snippet} 125 <button 126 onclick={() => selectAccount(account.did)} 127 class=" 128 group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 129 {account.did === selectedDid ? 'shadow-lg' : ''} 130 " 131 style="color: {color}; background: {account.did === selectedDid 132 ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 133 : 'transparent'};" 134 > 135 <span>@{account.handle}</span> 136 137 <div class="grow"></div> 138 139 {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => 140 initiateLogin(account.did, account.handle) 141 )} 142 {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} 143 144 {#if account.did === selectedDid} 145 <Icon 146 icon="heroicons:check-16-solid" 147 class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" 148 /> 149 {/if} 150 </button> 151 {/each} 152 </div> 153 <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 154 {/if} 155 <button 156 onclick={openLoginModal} 157 class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]" 158 > 159 <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> 160 <span>add account</span> 161 </button> 162</Dropdown> 163 164<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> 165 <!-- svelte-ignore a11y_no_static_element_interactions --> 166 <div class="space-y-2" onkeydown={handleKeydown}> 167 <div> 168 <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 169 account handle 170 </label> 171 <input 172 id="handle" 173 type="text" 174 bind:value={loginHandle} 175 placeholder="example.bsky.social" 176 class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 177 disabled={isLoggingIn} 178 /> 179 </div> 180 181 {#if loginError} 182 <div class="error-disclaimer"> 183 <p> 184 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 185 {loginError} 186 </p> 187 </div> 188 {/if} 189 190 <div class="flex gap-3 pt-3"> 191 <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 192 cancel 193 </button> 194 <button 195 onclick={handleLogin} 196 class="flex-1 action-button border-transparent text-(--nucleus-fg)" 197 style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 198 disabled={isLoggingIn} 199 > 200 {isLoggingIn ? 'logging in...' : 'login'} 201 </button> 202 </div> 203 </div> 204</Popup>