wip bsky client for the web & android
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: replies!!

willow c71715f0 c464b8b8

+973 -73
+26 -18
src/App.vue
··· 59 59 60 60 <template> 61 61 <Transition name="intro-fade"> 62 - <OnboardingFlow v-if="showIntro" @complete="onIntroComplete" /> 62 + <OnboardingFlow v-if="showIntro" @complete="onIntroComplete('stay')" /> 63 63 </Transition> 64 64 65 65 <Transition name="app-fade" mode="in-out"> 66 66 <div v-if="!showIntro" class="app-root"> 67 - <OAuthCallback v-if="isCallback" @complete="onAuthComplete" class="view-layer" /> 68 - 69 - <div v-else class="app-shell view-layer"> 70 - <div class="skip-links"> 71 - <a href="#main-content" id="skip-to-content" class="skip-link"> skip to main content </a> 72 - <a href="#navigation-bar" class="skip-link"> skip to navigation </a> 73 - </div> 67 + <Transition name="app-fade" mode="out-in"> 68 + <OAuthCallback 69 + v-if="isCallback" 70 + @complete="onAuthComplete" 71 + class="view-layer" 72 + :key="'callback'" 73 + /> 74 + <div v-else class="app-shell view-layer" :key="'shell'"> 75 + <div class="skip-links"> 76 + <a href="#main-content" id="skip-to-content" class="skip-link"> 77 + skip to main content 78 + </a> 79 + <a href="#navigation-bar" class="skip-link"> skip to navigation </a> 80 + </div> 74 81 75 - <div class="viewport" id="main-content"> 76 - <TabStack 77 - v-for="t in tabs" 78 - :key="t" 79 - :tab="t" 80 - v-show="activeTab === t" 81 - :class="{ active: activeTab === t }" 82 - /> 82 + <div class="viewport" id="main-content"> 83 + <TabStack 84 + v-for="t in tabs" 85 + :key="t" 86 + :tab="t" 87 + v-show="activeTab === t" 88 + :class="{ active: activeTab === t }" 89 + /> 90 + </div> 91 + <NavigationBar ref="navBar" /> 83 92 </div> 84 - <NavigationBar ref="navBar" /> 85 - </div> 93 + </Transition> 86 94 </div> 87 95 </Transition> 88 96 </template>
+4
src/assets/main.css
··· 22 22 outline-offset: 4px; 23 23 24 24 transition: 25 + grid-template-rows var(--transition), 26 + grid-template-columns var(--transition), 27 + stroke-dasharray var(--transition), 28 + stroke-dashoffset var(--transition), 25 29 outline-color var(--transition), 26 30 outline-offset var(--transition), 27 31 color var(--transition),
+13 -2
src/components/Feed/Embeds/EmbedRecord.vue
··· 21 21 :embedded="true" 22 22 /> 23 23 24 - <div v-else class="not-found"> 25 - {{ messages[embed.record.$type] || 'Unknown embed type.' }} 24 + <div v-else class="not-found" @click.stop> 25 + <template v-if="embed.record.$type in Object.keys(messages)"> 26 + <p>{{ messages[embed.record.$type] || 'Unknown embed type.' }}</p> 27 + </template> 28 + <template v-else> 29 + <p>Unknown embed type "{{ embed.record.$type }}".</p> 30 + <pre>{{ JSON.stringify(embed.record, null, 2) }}</pre> 31 + </template> 26 32 </div> 27 33 </div> 28 34 </template> ··· 33 39 } 34 40 35 41 .not-found { 42 + cursor: default; 36 43 padding: 0.75rem; 37 44 border: 1px solid hsla(var(--surface2) / 0.5); 38 45 border-radius: var(--radius-md); 39 46 background-color: hsla(var(--surface0) / 0.3); 40 47 color: hsl(var(--subtext0)); 41 48 font-size: 0.9rem; 49 + } 50 + 51 + pre { 52 + overflow-x: auto; 42 53 } 43 54 </style>
+822
src/components/Feed/ReplyComposer.vue
··· 1 + <script setup lang="ts"> 2 + import { ref, computed, nextTick, onUnmounted } from 'vue' 3 + import { 4 + IconImageRounded, 5 + IconSentimentSatisfiedRounded, 6 + IconLanguage as IconLanguageRounded, 7 + IconCloseRounded, 8 + IconVideocamRounded, 9 + IconErrorRounded, 10 + } from '@iconify-prerendered/vue-material-symbols' 11 + 12 + import { Client, ok, simpleFetchHandler } from '@atcute/client' 13 + import type { Did } from '@atcute/lexicons' 14 + import type { 15 + AppBskyFeedDefs, 16 + AppBskyEmbedImages, 17 + AppBskyEmbedVideo, 18 + AppBskyFeedPost, 19 + } from '@atcute/bluesky' 20 + 21 + import TextArea from '@/components/UI/TextArea.vue' 22 + import BaseButton from '@/components/UI/BaseButton.vue' 23 + 24 + import type { CollectionString } from '@/types/atproto' 25 + import { MAX_POST_IMAGE_SIZE, MAX_POST_VIDEO_SIZE, MAX_POST_TEXT_LENGTH } from '@/utils/constants' 26 + import { formatSize } from '@/utils/formatting' 27 + 28 + import { usePostStore } from '@/stores/posts' 29 + import { useAuthStore } from '@/stores/auth' 30 + 31 + const props = defineProps<{ 32 + replyTo: AppBskyFeedDefs.PostView 33 + rootPost: AppBskyFeedDefs.PostView 34 + }>() 35 + 36 + const emit = defineEmits<{ 37 + (e: 'success'): void 38 + }>() 39 + 40 + const store = usePostStore() 41 + const auth = useAuthStore() 42 + 43 + const text = ref('') 44 + const loading = ref(false) 45 + const status = ref('') 46 + 47 + const errors = ref<string[]>([]) 48 + const isExpanded = ref(false) 49 + 50 + const images = ref<File[]>([]) 51 + const imagePreviews = ref<string[]>([]) 52 + const video = ref<File | null>(null) 53 + const videoPreview = ref<string | null>(null) 54 + 55 + const fileInput = ref<HTMLInputElement | null>(null) 56 + 57 + const charCount = computed(() => text.value.length) 58 + const charsRemaining = computed(() => MAX_POST_TEXT_LENGTH - charCount.value) 59 + const countColour = computed(() => { 60 + const THRESHOLD = 15 61 + if (charsRemaining.value < 0) return 'hsl(var(--red))' 62 + if (charsRemaining.value > THRESHOLD) return 'hsl(var(--subtext0))' 63 + 64 + const percent = (THRESHOLD - charsRemaining.value) / THRESHOLD 65 + return `color-mix(in srgb, hsl(var(--subtext0)) ${100 - percent * 100}%, hsl(var(--red)))` 66 + }) 67 + const circumference = computed(() => 2 * Math.PI * 9.9155) 68 + const progressDashOffset = computed( 69 + () => circumference.value * (1 - Math.min(charCount.value / MAX_POST_TEXT_LENGTH, 1)), 70 + ) 71 + 72 + const hasMedia = computed(() => images.value.length > 0 || video.value !== null) 73 + const hasVideo = computed(() => video.value !== null) 74 + 75 + async function getVideoRpc(lxm: CollectionString, aud: Did = 'did:web:video.bsky.app') { 76 + const _rpc = auth.getRpc() 77 + 78 + const token = ok( 79 + await _rpc.get('com.atproto.server.getServiceAuth', { 80 + params: { 81 + aud: aud, 82 + lxm: lxm, 83 + }, 84 + }), 85 + ).token 86 + 87 + const handler = simpleFetchHandler({ service: 'https://video.bsky.app' }) 88 + const rpc = new Client({ handler }) 89 + 90 + return { rpc, token } 91 + } 92 + 93 + async function canUploadVideo() { 94 + const { rpc, token } = await getVideoRpc('app.bsky.video.getUploadLimits') 95 + 96 + const uploadLimits = ok( 97 + await rpc.get('app.bsky.video.getUploadLimits', { 98 + headers: { 99 + Authorization: `Bearer ${token}`, 100 + }, 101 + }), 102 + ) 103 + 104 + return uploadLimits 105 + } 106 + 107 + async function processImages(): Promise<AppBskyEmbedImages.Main> { 108 + const blobs: AppBskyEmbedImages.Image[] = [] 109 + 110 + for (let i = 0; i < images.value.length; i++) { 111 + const file = images.value[i] 112 + status.value = `Uploading image ${i + 1}/${images.value.length}...` 113 + if (!file) throw new Error('No file selected') 114 + 115 + try { 116 + const rpc = auth.getRpc() 117 + const data = ok( 118 + await rpc.post('com.atproto.repo.uploadBlob', { 119 + input: await file.arrayBuffer(), 120 + headers: { 121 + 'Content-Type': file.type, 122 + }, 123 + }), 124 + ) 125 + 126 + blobs.push({ 127 + alt: '', 128 + image: data.blob, 129 + }) 130 + } catch (e) { 131 + console.error('Blob upload failed', e) 132 + throw new Error(`Failed to upload ${file.name}`) 133 + } 134 + } 135 + 136 + return { 137 + $type: 'app.bsky.embed.images' as const, 138 + images: blobs, 139 + } 140 + } 141 + 142 + async function processVideo(): Promise<AppBskyEmbedVideo.Main> { 143 + if (!video.value) throw new Error('No video selected') 144 + status.value = 'Uploading video...' 145 + 146 + try { 147 + const { rpc: uploadRpc, token: uploadToken } = await getVideoRpc( 148 + 'com.atproto.repo.uploadBlob', 149 + `did:web:${auth.session?.info.server.issuer.replace('https://', '')}`, 150 + ) 151 + 152 + const uploadRes = await uploadRpc.post('app.bsky.video.uploadVideo', { 153 + input: await video.value.arrayBuffer(), 154 + params: { 155 + did: auth.userDid, 156 + name: video.value.name, 157 + }, 158 + headers: { 159 + 'Content-Type': video.value.type, 160 + Authorization: `Bearer ${uploadToken}`, 161 + }, 162 + }) 163 + 164 + if (!('did' in uploadRes.data)) { 165 + throw new Error(`Video upload failed with status ${uploadRes.status}`) 166 + } 167 + 168 + // @ts-expect-error: types are incorrect, there is no jobStatus object. 169 + const jobId = uploadRes.data.jobId as string 170 + 171 + const { rpc: jobRpc, token: jobToken } = await getVideoRpc('app.bsky.video.getJobStatus') 172 + const jobRes = await jobRpc.get('app.bsky.video.getJobStatus', { 173 + params: { 174 + did: auth.profile?.did, 175 + jobId: jobId, 176 + }, 177 + headers: { 178 + Authorization: `Bearer ${jobToken}`, 179 + }, 180 + }) 181 + 182 + if (!jobRes.ok) throw new Error(`Video processing failed with status ${jobRes.status}`) 183 + if (jobRes.data.jobStatus.state === 'JOB_STATE_FAILED') 184 + throw new Error(`Video processing error: ${jobRes.data.jobStatus.message}`) 185 + 186 + const { blob } = jobRes.data.jobStatus 187 + if (!blob) throw new Error('Video processing did not return a blob') 188 + 189 + return { 190 + $type: 'app.bsky.embed.video' as const, 191 + video: blob, 192 + } 193 + } catch (e) { 194 + console.error('Video upload failed', e) 195 + throw new Error(`Failed to upload video`) 196 + } 197 + } 198 + 199 + async function processMedia(): Promise<AppBskyFeedPost.Main['embed'] | undefined> { 200 + if (video.value) { 201 + const embed = await processVideo() 202 + return embed ? (embed as AppBskyFeedPost.Main['embed']) : undefined 203 + } else if (images.value.length > 0) { 204 + const embed = await processImages() 205 + return embed ? (embed as AppBskyFeedPost.Main['embed']) : undefined 206 + } 207 + 208 + return undefined 209 + } 210 + 211 + async function submit() { 212 + if ((!text.value.trim() && !hasMedia.value) || charsRemaining.value < 0) return 213 + 214 + loading.value = true 215 + errors.value = [] 216 + status.value = 'Preparing...' 217 + 218 + try { 219 + let embed: AppBskyFeedPost.Main['embed'] | undefined 220 + if (hasMedia.value) embed = await processMedia() 221 + status.value = 'Sending...' 222 + 223 + await store.reply( 224 + { uri: props.replyTo.uri, cid: props.replyTo.cid }, 225 + { uri: props.rootPost.uri, cid: props.rootPost.cid }, 226 + text.value, 227 + embed, 228 + ) 229 + 230 + reset() 231 + emit('success') 232 + } catch (err) { 233 + console.error('Failed to reply', err) 234 + if (err instanceof Error) errors.value = [err.message] 235 + else errors.value = ['Failed to send reply'] 236 + } finally { 237 + loading.value = false 238 + status.value = '' 239 + } 240 + } 241 + 242 + function expand() { 243 + if (!auth.isAuthenticated || isExpanded.value) return 244 + 245 + isExpanded.value = true 246 + nextTick(() => { 247 + document.getElementById('reply-input')?.focus() 248 + }) 249 + 250 + document.addEventListener('keydown', (e) => { 251 + if (e.key === 'Escape') { 252 + collapse() 253 + } 254 + }) 255 + } 256 + 257 + function collapse() { 258 + if (!text.value && !hasMedia.value) reset() 259 + } 260 + 261 + function reset() { 262 + isExpanded.value = false 263 + text.value = '' 264 + errors.value = [] 265 + status.value = '' 266 + 267 + images.value = [] 268 + imagePreviews.value.forEach((url) => URL.revokeObjectURL(url)) 269 + imagePreviews.value = [] 270 + 271 + if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 272 + video.value = null 273 + videoPreview.value = null 274 + 275 + if (fileInput.value) fileInput.value.value = '' 276 + } 277 + 278 + function triggerImageSelect() { 279 + fileInput.value?.click() 280 + } 281 + 282 + function validateFileSize(file: File, isVideo: boolean): string | null { 283 + const maxSize = isVideo ? MAX_POST_VIDEO_SIZE : MAX_POST_IMAGE_SIZE 284 + const type = isVideo ? 'Video' : 'Image' 285 + 286 + if (file.size > maxSize) { 287 + return `${type} "${file.name}" is too large (${formatSize(file.size)}). Maximum size is ${formatSize(maxSize)}.` 288 + } 289 + 290 + return null 291 + } 292 + 293 + async function handleFileSelect(event: Event) { 294 + const input = event.target as HTMLInputElement 295 + if (!input.files || input.files.length === 0) return 296 + 297 + errors.value = [] 298 + const currentErrors: string[] = [] 299 + 300 + const newFiles = Array.from(input.files) 301 + const videoFiles = newFiles.filter((file) => file.type.startsWith('video/')) 302 + const imageFiles = newFiles.filter((file) => file.type.startsWith('image/')) 303 + 304 + if (videoFiles.length > 0 && imageFiles.length > 0) 305 + currentErrors.push('You can only upload one video or up to four images, not both.') 306 + if (videoFiles.length > 0 && images.value.length > 0) 307 + currentErrors.push('You already have images attached. Remove them to add a video.') 308 + if (imageFiles.length > 0 && video.value) 309 + currentErrors.push('You already have a video attached. Remove it to add images.') 310 + if (videoFiles.length > 1) currentErrors.push('You can only upload one video at a time.') 311 + if (videoFiles.length === 1 && video.value) 312 + currentErrors.push('You can only upload one video. Remove the current one first.') 313 + 314 + const totalImages = images.value.length + imageFiles.length 315 + if (totalImages > 4) 316 + currentErrors.push( 317 + `You can only upload up to four images. You have ${images.value.length}, trying to add ${imageFiles.length}.`, 318 + ) 319 + 320 + if (currentErrors.length > 0) { 321 + errors.value = currentErrors 322 + input.value = '' 323 + return 324 + } 325 + 326 + if (videoFiles.length === 1) { 327 + const uploadLimits = await canUploadVideo() 328 + if (!uploadLimits.canUpload) { 329 + if (uploadLimits.message) errors.value.push(uploadLimits.message) 330 + 331 + if ( 332 + (uploadLimits.remainingDailyBytes && uploadLimits.remainingDailyBytes <= 0) || 333 + (uploadLimits.remainingDailyVideos && uploadLimits.remainingDailyVideos <= 0) 334 + ) { 335 + errors.value.push("You've hit your daily video upload quota.") 336 + } else if ( 337 + uploadLimits.remainingDailyBytes && 338 + uploadLimits.remainingDailyBytes < video.value!.size 339 + ) { 340 + errors.value.push( 341 + `Video is too large for remaining quota (${formatSize(uploadLimits.remainingDailyBytes)} left).`, 342 + ) 343 + } 344 + 345 + return 346 + } 347 + 348 + const file = videoFiles[0] 349 + if (!file) return 350 + const sizeError = validateFileSize(file, true) 351 + 352 + if (sizeError) { 353 + errors.value = [sizeError] 354 + input.value = '' 355 + return 356 + } 357 + 358 + video.value = file 359 + videoPreview.value = URL.createObjectURL(file) 360 + input.value = '' 361 + return 362 + } 363 + 364 + const sizeErrors: string[] = [] 365 + for (const file of imageFiles) { 366 + const sizeError = validateFileSize(file, false) 367 + if (sizeError) sizeErrors.push(sizeError) 368 + } 369 + 370 + if (sizeErrors.length > 0) { 371 + errors.value = sizeErrors 372 + input.value = '' 373 + return 374 + } 375 + 376 + for (const file of imageFiles) { 377 + images.value.push(file) 378 + imagePreviews.value.push(URL.createObjectURL(file)) 379 + } 380 + 381 + input.value = '' 382 + } 383 + 384 + function removeImage(index: number) { 385 + const image = imagePreviews.value[index] 386 + if (!image) return 387 + URL.revokeObjectURL(image) 388 + imagePreviews.value.splice(index, 1) 389 + images.value.splice(index, 1) 390 + errors.value = [] 391 + } 392 + 393 + function removeVideo() { 394 + if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 395 + video.value = null 396 + videoPreview.value = null 397 + errors.value = [] 398 + } 399 + 400 + function handleKeydown(e: KeyboardEvent) { 401 + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { 402 + submit() 403 + } 404 + } 405 + 406 + onUnmounted(() => { 407 + imagePreviews.value.forEach((url) => URL.revokeObjectURL(url)) 408 + if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 409 + }) 410 + </script> 411 + 412 + <template> 413 + <div 414 + class="reply-composer" 415 + :class="{ 416 + 'is-expanded': isExpanded, 417 + 'is-active': auth.isAuthenticated, 418 + 'has-error': charsRemaining < 0, 419 + }" 420 + > 421 + <div class="layout"> 422 + <div class="avatar-col"> 423 + <img 424 + v-if="auth.isAuthenticated && auth.profile?.avatar" 425 + :src="auth.profile.avatar" 426 + alt="Avatar" 427 + loading="lazy" 428 + /> 429 + <div v-else class="avatar-fallback"></div> 430 + </div> 431 + 432 + <div class="content-col"> 433 + <div class="input-area" @click="expand" @focusin="expand"> 434 + <TextArea 435 + id="reply-input" 436 + v-model="text" 437 + placeholder="Write your reply..." 438 + :rows="1" 439 + :disabled="!auth.isAuthenticated || loading" 440 + class="composer-textarea" 441 + @keydown="handleKeydown" 442 + /> 443 + 444 + <div v-if="videoPreview" class="video-preview"> 445 + <div class="preview-item video-item"> 446 + <video :src="videoPreview" muted preload="metadata" /> 447 + <div class="video-badge"> 448 + <IconVideocamRounded /> 449 + </div> 450 + <button class="remove-btn" @click.stop="removeVideo" :disabled="loading"> 451 + <IconCloseRounded /> 452 + </button> 453 + </div> 454 + </div> 455 + 456 + <div v-else-if="imagePreviews.length > 0" class="image-previews"> 457 + <div v-for="(src, idx) in imagePreviews" :key="idx" class="preview-item"> 458 + <img :src="src" alt="preview" /> 459 + <button class="remove-btn" @click.stop="removeImage(idx)" :disabled="loading"> 460 + <IconCloseRounded /> 461 + </button> 462 + </div> 463 + </div> 464 + </div> 465 + 466 + <div v-if="errors.length > 0" class="error-container"> 467 + <div v-for="(err, idx) in errors" :key="idx" class="error-item"> 468 + <IconErrorRounded /> 469 + <span>{{ err }}</span> 470 + </div> 471 + </div> 472 + 473 + <input 474 + type="file" 475 + ref="fileInput" 476 + multiple 477 + accept="image/png, image/jpeg, image/webp, video/mp4, video/webm, video/quicktime" 478 + style="display: none" 479 + @change="handleFileSelect" 480 + /> 481 + 482 + <div class="actions-drawer"> 483 + <div class="actions-inner"> 484 + <div class="toolbar"> 485 + <div class="tools-left"> 486 + <BaseButton 487 + variant="ghost" 488 + icon 489 + @click.stop="triggerImageSelect" 490 + :disabled="(images.length >= 4 && !hasVideo) || loading" 491 + :title="hasVideo ? 'Remove video to add images' : 'Add Image or Video'" 492 + > 493 + <IconImageRounded /> 494 + </BaseButton> 495 + <BaseButton variant="ghost" icon title="Add Emoji" :disabled="loading"> 496 + <IconSentimentSatisfiedRounded /> 497 + </BaseButton> 498 + <div class="divider"></div> 499 + <BaseButton variant="ghost" class="lang-btn" :disabled="loading"> 500 + <IconLanguageRounded /> 501 + <span>English</span> 502 + </BaseButton> 503 + </div> 504 + 505 + <div class="tools-right"> 506 + <div 507 + class="char-counter" 508 + :style="{ color: countColour }" 509 + :class="{ danger: charsRemaining < 20 }" 510 + > 511 + <svg width="24" height="24" viewBox="0 0 24 24" class="circular-chart"> 512 + <circle class="circle-bg" cx="12" cy="12" r="9.9155" /> 513 + <circle 514 + class="circle" 515 + cx="12" 516 + cy="12" 517 + r="9.9155" 518 + :stroke-dasharray="circumference" 519 + :stroke-dashoffset="progressDashOffset" 520 + style="transform: rotate(-90deg); transform-origin: center" 521 + /> 522 + </svg> 523 + <span class="counter-text">{{ charsRemaining }}</span> 524 + </div> 525 + 526 + <BaseButton 527 + variant="primary" 528 + :loading="loading" 529 + :disabled="(!text.trim() && !hasMedia) || charsRemaining < 0 || loading" 530 + @click.stop="submit" 531 + > 532 + {{ loading && status ? status : 'Reply' }} 533 + </BaseButton> 534 + </div> 535 + </div> 536 + </div> 537 + </div> 538 + </div> 539 + </div> 540 + </div> 541 + </template> 542 + 543 + <style scoped lang="scss"> 544 + .reply-composer { 545 + padding: 1rem; 546 + border-top: 1px solid hsla(var(--surface2) / 0.5); 547 + border-bottom: 1px solid hsla(var(--surface2) / 0.5); 548 + background-color: hsla(var(--surface1) / 0.05); 549 + 550 + &.is-active:hover { 551 + background-color: hsla(var(--surface1) / 0.15); 552 + } 553 + 554 + &.is-expanded { 555 + background-color: hsla(var(--surface1) / 0.1); 556 + :deep(textarea) { 557 + background: hsla(var(--mantle) / 0.75); 558 + } 559 + } 560 + } 561 + 562 + .layout { 563 + display: flex; 564 + gap: 0.75rem; 565 + } 566 + 567 + .avatar-col { 568 + flex-shrink: 0; 569 + width: 2.5rem; 570 + height: 2.5rem; 571 + margin-top: 2px; 572 + 573 + img { 574 + width: 100%; 575 + height: 100%; 576 + border-radius: 50%; 577 + object-fit: cover; 578 + background-color: hsl(var(--surface1)); 579 + } 580 + .avatar-fallback { 581 + width: 100%; 582 + height: 100%; 583 + border-radius: 50%; 584 + background-color: hsl(var(--surface2)); 585 + } 586 + } 587 + 588 + .content-col { 589 + flex: 1; 590 + display: flex; 591 + flex-direction: column; 592 + min-width: 0; 593 + } 594 + 595 + .input-area { 596 + :deep(.input-group) { 597 + margin-bottom: 0; 598 + } 599 + :deep(textarea) { 600 + min-height: 2.75rem; 601 + height: 2.75rem; 602 + padding: 0.5rem; 603 + background: transparent; 604 + border: none !important; 605 + resize: none; 606 + cursor: pointer; 607 + font-size: 1rem; 608 + transition: all 0.2s ease; 609 + 610 + &::placeholder { 611 + color: hsl(var(--subtext0)); 612 + } 613 + } 614 + } 615 + 616 + .is-expanded .input-area { 617 + :deep(textarea) { 618 + min-height: 5rem; 619 + height: auto; 620 + cursor: text; 621 + } 622 + } 623 + 624 + .image-previews, 625 + .video-preview { 626 + display: flex; 627 + gap: 0.5rem; 628 + margin-top: 0.5rem; 629 + padding-bottom: 0.5rem; 630 + overflow-x: auto; 631 + } 632 + 633 + .preview-item { 634 + position: relative; 635 + width: 15rem; 636 + height: 15rem; 637 + flex-shrink: 0; 638 + border-radius: var(--radius-md); 639 + border: 1px solid hsla(var(--surface2) / 0.5); 640 + overflow: hidden; 641 + 642 + img, 643 + video { 644 + width: 100%; 645 + height: 100%; 646 + object-fit: cover; 647 + } 648 + 649 + &.video-item { 650 + width: 16rem; 651 + height: unset; 652 + aspect-ratio: var(--aspect-ratio, 16 / 9); 653 + } 654 + 655 + .video-badge { 656 + position: absolute; 657 + top: 0.25rem; 658 + right: calc(0.25rem + 1.25rem + 0.25rem); 659 + 660 + padding: 0.25rem 0.7rem; 661 + display: flex; 662 + align-items: center; 663 + justify-content: center; 664 + 665 + svg { 666 + width: 0.875rem; 667 + height: 0.875rem; 668 + } 669 + } 670 + 671 + .remove-btn, 672 + .video-badge { 673 + background: hsla(var(--surface0) / 1); 674 + color: hsl(var(--text)); 675 + border-radius: var(--radius-xsm); 676 + min-width: 1.25rem; 677 + height: 1.25rem; 678 + &:hover { 679 + background: hsla(var(--surface1) / 0.9); 680 + } 681 + } 682 + 683 + .remove-btn { 684 + position: absolute; 685 + top: 0.25rem; 686 + right: 0.25rem; 687 + 688 + border: none; 689 + display: flex; 690 + align-items: center; 691 + justify-content: center; 692 + cursor: pointer; 693 + 694 + &:disabled { 695 + cursor: not-allowed; 696 + opacity: 0.5; 697 + } 698 + svg { 699 + width: 0.9rem; 700 + height: 0.9rem; 701 + } 702 + } 703 + } 704 + 705 + .error-container { 706 + margin-top: 0.5rem; 707 + display: flex; 708 + flex-direction: column; 709 + gap: 0.25rem; 710 + 711 + .error-item { 712 + display: flex; 713 + align-items: center; 714 + gap: 0.5rem; 715 + font-size: 0.85rem; 716 + color: hsl(var(--red)); 717 + background-color: hsla(var(--red) / 0.1); 718 + padding: 0.5rem; 719 + border-radius: var(--radius-sm); 720 + 721 + svg { 722 + flex-shrink: 0; 723 + width: 1rem; 724 + height: 1rem; 725 + } 726 + } 727 + } 728 + 729 + .actions-drawer { 730 + display: grid; 731 + grid-template-rows: 0fr; 732 + } 733 + 734 + .is-expanded .actions-drawer { 735 + grid-template-rows: 1fr; 736 + } 737 + 738 + .actions-inner { 739 + position: relative; 740 + 741 + .toolbar { 742 + padding-top: 0.5rem; 743 + display: flex; 744 + justify-content: space-between; 745 + align-items: center; 746 + opacity: 0; 747 + transform: translateY(-5px); 748 + position: absolute; 749 + } 750 + 751 + .tools-left { 752 + display: flex; 753 + align-items: center; 754 + gap: 0.25rem; 755 + color: hsl(var(--accent)); 756 + 757 + .divider { 758 + width: 1px; 759 + height: 1.25rem; 760 + background-color: hsla(var(--surface2) / 0.5); 761 + margin: 0 0.25rem; 762 + } 763 + 764 + .lang-btn { 765 + font-size: 0.8rem; 766 + font-weight: 600; 767 + gap: 0.35rem; 768 + color: hsl(var(--subtext0)); 769 + } 770 + } 771 + 772 + .tools-right { 773 + display: flex; 774 + align-items: center; 775 + gap: 0.75rem; 776 + } 777 + } 778 + 779 + .is-expanded .toolbar { 780 + opacity: 1; 781 + transform: translateY(0); 782 + transition-delay: 0.1s; 783 + position: static; 784 + } 785 + 786 + .char-counter { 787 + position: relative; 788 + display: flex; 789 + align-items: center; 790 + justify-content: center; 791 + width: 1.5rem; 792 + height: 1.5rem; 793 + 794 + .circular-chart { 795 + transform: rotate(-90deg); 796 + width: 100%; 797 + height: 100%; 798 + } 799 + 800 + .circle-bg { 801 + fill: none; 802 + stroke: hsla(var(--surface2) / 0.5); 803 + stroke-width: 2.5; 804 + } 805 + 806 + .circle { 807 + fill: none; 808 + stroke: currentColor; 809 + stroke-width: 2.5; 810 + stroke-linecap: round; 811 + } 812 + 813 + .counter-text { 814 + position: absolute; 815 + font-size: 0.7rem; 816 + font-weight: 700; 817 + color: currentColor; 818 + right: 1.75rem; 819 + white-space: nowrap; 820 + } 821 + } 822 + </style>
+44 -44
src/components/UI/TextArea.vue
··· 2 2 import { useId } from 'vue' 3 3 4 4 defineProps<{ 5 - label?: string 6 - placeholder?: string 7 - rows?: number 8 - error?: string 5 + label?: string 6 + placeholder?: string 7 + rows?: number 8 + error?: string 9 9 }>() 10 10 11 11 const model = defineModel<string>() ··· 13 13 </script> 14 14 15 15 <template> 16 - <div class="input-group"> 17 - <label v-if="label" :for="id" class="label">{{ label }}</label> 18 - <textarea 19 - :id="id" 20 - v-model="model" 21 - :rows="rows || 3" 22 - :placeholder="placeholder" 23 - class="input textarea" 24 - :class="{ 'has-error': error }" 25 - ></textarea> 26 - <span v-if="error" class="error-text">{{ error }}</span> 27 - </div> 16 + <div class="input-group"> 17 + <label v-if="label" :for="id" class="label">{{ label }}</label> 18 + <textarea 19 + :id="id" 20 + v-model="model" 21 + :rows="rows || 3" 22 + :placeholder="placeholder" 23 + class="input textarea" 24 + :class="{ 'has-error': error }" 25 + ></textarea> 26 + <span v-if="error" class="error-text">{{ error }}</span> 27 + </div> 28 28 </template> 29 29 30 30 <style scoped> 31 31 .input-group { 32 - display: flex; 33 - flex-direction: column; 34 - gap: 0.25rem; 35 - width: 100%; 36 - margin-bottom: 1rem; 32 + display: flex; 33 + flex-direction: column; 34 + gap: 0.25rem; 35 + width: 100%; 36 + margin-bottom: 1rem; 37 37 } 38 38 .label { 39 - font-size: 0.875rem; 40 - font-weight: 600; 41 - color: hsl(var(--subtext1)); 42 - margin-left: 0.25rem; 39 + font-size: 0.875rem; 40 + font-weight: 600; 41 + color: hsl(var(--subtext1)); 42 + margin-left: 0.25rem; 43 43 } 44 44 .input { 45 - width: 100%; 46 - padding: 0.75rem 1rem; 47 - border-radius: 0.75rem; 48 - border: 1px solid transparent; 49 - background-color: hsla(var(--surface0) / 0.3); 50 - border: 1px solid hsla(var(--surface2) / 0.5); 51 - color: hsl(var(--text)); 52 - font-size: 1rem; 53 - font-family: inherit; 54 - resize: vertical; 45 + width: 100%; 46 + padding: 0.75rem 1rem; 47 + border-radius: 0.75rem; 48 + border: 1px solid transparent; 49 + background-color: hsla(var(--surface0) / 0.3); 50 + border: 1px solid hsla(var(--surface2) / 0.5); 51 + color: hsl(var(--text)); 52 + font-size: 1rem; 53 + font-family: inherit; 54 + resize: vertical; 55 55 56 - &:focus-visible { 57 - background-color: hsla(var(--base) / 0.9); 58 - } 59 - &.has-error { 60 - border-color: hsl(var(--red)); 61 - } 56 + &:focus-visible { 57 + background-color: hsla(var(--base) / 0.9); 58 + } 59 + &.has-error { 60 + border-color: hsl(var(--red)); 61 + } 62 62 } 63 63 .error-text { 64 - font-size: 0.75rem; 65 - color: hsl(var(--red)); 66 - margin-left: 0.25rem; 64 + font-size: 0.75rem; 65 + color: hsl(var(--red)); 66 + margin-left: 0.25rem; 67 67 } 68 68 </style>
+38 -2
src/stores/posts.ts
··· 1 1 import { defineStore } from 'pinia' 2 2 import { shallowRef, reactive } from 'vue' 3 - import { AppBskyFeedDefs } from '@atcute/bluesky' 4 - import { useAuthStore } from './auth' 3 + import type { ComAtprotoRepoStrongRef } from '@atcute/atproto' 4 + import { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/bluesky' 5 5 import { ok } from '@atcute/client' 6 + 7 + import { useAuthStore } from './auth' 6 8 7 9 type PostView = AppBskyFeedDefs.PostView 8 10 ··· 144 146 } 145 147 } 146 148 149 + async function reply( 150 + parent: ComAtprotoRepoStrongRef.Main, 151 + root: ComAtprotoRepoStrongRef.Main, 152 + text: string, 153 + embeds?: AppBskyFeedPost.Main['embed'], 154 + ) { 155 + if (!auth.isAuthenticated || !auth.session) throw new Error('Not authenticated') 156 + 157 + const rpc = auth.getRpc() 158 + const record: AppBskyFeedPost.Main = { 159 + $type: 'app.bsky.feed.post', 160 + text, 161 + createdAt: new Date().toISOString(), 162 + reply: { 163 + root, 164 + parent, 165 + }, 166 + embed: embeds, 167 + } 168 + 169 + const data = await ok( 170 + rpc.post('com.atproto.repo.createRecord', { 171 + input: { 172 + collection: 'app.bsky.feed.post', 173 + repo: auth.session.info.sub, 174 + record, 175 + }, 176 + }), 177 + ) 178 + 179 + return data 180 + } 181 + 147 182 return { 148 183 posts, 149 184 mergePost, 150 185 toggleLike, 151 186 toggleRepost, 187 + reply, 152 188 } 153 189 })
+1
src/types/atproto.ts
··· 1 1 export type DidString = `did:${string}:${string}` 2 2 export type HandleString = `${string}.${string}` 3 + export type CollectionString = `${string}.${string}.${string}`
+3
src/utils/constants.ts
··· 1 + export const MAX_POST_IMAGE_SIZE = 1_000_000 2 + export const MAX_POST_VIDEO_SIZE = 100_000_000 3 + export const MAX_POST_TEXT_LENGTH = 300
+9
src/utils/formatting.ts
··· 1 + export const formatSize = (bytes: number): string => { 2 + const units = ['B', 'KB', 'MB', 'GB', 'TB'] 3 + let i = 0 4 + while (bytes >= 1024 && i < units.length - 1) { 5 + bytes /= 1024 6 + i++ 7 + } 8 + return `${bytes.toFixed(2)} ${units[i]}` 9 + }
+7 -1
src/views/Post/PostView.vue
··· 11 11 import PageLayout from '@/components/Navigation/PageLayout.vue' 12 12 import FeedItem from '@/components/Feed/FeedItem.vue' 13 13 import FeedThread from '@/components/Feed/FeedThread.vue' 14 + import ReplyComposer from '@/components/Feed/ReplyComposer.vue' 14 15 import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 15 16 import Button from '@/components/UI/BaseButton.vue' 16 17 import { IconRefreshRounded } from '@iconify-prerendered/vue-material-symbols' ··· 179 180 180 181 <div class="main-post-wrapper"> 181 182 <FeedItem :post="thread.post" class="main-post" :rootPost="true" /> 183 + <ReplyComposer 184 + v-if="auth.isAuthenticated" 185 + :replyTo="thread.post" 186 + :rootPost="ancestors[0] ? ancestors[0].post : thread.post" 187 + @success="fetchThread" 188 + /> 182 189 </div> 183 190 184 191 <div class="replies-section"> 185 - <div class="replies-header" v-if="replyNodes.length > 0">Replies</div> 186 192 <FeedThread 187 193 v-for="node in replyNodes" 188 194 :key="(node.data as AppBskyFeedDefs.PostView).uri"
+1 -1
src/views/Root/HomeView.vue
··· 124 124 125 125 const switchFeed = async (feed: PinnedFeedItem) => { 126 126 if (activeFeed.value === feed) { 127 - await feedList.value?.refresh() 128 127 pageLayout.value?.scrollToTop(true) 128 + await feedList.value?.refresh() 129 129 return 130 130 } 131 131
+5 -5
src/views/SettingsPage.vue
··· 141 141 <ListGroup title="General"> 142 142 <ListItem 143 143 title="Source Code" 144 - href="https://github.com/tangled-group/bluebell" 144 + href="https://tangled.org/vt3e.cat/bluebell" 145 145 target="_blank" 146 146 rel="noopener" 147 147 > ··· 277 277 278 278 <div class="about-actions"> 279 279 <a 280 - href="https://tangled.sh/wlo.moe/bluebell" 280 + href="https://tangled.sh/vt3e.cat/bluebell" 281 281 target="_blank" 282 282 rel="noopener" 283 283 class="action-card" ··· 286 286 <span>Source</span> 287 287 </a> 288 288 <a 289 - href="https://tangled.sh/wlo.moe/bluebell/issues" 289 + href="https://tangled.sh/vt3e.cat/bluebell/issues" 290 290 target="_blank" 291 291 rel="noopener" 292 292 class="action-card" ··· 299 299 <div class="about-credits"> 300 300 <p> 301 301 Created by 302 - <AppLink name="profile" :params="{ identifier: 'wlo.moe' }" class="credit-link"> 303 - @wlo.moe 302 + <AppLink name="user-profile" :params="{ id: 'vt3e.cat' }" class="credit-link"> 303 + @vt3e.cat 304 304 </AppLink> 305 305 </p> 306 306 <p class="tech-stack">