wip bsky client for the web & android bbell.vt3e.cat

chore: readme stuff

vt3e.cat c75a3fb6 f2af6a5f

verified
+452 -88
-84
.tangled/TODO.md
··· 1 - ## todo 2 - 3 - - [x] authentication 4 - - [x] login 5 - - [x] logout 6 - - [ ] switch accounts 7 - 8 - ### bluesky specifics 9 - 10 - for feature parity with the official bluesky app: 11 - 12 - - viewing feeds 13 - - [x] timeline 14 - - [x] feedGenerators 15 - - [x] lists 16 - - interactions 17 - - viewing posts 18 - - [x] view single posts 19 - - [x] view post threads 20 - - embeds 21 - - [x] images 22 - - [x] videos 23 - - [x] link previews 24 - - records 25 - - [x] quote posts 26 - - [ ] lists 27 - - [ ] feeds 28 - - [ ] starter packs 29 - - likes 30 - - [x] un/like posts 31 - - [ ] view all likes 32 - - reposts 33 - - [x] un/repost posts 34 - - [ ] view all reposts 35 - - quotes 36 - - [ ] compose quote posts 37 - - [ ] view all quote posts 38 - - comments 39 - - [ ] compose comments 40 - - [x] view all comments 41 - - follows 42 - - [ ] follow/unfollow users 43 - - [ ] view followers/following 44 - - bookmarks 45 - - [ ] bookmark/unbookmark posts 46 - - [ ] view own bookmarks 47 - - blocks/mutes 48 - - [ ] block/unblock users 49 - - [ ] mute/unmute users 50 - - [ ] view blocked/muted users 51 - - posting 52 - - [ ] compose posts 53 - - [ ] facets (mentions, tags, [masked] links 54 - - [ ] images, videos, links 55 - - [ ] delete posts 56 - - profiles 57 - - [ ] view profiles 58 - - [ ] edit own profile 59 - - [ ] view followers/following 60 - - view posts 61 - - [ ] posts 62 - - [ ] replies 63 - - [ ] media 64 - - [ ] lists 65 - - [ ] feeds 66 - - [ ] starter packs 67 - - labellers 68 - - [ ] display labels on posts & accounts 69 - - [ ] un/subscribe to labellers 70 - - [ ] view subscribed labellers 71 - - [ ] configure labeller settings 72 - - [ ] report posts/accounts 73 - - [ ] appeal labels on own posts/accounts 74 - - notifications 75 - - [ ] view notifications 76 - - [ ] mark notifications as read 77 - - [ ] notification settings 78 - - [ ] push notifications 79 - - search 80 - - [ ] search for posts 81 - - [ ] search for users 82 - - [ ] view trending hashtags 83 - 84 - etc.
.tangled/assets/desktop_feed.png

This is a binary file and will not be displayed.

.tangled/assets/mobile_feed.png

This is a binary file and will not be displayed.

+34 -3
README.md
··· 2 2 3 3 _they're calling it the coilest client_ 4 4 5 - work in progress client for bluesky & other atproto-based apps, written with vue 6 - & typescript. 5 + a wip, pretty bsky client for the web & android, built with vue & ts. 7 6 8 7 [try it here](https://bbell.vt3e.cat) - note that this may be terribly out of date! 9 8 i have not yet set up automated deployments. 10 9 10 + ## screenshots 11 + 12 + <div align="center"> 13 + <figure style="display: inline-block; margin: 0 1rem; text-align: center;"> 14 + <a href="./.tangled/assets/mobile_feed.png" target="_blank"> 15 + <img src="./.tangled/assets/mobile_feed.png" 16 + alt="home feed - mobile" 17 + style="width: 300px; border: 1px solid #ddd;"> 18 + </a> 19 + <figcaption>feed on mobile</figcaption> 20 + </figure> 21 + 22 + <figure style="display: inline-block; margin:0 1rem; text-align: center;"> 23 + <a href="./.tangled/assets/desktop_feed.png" target="_blank"> 24 + <img src="./.tangled/assets/desktop_feed.png" 25 + alt="home feed - desktop" 26 + style="width: 400px; border: 1px solid #ddd;"> 27 + </a> 28 + <figcaption>feed on desktop</figcaption> 29 + </figure> 30 + </div> 31 + 11 32 ## todo 12 33 13 34 as stated previously, bluebell is _very_ work in progress and is far from feature 14 35 parity with the official bluesky app or other third party clients. 15 36 16 - at the moment, what is implemented is: 37 + ### currently implemented features 38 + 39 + non-exhaustive list of features that are currently implemented: 17 40 18 41 - oauth login 19 42 - viewing feeds, interacting with posts/replies (likes, reposts, replying) ··· 22 45 - smooth animations and a pretty ui!! 23 46 24 47 it's not a crazy amount but it is basically the core experience that's done. 48 + 49 + ### missing faetures 50 + 51 + non-exhaustive list of notable features that are not yet implemented: 52 + 53 + - moderation services 54 + - notifications 55 + - viewing lists, likes, reposts, etc. 25 56 26 57 ## name 27 58
+2
src/App.vue
··· 14 14 import AppShell from '@/components/Layout/AppShell.vue' 15 15 import SplashScreen from '@/components/Layout/SplashScreen.vue' 16 16 import ModalStack from '@/components/UI/ModalStack.vue' 17 + import ToastStack from '@/components/UI/Toast/ToastStack.vue' 17 18 import PronounsModal from '@/components/Modals/PronounsModal.vue' 18 19 19 20 import KEYS from './utils/keys' ··· 135 136 <template> 136 137 <div class="app-root"> 137 138 <ModalStack /> 139 + <ToastStack /> 138 140 139 141 <Transition name="fade" mode="out-in"> 140 142 <SplashScreen v-if="currentPhase === 'loading'" key="loading" />
+2 -1
src/components/Feed/Embeds/ExternalEmbed.vue
··· 14 14 <div class="embed-info"> 15 15 <div class="embed-title">{{ embed.title }}</div> 16 16 <div class="embed-description" v-if="embed.description"> 17 - {{ embed.description }} 17 + {{ embed.description.slice(0, 256) }}{{ embed.description.length > 256 ? '...' : '' }} 18 18 </div> 19 19 <div class="embed-uri">{{ embed.uri }}</div> 20 20 </div> ··· 45 45 background-color: hsl(var(--surface1)); 46 46 /* aspect-ratio: 16 / 9; */ 47 47 max-height: 256px; 48 + overflow: hidden; 48 49 49 50 img { 50 51 width: 100%;
+414
src/components/UI/Toast/ToastStack.vue
··· 1 + <script setup lang="ts"> 2 + import { ref, onBeforeUnmount, nextTick } from 'vue' 3 + 4 + import { 5 + IconCheckRounded, 6 + IconErrorRounded, 7 + IconInfoRounded, 8 + } from '@iconify-prerendered/vue-material-symbols' 9 + 10 + enum ToastType { 11 + Success = 'success', 12 + Error = 'error', 13 + Info = 'info', 14 + } 15 + const toastIcons = { 16 + [ToastType.Success]: IconCheckRounded, 17 + [ToastType.Error]: IconErrorRounded, 18 + [ToastType.Info]: IconInfoRounded, 19 + } 20 + 21 + type Toast = { 22 + id: number 23 + message: string 24 + type: ToastType 25 + } 26 + 27 + /* Example toasts */ 28 + const toasts = ref<Toast[]>([ 29 + { 30 + id: 1, 31 + message: 'This is a success toast!', 32 + type: ToastType.Success, 33 + }, 34 + { 35 + id: 2, 36 + message: 'This is an error toast!', 37 + type: ToastType.Error, 38 + }, 39 + ]) 40 + 41 + /* Configuration */ 42 + const AUTO_DISMISS_MS = 5000 43 + const SWIPE_CLICK_CANCEL_DISTANCE = 6 // px before we consider it a drag 44 + const SWIPE_REMOVAL_THRESHOLD_RATIO = 0.35 // percent of width 45 + const SWIPE_REMOVAL_MIN = 100 // px for threshold minimum 46 + const SWIPE_OUT_ANIM_MS = 200 47 + 48 + /* Per-toast runtime state tracked by id */ 49 + type DragState = { 50 + startX: number 51 + currentX: number 52 + dragging: boolean 53 + removing: boolean 54 + pointerId?: number 55 + width?: number 56 + transition?: string 57 + } 58 + const dragStates = ref<Record<number, DragState>>({}) 59 + 60 + /* Auto-dismiss timers */ 61 + const autoTimers = new Map<number, number>() 62 + 63 + /* Helper: start auto-dismiss for a toast */ 64 + function startAutoDismiss(id: number, duration = AUTO_DISMISS_MS) { 65 + clearAutoDismiss(id) 66 + const t = window.setTimeout(() => { 67 + // if currently being dragged, postpone until pointer up — don't start immediate removal 68 + const s = dragStates.value[id] 69 + if (s?.dragging) { 70 + // postpone: once pointerUp happens we'll restart a timer 71 + return 72 + } 73 + beginRemovingAnimated(id) 74 + }, duration) 75 + autoTimers.set(id, t) 76 + } 77 + 78 + /* Helper: clear auto-dismiss timer */ 79 + function clearAutoDismiss(id: number) { 80 + const existing = autoTimers.get(id) 81 + if (existing) { 82 + window.clearTimeout(existing) 83 + autoTimers.delete(id) 84 + } 85 + } 86 + 87 + /* Begin removal with swipe-out animation (we set removing state and animate translateX and opacity), 88 + then remove from the array after the animation duration. This is used for both clicks / auto-dismiss 89 + and swipe past threshold. */ 90 + function beginRemovingAnimated(id: number, direction?: number) { 91 + const state = dragStates.value[id] ?? { startX: 0, currentX: 0, dragging: false, removing: false } 92 + state.removing = true 93 + state.dragging = false 94 + // direction: -1 left, +1 right. If not provided, slide up and fade (translateY) - choose left by default 95 + if (typeof direction === 'number') { 96 + // slide off-screen horizontally in that direction 97 + state.currentX = direction > 0 ? window.innerWidth : -window.innerWidth 98 + state.transition = `transform ${SWIPE_OUT_ANIM_MS}ms ease, opacity ${SWIPE_OUT_ANIM_MS}ms ease` 99 + } else { 100 + // fallback: slide up a bit while fading 101 + state.transition = `transform ${SWIPE_OUT_ANIM_MS}ms ease, opacity ${SWIPE_OUT_ANIM_MS}ms ease` 102 + // Using translateY to somewhat match other leave animations 103 + // We'll encode this by storing a special currentX === NaN to indicate translateY-out 104 + state.currentX = 0 105 + } 106 + dragStates.value[id] = state 107 + 108 + // clear auto-dismiss if any 109 + clearAutoDismiss(id) 110 + 111 + // wait for animation to finish then remove 112 + window.setTimeout(() => { 113 + removeToastById(id) 114 + }, SWIPE_OUT_ANIM_MS + 10) 115 + } 116 + 117 + /* Remove toast immediately and clear state/timers */ 118 + function removeToastById(id: number) { 119 + clearAutoDismiss(id) 120 + delete dragStates.value[id] 121 + const idx = toasts.value.findIndex((t) => t.id === id) 122 + if (idx !== -1) { 123 + toasts.value.splice(idx, 1) 124 + } 125 + } 126 + 127 + /* Pointer handlers: 128 + - pointerdown: capture pointer, initialize drag state and clear auto-dismiss 129 + - pointermove: update currentX and set dragging flag 130 + - pointerup/pointercancel: decide whether to dismiss or reset 131 + */ 132 + function onPointerDown(id: number, e: PointerEvent) { 133 + // only left button or touch 134 + if (e.pointerType === 'mouse' && e.button !== 0) return 135 + 136 + const el = e.currentTarget as HTMLElement 137 + try { 138 + el.setPointerCapture(e.pointerId) 139 + } catch { 140 + // ignore if not supported / fails 141 + } 142 + 143 + clearAutoDismiss(id) 144 + const width = el.offsetWidth || 0 145 + dragStates.value[id] = { 146 + startX: e.clientX, 147 + currentX: 0, 148 + dragging: false, 149 + removing: false, 150 + pointerId: e.pointerId, 151 + width, 152 + transition: 'none', 153 + } 154 + } 155 + 156 + function onPointerMove(id: number, e: PointerEvent) { 157 + const s = dragStates.value[id] 158 + if (!s) return 159 + // ensure same pointer 160 + if (s.pointerId !== undefined && e.pointerId !== s.pointerId) return 161 + 162 + const dx = e.clientX - s.startX 163 + // if not dragging yet, check small threshold 164 + if (!s.dragging && Math.abs(dx) > SWIPE_CLICK_CANCEL_DISTANCE) { 165 + s.dragging = true 166 + } 167 + s.currentX = dx 168 + s.transition = 'none' 169 + dragStates.value[id] = s 170 + } 171 + 172 + function onPointerUp(id: number, e: PointerEvent) { 173 + const s = dragStates.value[id] 174 + const el = e.currentTarget as HTMLElement 175 + if (s?.pointerId !== undefined) { 176 + try { 177 + el.releasePointerCapture(s.pointerId) 178 + } catch { 179 + // ignore 180 + } 181 + } 182 + 183 + if (!s) { 184 + // nothing to do 185 + startAutoDismiss(id) 186 + return 187 + } 188 + 189 + const dx = s.currentX || 0 190 + const absDx = Math.abs(dx) 191 + const width = s.width ?? el.offsetWidth ?? 0 192 + const threshold = Math.max(SWIPE_REMOVAL_MIN, width * SWIPE_REMOVAL_THRESHOLD_RATIO) 193 + 194 + // if user dragged past threshold -> animate out in that direction 195 + if (absDx >= threshold) { 196 + const dir = dx > 0 ? 1 : -1 197 + beginRemovingAnimated(id, dir) 198 + return 199 + } 200 + 201 + // otherwise revert to original position 202 + s.currentX = 0 203 + s.dragging = false 204 + s.transition = `transform ${SWIPE_OUT_ANIM_MS}ms ease, opacity ${SWIPE_OUT_ANIM_MS}ms ease` 205 + dragStates.value[id] = s 206 + 207 + // clear the drag state after the transition to keep the DOM tidy 208 + window.setTimeout(() => { 209 + // only delete if not removed in the meantime 210 + const now = dragStates.value[id] 211 + if (now && !now.removing && !now.dragging) { 212 + delete dragStates.value[id] 213 + } 214 + }, SWIPE_OUT_ANIM_MS + 20) 215 + 216 + // restart auto-dismiss 217 + startAutoDismiss(id) 218 + } 219 + 220 + function onPointerCancel(id: number, e: PointerEvent) { 221 + // treat like pointerup (reset) 222 + onPointerUp(id, e) 223 + } 224 + 225 + /* Clicking should dismiss if the user wasn't dragging */ 226 + function onToastClick(id: number, _e: MouseEvent) { 227 + const s = dragStates.value[id] 228 + // if dragging, ignore click 229 + if (s?.dragging) return 230 + beginRemovingAnimated(id) 231 + } 232 + 233 + /* Pause auto-dismiss on hover; resume on leave */ 234 + function onMouseEnter(id: number) { 235 + clearAutoDismiss(id) 236 + } 237 + function onMouseLeave(id: number) { 238 + // only restart if not being dragged and toast still exists 239 + if (!dragStates.value[id]?.dragging) { 240 + startAutoDismiss(id) 241 + } 242 + } 243 + 244 + /* Inline style computed per toast id */ 245 + function toastStyle(id: number) { 246 + const s = dragStates.value[id] 247 + const base: Record<string, string> = {} 248 + // Default: no transform 249 + let transform = 'translateX(0)' 250 + let opacity = '1' 251 + let transition = 252 + s?.transition ?? `transform ${SWIPE_OUT_ANIM_MS}ms ease, opacity ${SWIPE_OUT_ANIM_MS}ms ease` 253 + 254 + if (s) { 255 + // If removing and we used horizontal slide (currentX large magnitude), use that 256 + if (s.removing && Math.abs(s.currentX) > 0) { 257 + transform = `translateX(${s.currentX}px)` 258 + opacity = '0' 259 + } else if (s.removing && s.currentX === 0) { 260 + // translateY-out fallback 261 + transform = `translateY(-0.5rem)` 262 + opacity = '0' 263 + } else if (s.dragging) { 264 + transform = `translateX(${s.currentX}px)` 265 + // reduce opacity slightly as it's dragged farther 266 + const w = s.width ?? 200 267 + const a = Math.min(1, Math.abs(s.currentX) / (w * 0.8)) 268 + opacity = String(1 - Math.min(0.6, a * 0.6)) 269 + transition = 'none' 270 + } else { 271 + transform = `translateX(${s.currentX}px)` 272 + } 273 + } 274 + 275 + base.transform = transform 276 + base.opacity = opacity 277 + base.transition = transition 278 + return base 279 + } 280 + 281 + /* Start auto-dismiss timers for any pre-existing toasts */ 282 + nextTick(() => { 283 + toasts.value.forEach((t) => startAutoDismiss(t.id)) 284 + }) 285 + 286 + /* Clean up timers on unmount */ 287 + onBeforeUnmount(() => { 288 + autoTimers.forEach((v) => window.clearTimeout(v)) 289 + autoTimers.clear() 290 + }) 291 + </script> 292 + 293 + <template> 294 + <Teleport to="body"> 295 + <!-- Use TransitionGroup so entering/leaving animations run for "natural" adds/removes. 296 + Note: swiped-away toasts use manual animated removal and are removed after the animation. --> 297 + <TransitionGroup name="toast" tag="div" class="toast-stack"> 298 + <div 299 + v-for="toast in toasts" 300 + :key="toast.id" 301 + :class="['toast', toast.type]" 302 + :style="toastStyle(toast.id)" 303 + aria-live="polite" 304 + role="status" 305 + @click="() => onToastClick(toast.id, $event)" 306 + @pointerdown="(e) => onPointerDown(toast.id, e)" 307 + @pointermove="(e) => onPointerMove(toast.id, e)" 308 + @pointerup="(e) => onPointerUp(toast.id, e)" 309 + @pointercancel="(e) => onPointerCancel(toast.id, e)" 310 + @mouseenter="() => onMouseEnter(toast.id)" 311 + @mouseleave="() => onMouseLeave(toast.id)" 312 + > 313 + <div class="icon"> 314 + <component :is="toastIcons[toast.type]" /> 315 + </div> 316 + <span class="message">{{ toast.message }}</span> 317 + </div> 318 + </TransitionGroup> 319 + </Teleport> 320 + </template> 321 + 322 + <style scoped lang="scss"> 323 + .toast-stack { 324 + position: fixed; 325 + bottom: calc(5rem + env(safe-area-inset-bottom)); 326 + left: 50%; 327 + transform: translateX(-50%); 328 + z-index: 1000; 329 + 330 + display: flex; 331 + flex-direction: column; 332 + align-items: flex-start; 333 + gap: 0.75rem; 334 + 335 + width: 100%; 336 + padding: 0 1rem; 337 + pointer-events: none; /* container doesn't block; individual toasts receive events */ 338 + } 339 + 340 + /* Durations used by TransitionGroup (enter/leave for new toasts) */ 341 + $enter-duration: 240ms; 342 + $leave-duration: 180ms; 343 + $move-duration: 200ms; 344 + 345 + .toast { 346 + display: inline-flex; 347 + align-items: center; 348 + justify-content: center; 349 + 350 + max-width: var(--content-width); 351 + 352 + background-color: hsla(var(--surface0) / 1); 353 + padding: 0.25rem 0.5rem; 354 + padding-right: 1rem; 355 + gap: 0.5rem; 356 + border-radius: 2rem; 357 + 358 + pointer-events: auto; /* allow interactions on the toast itself */ 359 + 360 + &.success { 361 + border: 2px solid hsla(var(--green) / 0.5); 362 + } 363 + &.error { 364 + border: 2px solid hsla(var(--red) / 0.5); 365 + } 366 + &.info { 367 + border: 2px solid hsla(var(--accent) / 0.5); 368 + } 369 + 370 + .icon { 371 + font-size: 1.5rem; 372 + display: flex; 373 + align-items: center; 374 + justify-content: center; 375 + } 376 + } 377 + 378 + /* Transition classes generated by TransitionGroup with name="toast" */ 379 + /* Entering: start slightly lower and faded, then slide to natural position */ 380 + .toast-enter-from { 381 + opacity: 0; 382 + transform: translateY(0.5rem) scale(0.99); 383 + } 384 + .toast-enter-active { 385 + transition: 386 + opacity $enter-duration ease, 387 + transform $enter-duration cubic-bezier(0.2, 0.9, 0.2, 1); 388 + } 389 + .toast-enter-to { 390 + opacity: 1; 391 + transform: translateY(0) scale(1); 392 + } 393 + 394 + /* Leaving: fade and slide down a bit */ 395 + .toast-leave-from { 396 + opacity: 1; 397 + transform: translateY(0) scale(1); 398 + } 399 + .toast-leave-active { 400 + transition: 401 + opacity $leave-duration ease, 402 + transform $leave-duration cubic-bezier(0.2, 0.9, 0.2, 1); 403 + } 404 + .toast-leave-to { 405 + opacity: 0; 406 + transform: translateY(0.5rem) scale(0.99); 407 + } 408 + 409 + /* When items reorder / move */ 410 + .toast-move { 411 + transition: transform $move-duration cubic-bezier(0.2, 0.9, 0.2, 1); 412 + will-change: transform; 413 + } 414 + </style>