Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df5f7d7c10 | |||
| e804546809 | |||
| d3f5093817 | |||
| 75c9e2c181 | |||
| b9561f78ee | |||
| 19e2dc939a | |||
| ecc78efb12 | |||
| bcbc114189 | |||
| 8d3eba2381 | |||
| 59a2d19c26 | |||
| eee2df6d3a | |||
| c56427c6fb | |||
| 9c502b38e5 | |||
| 1f6d6d0545 | |||
| 27d1b96e73 | |||
| 5d0b900719 | |||
| 9b31bb8470 | |||
| 02daa0179b | |||
| ff294edd1e | |||
| a7b6cd4504 | |||
| 888eca73b3 | |||
| 2c717dc1e7 | |||
| ff68e4036d | |||
| 170e169ad1 | |||
| 19df58f1ef | |||
| c79dff9b3e | |||
| 13b453e506 | |||
| d8fdfddbaf | |||
| ff30d39355 | |||
| 9c65a8a78c | |||
| 8c9a11dc3f | |||
| d01362a01e | |||
| 3ff40e9603 | |||
| cfeac097e1 | |||
| c42940b0ad | |||
| a0d4af5c5f | |||
| 5ad3597fdb |
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25-bookworm AS build-env
|
||||
FROM golang:1.26-bookworm AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25-bookworm AS build-env
|
||||
FROM golang:1.26-bookworm AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z"/></svg>
|
||||
|
After Width: | Height: | Size: 448 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M10.655 3.718c.55-1.116 2.14-1.116 2.69 0l7.548 15.317c.578 1.172-.515 2.471-1.768 2.103L13 19.336V15a1 1 0 0 0-2 0v4.336l-6.124 1.802c-1.254.369-2.346-.93-1.769-2.103l7.548-15.317Z"/></svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -1,10 +1,10 @@
|
||||
<svg width="105" height="32" viewBox="0 0 105 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.7901 9.77492C21.7947 11.2889 23.9508 14.3587 24.7425 16.0059C25.5342 14.3587 27.6903 11.2889 29.6949 9.77492C31.1413 8.68251 33.485 7.83725 33.485 10.5269C33.485 11.064 33.1789 15.0393 32.9993 15.6848C32.3752 17.9285 30.1009 18.5008 28.0778 18.1544C31.6141 18.7598 32.5136 20.7653 30.5709 22.7708C26.1996 27.2831 24.7425 20.3569 24.7425 20.3569C24.7425 20.3569 23.2854 27.2831 18.9142 22.7708C16.9714 20.7653 17.871 18.7598 21.4072 18.1544C19.3841 18.5008 17.1099 17.9285 16.4857 15.6848C16.3061 15.0393 16 11.064 16 10.5269C16 7.83725 18.3437 8.68251 19.7901 9.77492Z" fill="white"/>
|
||||
<path d="M44.3863 16.3646C45.5901 16.7932 46.2292 17.7837 46.2292 18.8924C46.2292 20.7698 44.9659 21.908 42.5434 21.908H37.5942V11.3828H42.3799C44.6835 11.3828 45.7982 12.5506 45.7982 14.1028C45.7982 15.1376 45.3226 15.8915 44.3863 16.3646ZM42.2313 13.0237H39.6006V15.7732H42.2313C43.2568 15.7732 43.8067 15.2411 43.8067 14.3541C43.8067 13.5411 43.2419 13.0237 42.2313 13.0237ZM39.6006 20.2524H42.4393C43.5689 20.2524 44.1782 19.735 44.1782 18.8037C44.1782 17.828 43.5986 17.3402 42.4393 17.3402H39.6006V20.2524Z" fill="white"/>
|
||||
<path d="M49.0111 21.908H47.1385V11.3828H49.0111V21.908Z" fill="white"/>
|
||||
<path d="M55.2202 18.5524V14.2802H57.0929V21.908H55.2797V20.7993C54.7 21.6567 53.8975 22.0854 52.872 22.0854C51.252 22.0854 50.1968 21.1098 50.1968 19.3359V14.2802H52.0694V19.0254C52.0694 19.9863 52.545 20.4741 53.5111 20.4741C54.4177 20.4741 55.2202 19.8089 55.2202 18.5524Z" fill="white"/>
|
||||
<path d="M65.6167 18.2272V18.6706H59.8799C60.0137 19.9863 60.7419 20.6367 61.8714 20.6367C62.7334 20.6367 63.3131 20.2672 63.6252 19.5428H65.4235C65.0222 21.095 63.6846 22.0854 61.8566 22.0854C60.7122 22.0854 59.7907 21.7159 59.0922 20.9915C58.3937 20.2672 58.037 19.3063 58.037 18.0941C58.037 16.8967 58.3788 15.9359 59.0773 15.1967C59.7759 14.4724 60.6825 14.1028 61.8269 14.1028C62.9861 14.1028 63.9076 14.4872 64.5912 15.2411C65.2749 15.995 65.6167 17.0002 65.6167 18.2272ZM61.812 15.5515C60.7865 15.5515 60.0731 16.1428 59.8948 17.3698H63.7441C63.5806 16.2611 62.8969 15.5515 61.812 15.5515Z" fill="white"/>
|
||||
<path d="M69.7673 22.115C67.5379 22.115 66.3638 21.2428 66.2598 19.4837H68.0878C68.1918 20.4298 68.6674 20.7846 69.797 20.7846C70.8076 20.7846 71.3129 20.4741 71.3129 19.868C71.3129 19.3211 70.9562 19.055 69.8118 18.8628L68.935 18.715C67.2555 18.4341 66.4232 17.6654 66.4232 16.4089C66.4232 14.975 67.5676 14.1028 69.6038 14.1028C71.7885 14.1028 72.9181 14.9602 72.9924 16.6898H71.2238C71.1792 15.7585 70.6293 15.4332 69.6038 15.4332C68.712 15.4332 68.2662 15.7289 68.2662 16.3202C68.2662 16.8524 68.6526 17.0889 69.5443 17.2515L70.5104 17.3993C72.3681 17.7393 73.1707 18.4193 73.1707 19.7202C73.1707 21.2576 71.9223 22.115 69.7673 22.115Z" fill="white"/>
|
||||
<path d="M81.3899 21.908H79.2497L77.0204 18.3602L75.8611 19.5132V21.908H74.0182V11.3828H75.8611V17.4437L78.9822 14.2802H81.2116L78.3134 17.1628L81.3899 21.908Z" fill="white"/>
|
||||
<path d="M86.2805 16.2019L86.9047 14.2802H88.8665L85.909 22.5289C85.5968 23.3715 85.2104 23.9776 84.72 24.3176C84.2295 24.6576 83.531 24.8202 82.6095 24.8202C82.2974 24.8202 82.0299 24.8054 81.7921 24.7759V23.3124H82.5055C83.3526 23.3124 83.7688 22.795 83.7688 22.0854C83.7688 21.7306 83.6499 21.2132 83.4121 20.548L81.1827 14.2802H83.204L83.8282 16.1872C84.289 17.6211 84.6902 19.0402 85.0469 20.4446C85.3739 19.2324 85.7901 17.8132 86.2805 16.2019Z" fill="white"/>
|
||||
<svg width="120" height="28" viewBox="0 0 120 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.23031 1.75921C9.52558 4.31406 13.0698 9.49434 14.3713 12.2741C15.6727 9.49434 19.2169 4.31406 22.5122 1.75921C24.8899 -0.0842317 28.7425 -1.5106 28.7425 3.02818C28.7425 3.93462 28.2393 10.6429 27.9441 11.7321C26.9181 15.5183 23.1796 16.4841 19.8539 15.8995C25.667 16.9212 27.1457 20.3055 23.9521 23.6897C16.7665 31.3043 14.3713 19.6163 14.3713 19.6163C14.3713 19.6163 11.976 31.3043 4.79042 23.6897C1.59681 20.3055 3.07554 16.9212 8.88858 15.8995C5.56292 16.4841 1.82441 15.5183 0.798403 11.7321C0.503259 10.6429 0 3.93462 0 3.02818C0 -1.5106 3.85263 -0.0842317 6.23031 1.75921Z" fill="#006AFF"/>
|
||||
<path d="M46.662 12.8778C48.641 13.6012 49.6915 15.2726 49.6915 17.1435C49.6915 20.3116 47.6149 22.2324 43.6326 22.2324H35.497V4.47113H43.3638C47.1507 4.47113 48.983 6.44184 48.983 9.06113C48.983 10.8073 48.2012 12.0796 46.662 12.8778ZM43.1195 7.2401H38.7952V11.88H43.1195C44.8053 11.88 45.7092 10.9819 45.7092 9.48521C45.7092 8.1132 44.7808 7.2401 43.1195 7.2401ZM38.7952 19.4385H43.4616C45.3183 19.4385 46.32 18.5654 46.32 16.9938C46.32 15.3474 45.3672 14.5242 43.4616 14.5242H38.7952V19.4385Z" fill="#006AFF"/>
|
||||
<path d="M54.2645 22.2324H51.1862V4.47113H54.2645V22.2324Z" fill="#006AFF"/>
|
||||
<path d="M64.4712 16.5698V9.36048H67.5495V22.2324H64.5689V20.3615C63.6161 21.8084 62.2968 22.5318 60.6111 22.5318C57.9481 22.5318 56.2135 20.8854 56.2135 17.8919V9.36048H59.2918V17.368C59.2918 18.9895 60.0736 19.8127 61.6616 19.8127C63.1519 19.8127 64.4712 18.6902 64.4712 16.5698Z" fill="#006AFF"/>
|
||||
<path d="M81.5614 16.021V16.7693H72.131C72.3508 18.9895 73.548 20.0871 75.4047 20.0871C76.8217 20.0871 77.7746 19.4635 78.2876 18.2411H81.2438C80.5841 20.8604 78.3853 22.5318 75.3803 22.5318C73.4991 22.5318 71.9844 21.9081 70.8361 20.6858C69.6878 19.4635 69.1015 17.842 69.1015 15.7965C69.1015 13.7759 69.6634 12.1544 70.8117 10.9071C71.9599 9.68477 73.4502 9.06113 75.3314 9.06113C77.2371 9.06113 78.7518 9.70972 79.8756 10.9819C80.9995 12.2542 81.5614 13.9505 81.5614 16.021ZM75.307 11.5058C73.6213 11.5058 72.4486 12.5036 72.1554 14.5741H78.4831C78.2143 12.7032 77.0905 11.5058 75.307 11.5058Z" fill="#006AFF"/>
|
||||
<path d="M88.3842 22.5817C84.7195 22.5817 82.7894 21.1099 82.6184 18.1413H85.6234C85.7944 19.7379 86.5762 20.3366 88.433 20.3366C90.0943 20.3366 90.925 19.8127 90.925 18.7899C90.925 17.8669 90.3386 17.4179 88.4574 17.0936L87.016 16.8442C84.2553 16.3702 82.8871 15.073 82.8871 12.9527C82.8871 10.5329 84.7683 9.06113 88.1154 9.06113C91.7068 9.06113 93.5636 10.508 93.6857 13.4266H90.7784C90.7051 11.855 89.8012 11.3062 88.1154 11.3062C86.6495 11.3062 85.9166 11.8052 85.9166 12.803C85.9166 13.701 86.5518 14.1002 88.0177 14.3746L89.6057 14.624C92.6596 15.1978 93.9789 16.3453 93.9789 18.5405C93.9789 21.1348 91.9267 22.5817 88.3842 22.5817Z" fill="#006AFF"/>
|
||||
<path d="M107.49 22.2324H103.972L100.307 16.2455L98.4015 18.1912V22.2324H95.372V4.47113H98.4015V14.6988L103.532 9.36048H107.197L102.433 14.2249L107.49 22.2324Z" fill="#006AFF"/>
|
||||
<path d="M115.529 12.6034L116.555 9.36048H119.78L114.918 23.2802C114.405 24.7021 113.77 25.7248 112.964 26.2986C112.158 26.8723 111.009 27.1467 109.495 27.1467C108.982 27.1467 108.542 27.1218 108.151 27.0719V24.6023H109.324C110.716 24.6023 111.4 23.7292 111.4 22.5318C111.4 21.9331 111.205 21.06 110.814 19.9374L107.149 9.36048H110.472L111.498 12.5785C112.255 14.9982 112.915 17.393 113.501 19.7628C114.039 17.7173 114.723 15.3225 115.529 12.6034Z" fill="#006AFF"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z"/></svg>
|
||||
|
After Width: | Height: | Size: 182 B |
@@ -9,7 +9,11 @@ export function applyTheme(theme: 'light' | 'dark') {
|
||||
document.documentElement.classList.add(theme)
|
||||
}
|
||||
|
||||
export function initSystemColorMode() {
|
||||
export function initSystemColorMode({additionalBodyClasses = ''} = {}) {
|
||||
if (additionalBodyClasses) {
|
||||
document.body.classList.add(additionalBodyClasses)
|
||||
}
|
||||
|
||||
applyTheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Container({
|
||||
if (!entry) return
|
||||
|
||||
let {height} = entry.contentRect
|
||||
height += 2 // border top and bottom
|
||||
height += 4 // border-2 = 2px top + 2px bottom
|
||||
if (height !== prevHeight.current) {
|
||||
prevHeight.current = height
|
||||
window.parent.postMessage(
|
||||
@@ -37,7 +37,7 @@ export function Container({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full bg-brand text-black dark:bg-brand relative transition-colors max-w-[600px] min-w-[300px] flex items-center dark:text-slate-200 rounded-[20px] cursor-pointer hover:bg-opacity-90"
|
||||
className="w-full border-2 border-brand text-black relative transition-colors max-w-[600px] min-w-[300px] flex items-center dark:text-slate-200 rounded-[32px] overflow-hidden cursor-pointer"
|
||||
onClick={() => {
|
||||
if (ref.current && href) {
|
||||
// forwardRef requires preact/compat - let's keep it simple
|
||||
@@ -49,9 +49,7 @@ export function Container({
|
||||
}
|
||||
}}>
|
||||
{href && <Link href={href} />}
|
||||
<div className="flex-1 px-[6px] pt-[6px] pb-2.5 max-w-full">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex-1 max-w-full">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ import {ComponentChildren, h} from 'preact'
|
||||
import {useMemo} from 'preact/hooks'
|
||||
|
||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
||||
import playIcon from '../../assets/play_filled_corner0_rounded.svg'
|
||||
import starterPackIcon from '../../assets/starterPack.svg'
|
||||
import {Globe} from '../icons/Globe'
|
||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||
import * as bsky from '../types/bsky'
|
||||
import {getRkey} from '../util/rkey'
|
||||
@@ -93,7 +94,7 @@ export function Embed({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center shrink min-w-0 min-h-0">
|
||||
<p className="block text-sm shrink-0 font-bold max-w-[70%] line-clamp-1">
|
||||
<p className="text-sm shrink-0 font-semibold max-w-[70%] truncate">
|
||||
{record.author.displayName?.trim() || record.author.handle}
|
||||
</p>
|
||||
{verification.isVerified && (
|
||||
@@ -103,7 +104,7 @@ export function Embed({
|
||||
size={12}
|
||||
/>
|
||||
)}
|
||||
<p className="block line-clamp-1 text-sm text-textLight dark:text-textDimmed shrink-[10] ml-1">
|
||||
<p className="text-sm text-textLight dark:text-textDimmed min-w-0 truncate ml-1">
|
||||
@{record.author.handle}
|
||||
</p>
|
||||
</div>
|
||||
@@ -334,13 +335,18 @@ function ExternalEmbed({
|
||||
/>
|
||||
)}
|
||||
<div className="py-3 px-4">
|
||||
<p className="text-sm text-textLight dark:text-textDimmed line-clamp-1">
|
||||
{toNiceDomain(content.external.uri)}
|
||||
<p className="font-semibold leading-tight line-clamp-3">
|
||||
{content.external.title}
|
||||
</p>
|
||||
<p className="font-semibold line-clamp-3">{content.external.title}</p>
|
||||
<p className="text-sm text-textLight dark:text-textDimmed line-clamp-2 mt-0.5">
|
||||
<p className="text-sm leading-snug text-textLight dark:text-textDimmed line-clamp-2 mt-0.5">
|
||||
{content.external.description}
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-1 border-t dark:border-slate-600 mt-1 pt-1.5">
|
||||
<Globe size={12} className="text-textLight dark:text-textDimmed" />
|
||||
<p className="text-sm leading-none text-textLight dark:text-textDimmed line-clamp-1">
|
||||
{toNiceDomain(content.external.uri)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -374,7 +380,7 @@ function GenericWithImageEmbed({
|
||||
<div className="w-8 h-8 rounded-md bg-brand shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-sm">{title}</p>
|
||||
<p className="font-semibold text-sm">{title}</p>
|
||||
<p className="text-textLight dark:text-textDimmed text-sm">
|
||||
{subtitle}
|
||||
</p>
|
||||
@@ -389,7 +395,6 @@ function GenericWithImageEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
// just the thumbnail and a play button
|
||||
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||
let aspectRatio = 1
|
||||
|
||||
@@ -398,6 +403,28 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
|
||||
}
|
||||
|
||||
const supportsHls = useMemo(() => {
|
||||
const video = document.createElement('video')
|
||||
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
||||
}, [])
|
||||
|
||||
if (supportsHls) {
|
||||
return (
|
||||
<video
|
||||
src={content.playlist}
|
||||
poster={content.thumbnail}
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
loading="lazy"
|
||||
aria-label={content.alt || undefined}
|
||||
onClickCapture={evt => evt.stopPropagation()}
|
||||
className="w-full rounded-xl bg-black"
|
||||
style={{aspectRatio: `${aspectRatio} / 1`}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-xl aspect-square relative"
|
||||
|
||||
@@ -53,7 +53,7 @@ export function Post({thread}: Props) {
|
||||
return (
|
||||
<Container href={href}>
|
||||
<div
|
||||
className="flex-1 flex-col flex gap-2 bg-neutral-50 dark:bg-black dark:hover:bg-slate-900 hover:bg-blue-50 rounded-[14px] p-4"
|
||||
className="flex-1 flex-col flex gap-4 bg-white dark:bg-black hover:bg-brandHover dark:hover:bg-brandHoverDark rounded-[30px] p-5"
|
||||
lang={record?.langs?.[0]}>
|
||||
<div className="flex gap-2.5 items-center cursor-pointer w-full max-w-full ">
|
||||
<Link
|
||||
@@ -70,7 +70,7 @@ export function Post({thread}: Props) {
|
||||
<div className="flex flex-1 items-center">
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="block font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
|
||||
className="block font-semibold text-[15px] min-[400px]:text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
|
||||
{post.author.displayName?.trim() || post.author.handle}
|
||||
</Link>
|
||||
{verification.isVerified && (
|
||||
@@ -87,72 +87,68 @@ export function Post({thread}: Props) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
|
||||
@{post.author.handle}
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 text-[13px] min-[400px]:text-[15px] min-w-0">
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="text-textNeutral hover:underline line-clamp-1">
|
||||
@{post.author.handle}
|
||||
</Link>
|
||||
<span className="text-textNeutral shrink-0">·</span>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="text-brand hover:underline shrink-0">
|
||||
Follow
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostContent record={record} />
|
||||
<Embed content={post.embed} labels={post.labels} />
|
||||
|
||||
<div className="flex items-center justify-between w-full pt-2.5 text-sm">
|
||||
<div className="flex items-center gap-3 text-sm cursor-pointer">
|
||||
{!!post.likeCount && (
|
||||
<div className="flex items-center gap-1 cursor-pointer group">
|
||||
<LikeIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
|
||||
/>
|
||||
<p className="font-medium text-slate-600 text-neutral-600 dark:text-neutral-300 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors dark:text-slate-400">
|
||||
{prettyNumber(post.likeCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!post.replyCount && (
|
||||
<div className="flex items-center gap-1 cursor-pointer group">
|
||||
<ReplyIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
|
||||
/>
|
||||
<p className="font-medium text-slate-600 text-neutral-600 dark:text-neutral-300 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors dark:text-slate-400">
|
||||
{prettyNumber(post.replyCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!post.repostCount && (
|
||||
<div className="flex items-center gap-1 cursor-pointer group">
|
||||
<RepostIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
|
||||
/>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
|
||||
{prettyNumber(post.repostCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end justify-between w-full">
|
||||
<div className="flex flex-col min-[400px]:gap-0.5">
|
||||
<div className="flex items-center gap-3 text-sm cursor-pointer ml-[-2px]">
|
||||
{!!post.likeCount && (
|
||||
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
|
||||
<LikeIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
|
||||
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
|
||||
{prettyNumber(post.likeCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!post.replyCount && (
|
||||
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
|
||||
<ReplyIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
|
||||
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
|
||||
{prettyNumber(post.replyCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!post.repostCount && (
|
||||
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
|
||||
<RepostIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
|
||||
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
|
||||
{prettyNumber(post.repostCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link href={href}>
|
||||
<time
|
||||
datetime={new Date(post.indexedAt).toISOString()}
|
||||
className="text-[11px] min-[400px]:text-[15px] text-textNeutral hover:underline">
|
||||
{niceDate(post.indexedAt)}
|
||||
</time>
|
||||
</Link>
|
||||
</div>
|
||||
<Link href={href}>
|
||||
<time
|
||||
datetime={new Date(post.indexedAt).toISOString()}
|
||||
className="text-slate-500 dark:text-textDimmed text-sm hover:underline dark:text-slate-500">
|
||||
{niceDate(post.indexedAt)}
|
||||
</time>
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 shrink-0">
|
||||
<img src={logo} className="h-5 min-[400px]:h-7" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 shrink-0">
|
||||
<img src={logo} className="h-8" />
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -177,7 +173,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
<Link
|
||||
key={counter}
|
||||
href={segment.link.uri}
|
||||
className="text-blue-500 hover:underline"
|
||||
className="text-brand hover:underline"
|
||||
disableTracking={
|
||||
!segment.link.uri.startsWith('https://bsky.app') &&
|
||||
!segment.link.uri.startsWith('https://go.bsky.app')
|
||||
@@ -193,7 +189,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
<Link
|
||||
key={counter}
|
||||
href={`/profile/${segment.mention.did}`}
|
||||
className="text-blue-500 hover:underline">
|
||||
className="text-brand hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
@@ -205,7 +201,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
<Link
|
||||
key={counter}
|
||||
href={`/hashtag/${segment.tag.tag}`}
|
||||
className="text-blue-500 hover:underline">
|
||||
className="text-brand hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
@@ -217,7 +213,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="min-[300px]:text-lg leading-6 min-[300px]:leading-6 break-word break-words whitespace-pre-wrap">
|
||||
<p className="text-md min-[400px]:text-lg leading-snug min-[400px]:leading-snug break-word break-words whitespace-pre-wrap">
|
||||
{richText}
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {h} from 'preact'
|
||||
|
||||
export const Globe = ({
|
||||
size = 14,
|
||||
className,
|
||||
}: {
|
||||
size?: number
|
||||
className?: string
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M4.4 9.493C4.14 10.28 4 11.124 4 12a8 8 0 1 0 10.899-7.459l-.953 3.81a1 1 0 0 1-.726.727l-3.444.866-.772 1.533a1 1 0 0 1-1.493.35L4.4 9.493Zm.883-1.84L7.756 9.51l.44-.874a1 1 0 0 1 .649-.52l3.306-.832.807-3.227a7.99 7.99 0 0 0-7.676 3.597ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8.43.162a1 1 0 0 1 .77-.29l1.89.121a1 1 0 0 1 .494.168l2.869 1.928a1 1 0 0 1 .336 1.277l-.973 1.946a1 1 0 0 1-.894.553h-2.92a1 1 0 0 1-.831-.445L9.225 14.5a1 1 0 0 1 .126-1.262l1.08-1.076Zm.915 1.913.177-.177 1.171.074 1.914 1.286-.303.607h-1.766l-1.194-1.79Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -2,6 +2,12 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.break-word {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
initSystemColorMode()
|
||||
initSystemColorMode({additionalBodyClasses: 'dark:bg-dimmedBgDarken'})
|
||||
|
||||
const agent = new AtpAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
@@ -39,6 +39,7 @@ render(<LandingPage />, root)
|
||||
function LandingPage() {
|
||||
const [uri, setUri] = useState('')
|
||||
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
|
||||
@@ -118,7 +119,7 @@ function LandingPage() {
|
||||
}, [uri])
|
||||
|
||||
return (
|
||||
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:bg-dimmedBgDarken dark:text-slate-200">
|
||||
<main className="w-full min-h-dvh flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:text-slate-200">
|
||||
<Link
|
||||
href="https://bsky.social/about"
|
||||
className="transition-transform hover:scale-110">
|
||||
@@ -185,7 +186,7 @@ function LandingPage() {
|
||||
function Skeleton() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex-1 flex-col flex gap-2 pb-8">
|
||||
<div className="flex-1 flex-col flex gap-2 p-5 pb-8">
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 dark:bg-slate-700 shrink-0 animate-pulse" />
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export function niceDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
return `${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})} · ${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: 'rgb(10,122,255)',
|
||||
brand: 'rgb(0,106,255)',
|
||||
brandHover: 'rgb(245,249,255)',
|
||||
brandHoverDark: 'rgb(17,24,34)',
|
||||
brandLighten: 'rgb(32,139,254)',
|
||||
textLight: 'rgb(66,87,108)',
|
||||
textDimmed: 'rgb(174,187,201)',
|
||||
textLight: 'rgb(63,82,104)',
|
||||
textDimmed: 'rgb(164,179,197)',
|
||||
textNeutral: 'rgb(102,123,153)',
|
||||
dimmedBgLighten: 'rgb(30,41,54)',
|
||||
dimmedBg: 'rgb(22,30,39)',
|
||||
dimmedBgDarken: 'rgb(18,25,32)',
|
||||
|
||||
@@ -54,7 +54,7 @@ func NewRenderer(prefix string, fs *embed.FS, debug bool) *Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
func (r Renderer) Render(w io.Writer, name string, data any, c echo.Context) error {
|
||||
var ctx pongo2.Context
|
||||
|
||||
if data != nil {
|
||||
|
||||
@@ -11,6 +11,6 @@ type Template struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
func (t *Template) Render(w io.Writer, name string, data any, c echo.Context) error {
|
||||
return t.templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
@@ -180,9 +180,9 @@ func serve(cctx *cli.Context) error {
|
||||
|
||||
// Create CORS middleware for oembed
|
||||
oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||
})
|
||||
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
@@ -271,7 +271,7 @@ func (srv *Server) errorHandler(err error, c echo.Context) {
|
||||
code = he.Code
|
||||
}
|
||||
c.Logger().Error(err)
|
||||
data := map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"statusCode": code,
|
||||
}
|
||||
c.Render(code, "error.html", data)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/bluesky-social/social-app/bskyweb
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, viewport-fit=cover">
|
||||
<meta name="referrer" content="origin-when-cross-origin">
|
||||
<!--
|
||||
Preconnect to essential domains
|
||||
|
||||
@@ -250,6 +250,14 @@ export default defineConfig(
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
||||
'@typescript-eslint/await-thenable': 'warn',
|
||||
|
||||
"no-restricted-imports": ["error", {
|
||||
"paths": [{
|
||||
"name": "react",
|
||||
"importNames": ["React", "default"],
|
||||
"message": "React is already in the global type namespace. Use named imports for runtime modules."
|
||||
}]
|
||||
}],
|
||||
|
||||
/**
|
||||
* Turn off rules that we haven't enforced thus far
|
||||
*/
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.120.0",
|
||||
"version": "1.121.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -81,14 +81,15 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.19.6",
|
||||
"@atproto/api": "^0.19.9",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.7",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/sift": "^0.3.1",
|
||||
"@bsky.app/sift": "^0.3.2",
|
||||
"@bsky.app/tapper": "^0.5.0",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
@@ -156,6 +157,7 @@
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-glass-effect": "55.0.8",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
@@ -213,7 +215,7 @@
|
||||
"react-native-drawer-layout": "^4.2.2",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.21.0",
|
||||
"react-native-keyboard-controller": "^1.21.5",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
@@ -245,7 +247,6 @@
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@crowdin/cli": "^4.14.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@expo/config-plugins": "~54.0.4",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
|
||||
"@lingui/cli": "^5.9.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
@@ -274,7 +275,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-native": "^5.0.0",
|
||||
"eslint-plugin-react-native-a11y": "^3.5.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||
"file-loader": "6.2.0",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^8.0.3",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
diff --git a/node_modules/expo-glass-effect/ios/GlassContainer.swift b/node_modules/expo-glass-effect/ios/GlassContainer.swift
|
||||
index 61fb67c..b2d111e 100644
|
||||
--- a/node_modules/expo-glass-effect/ios/GlassContainer.swift
|
||||
+++ b/node_modules/expo-glass-effect/ios/GlassContainer.swift
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
+import React
|
||||
|
||||
public final class GlassContainer: ExpoView {
|
||||
private var containerEffect: Any?
|
||||
@@ -46,11 +47,19 @@ public final class GlassContainer: ExpoView {
|
||||
}
|
||||
}
|
||||
|
||||
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ // Paper: redirect children into the container effect's contentView
|
||||
+ public override func didUpdateReactSubviews() {
|
||||
+ for subview in self.reactSubviews() {
|
||||
+ containerEffectView.contentView.addSubview(subview)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Fabric: redirect children into the container effect's contentView
|
||||
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
containerEffectView.contentView.insertSubview(childComponentView, at: index)
|
||||
}
|
||||
|
||||
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
childComponentView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/expo-glass-effect/ios/GlassView.swift b/node_modules/expo-glass-effect/ios/GlassView.swift
|
||||
index 35cd8f3..9587306 100644
|
||||
--- a/node_modules/expo-glass-effect/ios/GlassView.swift
|
||||
+++ b/node_modules/expo-glass-effect/ios/GlassView.swift
|
||||
@@ -271,11 +271,19 @@ public final class GlassView: ExpoView {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ // Paper: redirect children into the glass effect's contentView
|
||||
+ public override func didUpdateReactSubviews() {
|
||||
+ for subview in self.reactSubviews() {
|
||||
+ glassEffectView.contentView.addSubview(subview)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Fabric: redirect children into the glass effect's contentView
|
||||
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
glassEffectView.contentView.insertSubview(childComponentView, at: index)
|
||||
}
|
||||
|
||||
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
childComponentView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# expo-glass-effect patch
|
||||
|
||||
Patches in support for Expo SDK 54. Please delete when we update Expo
|
||||
@@ -0,0 +1,48 @@
|
||||
diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
index 24a25ae..2c5ff6d 100644
|
||||
--- a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
+++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
-import { Platform } from "react-native";
|
||||
import { scrollTo, useAnimatedReaction } from "react-native-reanimated";
|
||||
|
||||
-import { IS_FABRIC } from "../../../architecture";
|
||||
import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers";
|
||||
|
||||
import type { KeyboardLiftBehavior } from "../useChatKeyboard/types";
|
||||
@@ -52,7 +50,6 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
|
||||
scroll,
|
||||
layout,
|
||||
size,
|
||||
- contentOffsetY,
|
||||
inverted,
|
||||
keyboardLiftBehavior,
|
||||
freeze,
|
||||
@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
|
||||
(target: number) => {
|
||||
"worklet";
|
||||
|
||||
- if (contentOffsetY && IS_FABRIC) {
|
||||
- // eslint-disable-next-line react-compiler/react-compiler
|
||||
- contentOffsetY.value = target;
|
||||
- } else if (Platform.OS === "android") {
|
||||
- // Defer scrollTo so the animatedProps inset commit lands first;
|
||||
- // otherwise the native ScrollView clamps to the old range.
|
||||
- requestAnimationFrame(() => {
|
||||
- scrollTo(scrollViewRef, 0, target, false);
|
||||
- });
|
||||
- } else {
|
||||
+ // Always defer scrollTo so the animatedProps inset commit lands first;
|
||||
+ // otherwise the native ScrollView clamps contentOffset to the old
|
||||
+ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
|
||||
+ requestAnimationFrame(() => {
|
||||
scrollTo(scrollViewRef, 0, target, false);
|
||||
- }
|
||||
+ });
|
||||
},
|
||||
- [scrollViewRef, contentOffsetY],
|
||||
+ [scrollViewRef],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
@@ -1,7 +1,6 @@
|
||||
const {withEntitlementsPlist} = require('@expo/config-plugins')
|
||||
const {withEntitlementsPlist} = require('expo/config-plugins')
|
||||
|
||||
const withAppEntitlements = config => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withEntitlementsPlist(config, async config => {
|
||||
config.modResults['com.apple.security.application-groups'] = [
|
||||
`group.app.bsky`,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const {withInfoPlist} = require('expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withExtensionEntitlements = (config, {extensionName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const extensionEntitlementsPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const {withInfoPlist} = require('expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withExtensionInfoPlist = (config, {extensionName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const plistPath = path.join(
|
||||
config.modRequest.projectRoot,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
const {withXcodeProject} = require('expo/config-plugins')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
@@ -6,7 +6,6 @@ const withExtensionViewController = (
|
||||
config,
|
||||
{controllerName, extensionName},
|
||||
) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const controllerPath = path.join(
|
||||
config.modRequest.projectRoot,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const {withPlugins} = require('@expo/config-plugins')
|
||||
const {withPlugins} = require('expo/config-plugins')
|
||||
const {withAppEntitlements} = require('./withAppEntitlements')
|
||||
const {withXcodeTarget} = require('./withXcodeTarget')
|
||||
const {withExtensionEntitlements} = require('./withExtensionEntitlements')
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
const {withXcodeProject} = require('expo/config-plugins')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withSounds = (config, {extensionName, soundFiles}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
for (const file of soundFiles) {
|
||||
const soundPath = path.join(config.modRequest.projectRoot, 'assets', file)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
const {withXcodeProject, IOSConfig} = require('@expo/config-plugins')
|
||||
const path = require('path')
|
||||
const PBXFile = require('xcode/lib/pbxFile')
|
||||
const {withXcodeProject} = require('expo/config-plugins')
|
||||
|
||||
const withXcodeTarget = (
|
||||
config,
|
||||
{extensionName, controllerName, soundFiles},
|
||||
) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
let pbxProject = config.modResults
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {withEntitlementsPlist} = require('@expo/config-plugins')
|
||||
const {withEntitlementsPlist} = require('expo/config-plugins')
|
||||
|
||||
const withAppEntitlements = config => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withEntitlementsPlist(config, async config => {
|
||||
config.modResults['com.apple.security.application-groups'] = [
|
||||
`group.app.bsky`,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const {withInfoPlist} = require('expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withExtensionEntitlements = (config, {extensionName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const extensionEntitlementsPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const {withInfoPlist} = require('expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withExtensionInfoPlist = (config, {extensionName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const plistPath = path.join(
|
||||
config.modRequest.projectRoot,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
const {withXcodeProject} = require('expo/config-plugins')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
@@ -6,7 +6,6 @@ const withExtensionViewController = (
|
||||
config,
|
||||
{controllerName, extensionName},
|
||||
) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const controllerPath = path.join(
|
||||
config.modRequest.projectRoot,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {withAndroidManifest} = require('@expo/config-plugins')
|
||||
const {withAndroidManifest} = require('expo/config-plugins')
|
||||
|
||||
const withIntentFilters = config => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withAndroidManifest(config, config => {
|
||||
const intents = [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const {withPlugins} = require('@expo/config-plugins')
|
||||
const {withPlugins} = require('expo/config-plugins')
|
||||
const {withAppEntitlements} = require('./withAppEntitlements')
|
||||
const {withXcodeTarget} = require('./withXcodeTarget')
|
||||
const {withExtensionEntitlements} = require('./withExtensionEntitlements')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
const {withXcodeProject} = require('expo/config-plugins')
|
||||
|
||||
const withXcodeTarget = (config, {extensionName, controllerName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const pbxProject = config.modResults
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {withEntitlementsPlist} = require('@expo/config-plugins')
|
||||
const {withEntitlementsPlist} = require('expo/config-plugins')
|
||||
|
||||
const withAppEntitlements = config => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withEntitlementsPlist(config, async config => {
|
||||
config.modResults['com.apple.security.application-groups'] = [
|
||||
`group.app.bsky`,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const {withInfoPlist} = require('expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withClipEntitlements = (config, {targetName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const entitlementsPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const {withInfoPlist} = require('expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withClipInfoPlist = (config, {targetName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const targetPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
const {withXcodeProject} = require('expo/config-plugins')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const FILES = ['AppDelegate.swift', 'ViewController.swift']
|
||||
|
||||
const withFiles = (config, {targetName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const basePath = path.join(
|
||||
config.modRequest.projectRoot,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const {withPlugins} = require('@expo/config-plugins')
|
||||
const {withPlugins} = require('expo/config-plugins')
|
||||
const {withAppEntitlements} = require('./withAppEntitlements')
|
||||
const {withClipEntitlements} = require('./withClipEntitlements')
|
||||
const {withClipInfoPlist} = require('./withClipInfoPlist')
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
const {withXcodeProject} = require('expo/config-plugins')
|
||||
|
||||
const BUILD_PHASE_FILES = ['AppDelegate.swift', 'ViewController.swift']
|
||||
|
||||
const withXcodeTarget = (config, {targetName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const pbxProject = config.modResults
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {withAndroidManifest} = require('@expo/config-plugins')
|
||||
const {withAndroidManifest} = require('expo/config-plugins')
|
||||
|
||||
const withProcessTextQuery = config =>
|
||||
// eslint-disable-next-line no-shadow
|
||||
withAndroidManifest(config, config => {
|
||||
const manifest = config.modResults.manifest
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const {withProjectBuildGradle} = require('@expo/config-plugins')
|
||||
const {withProjectBuildGradle} = require('expo/config-plugins')
|
||||
|
||||
const jitpackRepository = "maven { url 'https://www.jitpack.io' }"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* This way we get a sane default color for spinners, text inputs, etc.
|
||||
*/
|
||||
|
||||
const {withAndroidStyles, AndroidConfig} = require('@expo/config-plugins')
|
||||
const {withAndroidStyles, AndroidConfig} = require('expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidStylesAccentColorPlugin(appConfig) {
|
||||
return withAndroidStyles(appConfig, function (decoratedAppConfig) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const {withAppDelegate} = require('@expo/config-plugins')
|
||||
const {mergeContents} = require('@expo/config-plugins/build/utils/generateCode')
|
||||
const {withAppDelegate, CodeGenerator} = require('expo/config-plugins')
|
||||
|
||||
module.exports = config =>
|
||||
withAppDelegate(config, config => {
|
||||
let contents = config.modResults.contents
|
||||
|
||||
contents = mergeContents({
|
||||
contents = CodeGenerator.mergeContents({
|
||||
src: contents,
|
||||
anchor: '// Linking API',
|
||||
newSrc: `
|
||||
@@ -22,7 +21,7 @@ module.exports = config =>
|
||||
comment: '//',
|
||||
}).contents
|
||||
|
||||
contents = mergeContents({
|
||||
contents = CodeGenerator.mergeContents({
|
||||
src: contents,
|
||||
anchor: '// Universal Links',
|
||||
newSrc: `
|
||||
|
||||
+1
-1
@@ -235,7 +235,7 @@ function App() {
|
||||
<Geo.Provider>
|
||||
<AppConfigProvider>
|
||||
<A11yProvider>
|
||||
<KeyboardControllerProvider>
|
||||
<KeyboardControllerProvider preload={false}>
|
||||
<OnboardingProvider>
|
||||
<AnalyticsContext>
|
||||
<SessionProvider>
|
||||
|
||||
@@ -78,6 +78,7 @@ import HashtagScreen from '#/screens/Hashtag'
|
||||
import {LogScreen} from '#/screens/Log'
|
||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
|
||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
@@ -568,6 +569,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesConversationScreen}
|
||||
options={{title: title(msg`Chat`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesConversationSettings"
|
||||
getComponent={() => MessagesConversationSettingsScreen}
|
||||
options={{title: title(msg`Group chat settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesSettings"
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
|
||||
@@ -20,8 +20,8 @@ import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAp
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||
import {Full as Logo} from '#/components/icons/Logo'
|
||||
|
||||
@@ -183,7 +183,7 @@ export async function getServerState({agent}: {agent: AtpAgent}) {
|
||||
const geolocation = device.get(['mergedGeolocation'])
|
||||
if (!geolocation || !geolocation.countryCode) {
|
||||
logger.error(`getServerState: missing geolocation countryCode`)
|
||||
return
|
||||
return null
|
||||
}
|
||||
const {data} = await agent.app.bsky.ageassurance.getState({
|
||||
countryCode: geolocation.countryCode,
|
||||
@@ -225,7 +225,9 @@ export async function prefetchServerState({agent}: {agent: AtpAgent}) {
|
||||
try {
|
||||
logger.debug(`prefetchServerState: resolving...`)
|
||||
const res = await networkRetry(3, () => getServerState({agent}))
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
|
||||
if (res) {
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
logger.warn(`prefetchServerState: failed`, {
|
||||
@@ -238,10 +240,12 @@ export async function refetchServerState({agent}: {agent: AtpAgent}) {
|
||||
if (!did) return
|
||||
logger.debug(`refetchServerState: fetching...`)
|
||||
const res = await networkRetry(3, () => getServerState({agent}))
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
createServerStateQueryKey({did}),
|
||||
res,
|
||||
)
|
||||
if (res) {
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
createServerStateQueryKey({did}),
|
||||
res,
|
||||
)
|
||||
}
|
||||
return res
|
||||
}
|
||||
export function usePatchServerState() {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export const atoms = {
|
||||
*/
|
||||
util_screen_outer: [
|
||||
web({
|
||||
minHeight: '100vh',
|
||||
minHeight: '100dvh',
|
||||
}),
|
||||
native({
|
||||
height: '100%',
|
||||
|
||||
@@ -2,6 +2,8 @@ import {useEffect, useRef} from 'react'
|
||||
|
||||
import {getCurrentState, onAppStateChange} from '#/lib/appState'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
|
||||
|
||||
/**
|
||||
* Tracks passive analytics like app foreground/background time.
|
||||
@@ -24,6 +26,20 @@ export function PassiveAnalytics() {
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (IS_DEV || IS_TESTFLIGHT) {
|
||||
const feats = Object.values(Features).reduce(
|
||||
(acc, feat) => {
|
||||
acc[feat] = features.evalFeature(feat)
|
||||
return acc
|
||||
},
|
||||
{} as Record<Features, any>,
|
||||
)
|
||||
ax.logger.info('FEATURES', {
|
||||
features: feats,
|
||||
definitions: features.getFeatures(),
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [ax])
|
||||
|
||||
@@ -10,8 +10,10 @@ export enum Features {
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
||||
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -563,6 +563,9 @@ export type Events = {
|
||||
| 'ChatsList'
|
||||
| 'SendViaChatDialog'
|
||||
}
|
||||
'groupchat:create': {
|
||||
logContext: 'NewChatDialog'
|
||||
}
|
||||
'starterPack:addUser': {
|
||||
starterPack?: string
|
||||
}
|
||||
@@ -1043,4 +1046,19 @@ export type Events = {
|
||||
'profile:associated:germ:click-self-info': {}
|
||||
'profile:associated:germ:self-disconnect': {}
|
||||
'profile:associated:germ:self-reconnect': {}
|
||||
|
||||
// Gallery carousel events
|
||||
'post:gallery:swipe': {
|
||||
fromImage: number
|
||||
toImage: number
|
||||
totalImages: number
|
||||
}
|
||||
'post:gallery:openLightbox': {
|
||||
fromImage: number
|
||||
totalImages: number
|
||||
}
|
||||
'post:gallery:impression': {
|
||||
totalImages: number
|
||||
postUri: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
type Props = {
|
||||
animate?: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
export function AvatarBubbles({
|
||||
animate = false,
|
||||
profiles: allProfiles,
|
||||
size = 'large',
|
||||
}: Props) {
|
||||
const {currentAccount} = useSession()
|
||||
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120
|
||||
const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1
|
||||
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
|
||||
|
||||
const initialValue = animate ? 0 : 1
|
||||
const p0 = useSharedValue(initialValue)
|
||||
const p1 = useSharedValue(initialValue)
|
||||
const p2 = useSharedValue(initialValue)
|
||||
const p3 = useSharedValue(initialValue)
|
||||
|
||||
const animateScale = (p: Animated.SharedValue<number>, index: number) => {
|
||||
p.set(0)
|
||||
p.set(() =>
|
||||
withDelay(
|
||||
500 + index * 100,
|
||||
withTiming(1, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.back(1.75)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const playScaleAnimation = useCallback(() => {
|
||||
animateScale(p0, 0)
|
||||
animateScale(p1, 1)
|
||||
animateScale(p2, 2)
|
||||
animateScale(p3, 3)
|
||||
}, [p0, p1, p2, p3])
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate) return
|
||||
playScaleAnimation()
|
||||
}, [animate, playScaleAnimation])
|
||||
|
||||
let avatars = (
|
||||
<>
|
||||
<AvatarBubble
|
||||
profile={profiles[0] ?? allProfiles[0]}
|
||||
scale={p0}
|
||||
size={76}
|
||||
x={-2}
|
||||
y={-2}
|
||||
style={[a.z_20]}
|
||||
includeProfileBorder
|
||||
/>
|
||||
<AvatarBubble
|
||||
profile={profiles[1]}
|
||||
scale={p1}
|
||||
size={76}
|
||||
x={42}
|
||||
y={42}
|
||||
style={[a.z_10]}
|
||||
includeProfileBorder
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
if (profiles.length === 3) {
|
||||
avatars = (
|
||||
<>
|
||||
<AvatarBubble
|
||||
profile={profiles[0]}
|
||||
scale={p0}
|
||||
size={68}
|
||||
x={-2}
|
||||
y={-2}
|
||||
/>
|
||||
<AvatarBubble
|
||||
profile={profiles[1]}
|
||||
scale={p1}
|
||||
size={56}
|
||||
x={38}
|
||||
y={62}
|
||||
/>
|
||||
<AvatarBubble
|
||||
profile={profiles[2]}
|
||||
scale={p2}
|
||||
size={46}
|
||||
x={71}
|
||||
y={18}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (profiles.length >= 4) {
|
||||
avatars = (
|
||||
<>
|
||||
<AvatarBubble
|
||||
profile={profiles[0]}
|
||||
scale={p0}
|
||||
size={68}
|
||||
x={-2}
|
||||
y={-2}
|
||||
/>
|
||||
<AvatarBubble
|
||||
profile={profiles[1]}
|
||||
scale={p1}
|
||||
size={56}
|
||||
x={60}
|
||||
y={49}
|
||||
/>
|
||||
<AvatarBubble
|
||||
profile={profiles[2]}
|
||||
scale={p2}
|
||||
size={42}
|
||||
x={14}
|
||||
y={74}
|
||||
/>
|
||||
<AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
a.p_2xs,
|
||||
{
|
||||
height: containerSize,
|
||||
width: containerSize,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
marginTop: marginOffset,
|
||||
marginLeft: marginOffset,
|
||||
transform: [{scale}],
|
||||
transformOrigin: 'top left',
|
||||
},
|
||||
]}>
|
||||
{avatars}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBubble({
|
||||
profile,
|
||||
scale,
|
||||
size,
|
||||
style,
|
||||
x,
|
||||
y,
|
||||
includeProfileBorder,
|
||||
}: {
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
scale: Animated.SharedValue<number>
|
||||
size: number
|
||||
style?: StyleProp<ViewStyle>
|
||||
x: number
|
||||
y: number
|
||||
includeProfileBorder?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{translateX: x},
|
||||
{translateY: y},
|
||||
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
a.flex_grow_0,
|
||||
{transform: [{translateX: x}, {translateY: y}]},
|
||||
includeProfileBorder && {
|
||||
borderColor: t.atoms.text_inverted.color,
|
||||
borderWidth: 2,
|
||||
},
|
||||
style,
|
||||
animatedStyle,
|
||||
]}>
|
||||
{profile ? (
|
||||
<Avatar profile={profile} size={size} />
|
||||
) : (
|
||||
<AvatarPlaceholder size={size} />
|
||||
)}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({
|
||||
profile,
|
||||
size = 76,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
size?: number
|
||||
}) {
|
||||
return (
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={size}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
noBorder
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarPlaceholder({size = 76}: {size?: number}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_200,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
]}>
|
||||
<PersonIcon
|
||||
width={size * 0.5}
|
||||
height={size * 0.5}
|
||||
fill={t.atoms.text_inverted.color}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {useSift, type UseSiftReturn} from '@bsky.app/sift'
|
||||
import {
|
||||
facets,
|
||||
@@ -121,7 +120,6 @@ export function Composer({
|
||||
...rest
|
||||
}: ComposerProps) {
|
||||
const {theme: t, fonts} = useAlf()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
/*
|
||||
* Meat and potatoes
|
||||
@@ -140,7 +138,6 @@ export function Composer({
|
||||
offset: a.p_sm.padding,
|
||||
placement: autocompletePlacement,
|
||||
dynamicWidth: IS_WEB,
|
||||
insets,
|
||||
})
|
||||
|
||||
/*
|
||||
@@ -306,7 +303,13 @@ export function Composer({
|
||||
{IS_WEB && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
|
||||
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}
|
||||
ref={node => {
|
||||
if (IS_WEB && node) {
|
||||
// @ts-ignore web only a11y
|
||||
node.setAttribute('inert', '')
|
||||
}
|
||||
}}>
|
||||
<Animated.View
|
||||
style={[
|
||||
contentPaddingStyle,
|
||||
@@ -335,6 +338,8 @@ export function Composer({
|
||||
web({
|
||||
caretColor: textStyle.color ?? 'black',
|
||||
overscrollBehavior: 'none',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_200} transparent`,
|
||||
}),
|
||||
]}
|
||||
{...rest}
|
||||
@@ -368,6 +373,7 @@ export function Composer({
|
||||
|
||||
{activeFacet && activeFacet.type !== 'url' && (
|
||||
<AutocompleteInner
|
||||
inverted={autocompletePlacement?.startsWith('top')}
|
||||
sift={sift}
|
||||
activeFacet={activeFacet}
|
||||
onDismiss={() => setActiveFacet(null)}
|
||||
@@ -382,10 +388,12 @@ export function Composer({
|
||||
*/
|
||||
|
||||
function AutocompleteInner({
|
||||
inverted,
|
||||
sift,
|
||||
activeFacet,
|
||||
onDismiss,
|
||||
}: {
|
||||
inverted?: boolean
|
||||
sift: UseSiftReturn
|
||||
activeFacet: TapperActiveFacet
|
||||
onDismiss: () => void
|
||||
@@ -410,7 +418,7 @@ function AutocompleteInner({
|
||||
|
||||
return items && items.length ? (
|
||||
<AutocompleteBase
|
||||
inverted={!IS_WEB}
|
||||
inverted={inverted}
|
||||
sift={sift}
|
||||
data={items}
|
||||
render={props => {
|
||||
|
||||
@@ -482,7 +482,11 @@ function TriggerClone({
|
||||
)
|
||||
}
|
||||
|
||||
export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
|
||||
export function AuxiliaryView({
|
||||
children,
|
||||
align = 'left',
|
||||
style,
|
||||
}: AuxiliaryViewProps) {
|
||||
const context = useContextMenuContext()
|
||||
const {width: screenWidth} = useWindowDimensions()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
@@ -556,6 +560,7 @@ export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
|
||||
: {right: screenWidth - measurement.x - measurement.width},
|
||||
animatedStyle,
|
||||
a.z_20,
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
|
||||
@@ -21,6 +21,7 @@ export type {
|
||||
export type AuxiliaryViewProps = {
|
||||
children?: React.ReactNode
|
||||
align?: 'left' | 'right'
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {
|
||||
GlassView as ExpoGlassView,
|
||||
type GlassViewProps as ExpoGlassViewProps,
|
||||
isGlassEffectAPIAvailable,
|
||||
isLiquidGlassAvailable,
|
||||
} from 'expo-glass-effect'
|
||||
|
||||
import {useTheme} from '#/alf'
|
||||
|
||||
export const IS_GLASS_AVAILABLE =
|
||||
isLiquidGlassAvailable() && isGlassEffectAPIAvailable()
|
||||
|
||||
/**
|
||||
* Liquid Glass View that uses `expo-glass-effect`
|
||||
*
|
||||
* If unavailable, falls back to a regular `View`. Use `fallbackStyle` to customize the fallback appearance.
|
||||
*/
|
||||
export const GlassView = IS_GLASS_AVAILABLE ? InnerGlassView : FallbackView
|
||||
|
||||
export type GlassViewProps = ExpoGlassViewProps & {
|
||||
fallbackStyle?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
function InnerGlassView({
|
||||
fallbackStyle: _fallbackStyle,
|
||||
...props
|
||||
}: GlassViewProps) {
|
||||
const t = useTheme()
|
||||
return <ExpoGlassView colorScheme={t.scheme} {...props} />
|
||||
}
|
||||
|
||||
function FallbackView({fallbackStyle, style, ...props}: GlassViewProps) {
|
||||
return <View style={[fallbackStyle, style]} {...props} />
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {useLightboxControls} from '#/state/lightbox'
|
||||
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {Gallery} from '#/components/images/Gallery'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {type EmbedType} from '#/types/bsky/post'
|
||||
import {type CommonProps} from './types'
|
||||
|
||||
@@ -23,8 +25,10 @@ export function ImageEmbed({
|
||||
}: CommonProps & {
|
||||
embed: EmbedType<'images'>
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {openLightbox} = useLightboxControls()
|
||||
const {images} = embed.view
|
||||
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||
|
||||
if (images.length > 0) {
|
||||
const items = images.map(img => ({
|
||||
@@ -95,6 +99,19 @@ export function ImageEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
if (galleryEnabled) {
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<Gallery
|
||||
images={images}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
viewContext={rest.viewContext}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<ImageLayoutGrid
|
||||
|
||||
@@ -19,6 +19,7 @@ import {Link} from '#/view/com/util/Link'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {GalleryBleed} from '#/components/images/Gallery'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -308,6 +309,7 @@ export function QuoteEmbed({
|
||||
<Embed
|
||||
embed={quote.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
|
||||
isWithinQuote={parentIsWithinQuote ?? true}
|
||||
// already within quote? override nested
|
||||
allowNestedQuotes={
|
||||
@@ -319,43 +321,45 @@ export function QuoteEmbed({
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.mt_sm]}
|
||||
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
||||
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
||||
<ContentHider
|
||||
modui={moderation?.ui('contentList')}
|
||||
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
|
||||
activeStyle={[a.p_md, a.pt_sm]}
|
||||
childContainerStyle={[a.pt_sm]}>
|
||||
{({active}) => (
|
||||
<>
|
||||
{!active && !linkDisabled && (
|
||||
<SubtleHover
|
||||
native
|
||||
hover={hover || pressed}
|
||||
style={[a.rounded_md]}
|
||||
/>
|
||||
)}
|
||||
{linkDisabled ? (
|
||||
<View style={[!active && a.p_md]} pointerEvents="none">
|
||||
{contents}
|
||||
</View>
|
||||
) : (
|
||||
<Link
|
||||
style={[!active && a.p_md]}
|
||||
hoverStyle={t.atoms.border_contrast_high}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
onBeforePress={onBeforePress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
{contents}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContentHider>
|
||||
</View>
|
||||
<GalleryBleed>
|
||||
<View
|
||||
style={[a.mt_sm]}
|
||||
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
||||
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
||||
<ContentHider
|
||||
modui={moderation?.ui('contentList')}
|
||||
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
|
||||
activeStyle={[a.p_md, a.pt_sm]}
|
||||
childContainerStyle={[a.pt_sm]}>
|
||||
{({active}) => (
|
||||
<>
|
||||
{!active && !linkDisabled && (
|
||||
<SubtleHover
|
||||
native
|
||||
hover={hover || pressed}
|
||||
style={[a.rounded_md]}
|
||||
/>
|
||||
)}
|
||||
{linkDisabled ? (
|
||||
<View style={[!active && a.p_md]} pointerEvents="none">
|
||||
{contents}
|
||||
</View>
|
||||
) : (
|
||||
<Link
|
||||
style={[!active && a.p_md]}
|
||||
hoverStyle={t.atoms.border_contrast_high}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
onBeforePress={onBeforePress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
{contents}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContentHider>
|
||||
</View>
|
||||
</GalleryBleed>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import {useLingui} from '@lingui/react/macro'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
|
||||
import {PostMenuItems} from './PostMenuItems'
|
||||
|
||||
|
||||
@@ -22,7 +22,13 @@ import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function RecentChats({postUri}: {postUri: string}) {
|
||||
export function RecentChats({
|
||||
postUri,
|
||||
onBeforePress,
|
||||
}: {
|
||||
postUri: string
|
||||
onBeforePress?: () => void
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const control = useDialogContext()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -32,6 +38,7 @@ export function RecentChats({postUri}: {postUri: string}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onSelectChat = (convoId: string) => {
|
||||
onBeforePress?.()
|
||||
control.close(() => {
|
||||
ax.metric('share:press:recentDm', {})
|
||||
navigation.navigate('MessagesConversation', {
|
||||
|
||||
@@ -5,12 +5,14 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {shareText, shareUrl} from '#/lib/sharing'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {precachePost} from '#/state/queries/post'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
@@ -40,6 +42,7 @@ let ShareMenuItems = ({
|
||||
const sendViaChatControl = useDialogControl()
|
||||
const [devModeEnabled] = useDevMode()
|
||||
const aa = useAgeAssurance()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const postUri = post.uri
|
||||
const postAuthor = useProfileShadow(post.author)
|
||||
@@ -77,7 +80,12 @@ let ShareMenuItems = ({
|
||||
onShareProp()
|
||||
}
|
||||
|
||||
const onBeforeShareViaChat = () => {
|
||||
precachePost(queryClient, postUri, post)
|
||||
}
|
||||
|
||||
const onSelectChatToShareTo = (conversation: string) => {
|
||||
onBeforeShareViaChat()
|
||||
navigation.navigate('MessagesConversation', {
|
||||
conversation,
|
||||
embed: postUri,
|
||||
@@ -98,7 +106,10 @@ let ShareMenuItems = ({
|
||||
{hasSession && aa.state.access === aa.Access.Full && (
|
||||
<Menu.Group>
|
||||
<Menu.ContainerItem>
|
||||
<RecentChats postUri={postUri} />
|
||||
<RecentChats
|
||||
postUri={postUri}
|
||||
onBeforePress={onBeforeShareViaChat}
|
||||
/>
|
||||
</Menu.ContainerItem>
|
||||
<Menu.Item
|
||||
testID="postDropdownSendViaDMBtn"
|
||||
|
||||
@@ -18,8 +18,8 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {native} from '#/alf'
|
||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
|
||||
import {ShareMenuItems} from './ShareMenuItems'
|
||||
|
||||
@@ -303,7 +303,7 @@ let PostControls = ({
|
||||
isToggled={Boolean(post.viewer?.like)}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
renderCount={({count}) => (
|
||||
<PostControlButtonText>
|
||||
<PostControlButtonText testID="likeCount">
|
||||
{formatPostStatCount(count)}
|
||||
</PostControlButtonText>
|
||||
)}
|
||||
|
||||
@@ -109,21 +109,32 @@ export function FollowDialogWithoutGuide({
|
||||
let lastSelectedInterest = ''
|
||||
let lastSearchText = ''
|
||||
|
||||
const FOR_YOU_TAB = 'all'
|
||||
|
||||
function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const interestsDisplayNames = useInterestsDisplayNames()
|
||||
const rawInterestsDisplayNames = useInterestsDisplayNames()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const personalizedInterests = preferences?.interests?.tags
|
||||
const interests = Object.keys(interestsDisplayNames)
|
||||
.sort(boostInterests(popularInterests))
|
||||
.sort(boostInterests(personalizedInterests))
|
||||
const interests = useMemo(
|
||||
() => [
|
||||
FOR_YOU_TAB,
|
||||
...Object.keys(rawInterestsDisplayNames)
|
||||
.sort(boostInterests(popularInterests))
|
||||
.sort(boostInterests(personalizedInterests)),
|
||||
],
|
||||
[rawInterestsDisplayNames, personalizedInterests],
|
||||
)
|
||||
const interestsDisplayNames = useMemo(
|
||||
() => ({
|
||||
[FOR_YOU_TAB]: l`For You`,
|
||||
...rawInterestsDisplayNames,
|
||||
}),
|
||||
[l, rawInterestsDisplayNames],
|
||||
)
|
||||
const [selectedInterest, setSelectedInterest] = useState(
|
||||
() =>
|
||||
lastSelectedInterest ||
|
||||
(personalizedInterests && interests.includes(personalizedInterests[0])
|
||||
? personalizedInterests[0]
|
||||
: interests[0]),
|
||||
() => lastSelectedInterest || FOR_YOU_TAB,
|
||||
)
|
||||
const [searchText, setSearchText] = useState(lastSearchText)
|
||||
const moderationOpts = useModerationOpts()
|
||||
@@ -137,14 +148,15 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
lastSelectedInterest = selectedInterest
|
||||
}, [searchText, selectedInterest])
|
||||
|
||||
const {
|
||||
data: suggestions,
|
||||
isFetching: isFetchingSuggestions,
|
||||
error: suggestionsError,
|
||||
} = useGetSuggestedUsersForSeeMoreQuery({
|
||||
category: selectedInterest,
|
||||
const isForYou = selectedInterest === FOR_YOU_TAB
|
||||
|
||||
const seeMoreQuery = useGetSuggestedUsersForSeeMoreQuery({
|
||||
category: isForYou ? undefined : selectedInterest,
|
||||
limit: 50,
|
||||
})
|
||||
const suggestions = seeMoreQuery.data
|
||||
const isFetchingSuggestions = seeMoreQuery.isFetching
|
||||
const suggestionsError = seeMoreQuery.error
|
||||
const {
|
||||
data: searchResults,
|
||||
isFetching: isFetchingSearchResults,
|
||||
@@ -277,7 +289,10 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
recId: recIdForLogging,
|
||||
position: position !== -1 ? position : 0,
|
||||
suggestedDid: item.profile.did,
|
||||
category: selectedInterestRef.current,
|
||||
category:
|
||||
selectedInterestRef.current === FOR_YOU_TAB
|
||||
? null
|
||||
: selectedInterestRef.current,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import {shareUrl} from '#/lib/sharing'
|
||||
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||
import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode'
|
||||
|
||||
@@ -109,7 +109,7 @@ function EmbedDialogInner({
|
||||
<Trans>Embed post</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
|
||||
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Trans>
|
||||
Embed this post in your website. Simply copy the following snippet
|
||||
and paste it into the HTML code of your website.
|
||||
@@ -117,12 +117,7 @@ function EmbedDialogInner({
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
style={[a.border, t.atoms.border_contrast_low, {borderRadius: 18}]}>
|
||||
<Button
|
||||
label={
|
||||
showCustomisation
|
||||
@@ -190,10 +185,9 @@ function EmbedDialogInner({
|
||||
<Button
|
||||
label={_(msg`Copy code`)}
|
||||
color="primary"
|
||||
variant="solid"
|
||||
size="large"
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(snippet)
|
||||
void navigator.clipboard.writeText(snippet)
|
||||
setCopied(true)
|
||||
}}>
|
||||
{copied ? (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||
@@ -15,7 +14,7 @@ export function ActionsWrapper({
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<MessageContextMenu message={message}>
|
||||
@@ -32,7 +31,7 @@ export function ActionsWrapper({
|
||||
]}
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{name: 'activate', label: _(msg`Open message options`)},
|
||||
{name: 'activate', label: l`Open message options`},
|
||||
]}
|
||||
onAccessibilityAction={() => trigger.control.open('full')}>
|
||||
{children}
|
||||
|
||||
@@ -91,7 +91,8 @@ export function ChatEmptyPill() {
|
||||
onPressOut={onPressOut}>
|
||||
<Text
|
||||
style={[a.font_semi_bold, a.pointer_events_none]}
|
||||
selectable={false}>
|
||||
selectable={false}
|
||||
emoji>
|
||||
{prompts[promptIndex]}
|
||||
</Text>
|
||||
</AnimatedPressable>
|
||||
|
||||
@@ -25,9 +25,9 @@ import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
||||
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {
|
||||
@@ -95,7 +95,7 @@ let ConvoMenu = ({
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
style={[a.bg_transparent]}>
|
||||
<ButtonIcon icon={DotsHorizontal} size="md" />
|
||||
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
@@ -220,9 +220,9 @@ function MenuContent({
|
||||
}
|
||||
|
||||
if (userBlock) {
|
||||
queueUnblock()
|
||||
void queueUnblock()
|
||||
} else {
|
||||
queueBlock()
|
||||
void queueBlock()
|
||||
}
|
||||
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
|
||||
|
||||
@@ -233,7 +233,7 @@ function MenuContent({
|
||||
<Menu.ItemText>
|
||||
<Trans>Leave conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ArrowBoxLeft} />
|
||||
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<>
|
||||
@@ -245,7 +245,7 @@ function MenuContent({
|
||||
<Menu.ItemText>
|
||||
<Trans>Mark as read</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Bubble} />
|
||||
<Menu.ItemIcon icon={BubbleIcon} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
@@ -296,7 +296,7 @@ function MenuContent({
|
||||
<Menu.ItemText>
|
||||
<Trans>Leave conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ArrowBoxLeft} />
|
||||
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {memo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {subDays} from 'date-fns'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -29,7 +27,7 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
|
||||
})
|
||||
|
||||
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
let date: string
|
||||
@@ -42,9 +40,9 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
const oneWeekAgo = subDays(today, 7)
|
||||
|
||||
if (localDateString(today) === localDateString(timestamp)) {
|
||||
date = _(msg`Today`)
|
||||
date = l`Today`
|
||||
} else if (localDateString(yesterday) === localDateString(timestamp)) {
|
||||
date = _(msg`Yesterday`)
|
||||
date = l`Yesterday`
|
||||
} else {
|
||||
if (timestamp < oneWeekAgo) {
|
||||
if (timestamp.getFullYear() === today.getFullYear()) {
|
||||
@@ -58,7 +56,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.my_lg]}>
|
||||
<View style={[a.w_full, a.my_sm]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
@@ -68,11 +66,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
a.px_md,
|
||||
]}>
|
||||
<Trans>
|
||||
<Text
|
||||
style={[a.text_xs, t.atoms.text_contrast_medium, a.font_semi_bold]}>
|
||||
{date}
|
||||
</Text>{' '}
|
||||
at {time}
|
||||
{date} at {time}
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -2,8 +2,7 @@ import {memo, useCallback} from 'react'
|
||||
import {LayoutAnimation, Platform} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||
@@ -12,13 +11,14 @@ import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as ContextMenu from '#/components/ContextMenu'
|
||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {ReportDialog} from '#/components/moderation/ReportDialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
@@ -35,7 +35,7 @@ export let MessageContextMenu = ({
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
children: TriggerProps['children']
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -47,6 +47,7 @@ export let MessageContextMenu = ({
|
||||
const translate = useGoogleTranslate()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
|
||||
|
||||
const onCopyMessage = useCallback(() => {
|
||||
const str = richTextToString(
|
||||
@@ -58,10 +59,10 @@ export let MessageContextMenu = ({
|
||||
)
|
||||
|
||||
void Clipboard.setStringAsync(str)
|
||||
Toast.show(_(msg`Copied to clipboard`), {
|
||||
Toast.show(l`Copied to clipboard`, {
|
||||
type: 'success',
|
||||
})
|
||||
}, [_, message.text, message.facets])
|
||||
}, [l, message.text, message.facets])
|
||||
|
||||
const onPressTranslateMessage = useCallback(() => {
|
||||
void translate(message.text, langPrefs.primaryLanguage)
|
||||
@@ -79,11 +80,9 @@ export let MessageContextMenu = ({
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() =>
|
||||
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
|
||||
)
|
||||
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
||||
}, [_, convo, message.id])
|
||||
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
|
||||
.catch(() => Toast.show(l`Failed to delete message`))
|
||||
}, [l, convo, message.id])
|
||||
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
@@ -96,17 +95,17 @@ export let MessageContextMenu = ({
|
||||
) {
|
||||
convo
|
||||
.removeReaction(message.id, emoji)
|
||||
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
|
||||
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
|
||||
} else {
|
||||
if (hasReachedReactionLimit(message, currentAccount?.did)) return
|
||||
convo.addReaction(message.id, emoji).catch(() =>
|
||||
Toast.show(_(msg`Failed to add emoji reaction`), {
|
||||
Toast.show(l`Failed to add emoji reaction`, {
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
[_, convo, message, currentAccount?.did],
|
||||
[l, convo, message, currentAccount?.did],
|
||||
)
|
||||
|
||||
const sender = convo.convo.members.find(
|
||||
@@ -117,7 +116,9 @@ export let MessageContextMenu = ({
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
{IS_NATIVE && (
|
||||
<ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
|
||||
<ContextMenu.AuxiliaryView
|
||||
align={isFromSelf ? 'right' : 'left'}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
<EmojiReactionPicker
|
||||
message={message}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
@@ -126,31 +127,31 @@ export let MessageContextMenu = ({
|
||||
)}
|
||||
|
||||
<ContextMenu.Trigger
|
||||
label={_(msg`Message options`)}
|
||||
contentLabel={_(
|
||||
msg`Message from @${
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`,
|
||||
)}>
|
||||
label={l`Message options`}
|
||||
contentLabel={l`Message from @${
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}>
|
||||
<ContextMenu.Outer
|
||||
align={isFromSelf ? 'right' : 'left'}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={_(msg`Translate`)}
|
||||
label={l`Translate`}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={Translate} position="right" />
|
||||
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={TranslateIcon} position="right" />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
label={l`Copy message text`}
|
||||
onPress={onCopyMessage}>
|
||||
<ContextMenu.ItemText>
|
||||
{_(msg`Copy message text`)}
|
||||
{l`Copy message text`}
|
||||
</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</ContextMenu.Item>
|
||||
@@ -159,23 +160,22 @@ export let MessageContextMenu = ({
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={_(msg`Delete message for me`)}
|
||||
label={l`Delete message for me`}
|
||||
onPress={() => deleteControl.open()}>
|
||||
<ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={Trash} position="right" />
|
||||
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={TrashIcon} position="right" />
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownReportBtn"
|
||||
label={_(msg`Report message`)}
|
||||
label={l`Report message`}
|
||||
onPress={() => reportControl.open()}>
|
||||
<ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={Warning} position="right" />
|
||||
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
|
||||
<ContextMenu.ItemIcon icon={WarningIcon} position="right" />
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Outer>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<ReportDialog
|
||||
control={reportControl}
|
||||
subject={{
|
||||
@@ -198,14 +198,11 @@ export let MessageContextMenu = ({
|
||||
message,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={_(msg`Delete message`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
title={l`Delete message`}
|
||||
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
|
||||
confirmButtonCta={l`Delete`}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
||||
+589
-207
@@ -1,13 +1,17 @@
|
||||
import {memo, useCallback, useMemo} from 'react'
|
||||
import {memo, useCallback, useMemo, useState} from 'react'
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
useSharedValue,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated'
|
||||
@@ -16,217 +20,420 @@ import {
|
||||
ChatBskyConvoDefs,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {localDateString} from './util'
|
||||
|
||||
const AVATAR_SIZE = 28
|
||||
const CLUSTERED_MESSAGE_GAP = 2
|
||||
const BORDER_RADIUS = 18
|
||||
const SQUARED_BORDER_RADIUS = 4
|
||||
const DISPLAY_NAME_INSET = 22
|
||||
|
||||
// 42px avatar + 2 * 8px my_sm margins
|
||||
const ROW_HEIGHT = 58
|
||||
|
||||
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
|
||||
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
||||
|
||||
type Reaction = {
|
||||
key: string
|
||||
value: string
|
||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
||||
count: number
|
||||
}
|
||||
|
||||
function isWithinCluster({
|
||||
isPending,
|
||||
adjacentMessage,
|
||||
isFromSameSender,
|
||||
currentSentAt,
|
||||
direction,
|
||||
}: {
|
||||
isPending: boolean
|
||||
adjacentMessage:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
isFromSameSender: boolean
|
||||
currentSentAt: string
|
||||
direction: 'prev' | 'next'
|
||||
}): boolean {
|
||||
if (!isFromSameSender) return true
|
||||
if (isPending && adjacentMessage) return false
|
||||
if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
|
||||
const thisDate = new Date(currentSentAt)
|
||||
const adjDate = new Date(adjacentMessage.sentAt)
|
||||
const diff =
|
||||
direction === 'next'
|
||||
? adjDate.getTime() - thisDate.getTime()
|
||||
: thisDate.getTime() - adjDate.getTime()
|
||||
return diff > CLUSTERED_MESSAGE_THRESHOLD_MS
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
let MessageItem = ({
|
||||
item,
|
||||
isGroupChat = false,
|
||||
profile,
|
||||
}: {
|
||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||
isGroupChat?: boolean
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {convo} = useConvoActive()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const reactionsControl = useDialogControl()
|
||||
|
||||
const {message, nextMessage, prevMessage} = item
|
||||
const isPending = item.type === 'pending-message'
|
||||
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
|
||||
)
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
|
||||
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
|
||||
|
||||
const isNextFromSelf =
|
||||
nextIsMessage && nextMessage.sender?.did === currentAccount?.did
|
||||
const isPrevFromSameSender =
|
||||
prevIsMessage && prevMessage.sender?.did === message.sender?.did
|
||||
const isNextFromSameSender =
|
||||
nextIsMessage && nextMessage.sender?.did === message.sender?.did
|
||||
|
||||
const isNextFromSameSender = isNextFromSelf === isFromSelf
|
||||
const isFirstInCluster = useMemo(
|
||||
() =>
|
||||
isWithinCluster({
|
||||
isPending,
|
||||
adjacentMessage: prevMessage,
|
||||
isFromSameSender: isPrevFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'prev',
|
||||
}),
|
||||
[isPending, prevMessage, isPrevFromSameSender, message.sentAt],
|
||||
)
|
||||
|
||||
const isNewDay = useMemo(() => {
|
||||
if (!prevMessage) return true
|
||||
const isLastInCluster = useMemo(
|
||||
() =>
|
||||
isWithinCluster({
|
||||
isPending,
|
||||
adjacentMessage: nextMessage,
|
||||
isFromSameSender: isNextFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'next',
|
||||
}),
|
||||
[isPending, nextMessage, isNextFromSameSender, message.sentAt],
|
||||
)
|
||||
|
||||
const thisDate = new Date(message.sentAt)
|
||||
const prevDate = new Date(prevMessage.sentAt)
|
||||
const hasLargeGapFromPrev =
|
||||
!ChatBskyConvoDefs.isMessageView(prevMessage) ||
|
||||
new Date(message.sentAt).getTime() -
|
||||
new Date(prevMessage.sentAt).getTime() >
|
||||
MESSAGE_GAP_THRESHOLD_MS
|
||||
|
||||
return localDateString(thisDate) !== localDateString(prevDate)
|
||||
}, [message, prevMessage])
|
||||
const showDateDivider = hasLargeGapFromPrev
|
||||
|
||||
const isLastMessageOfDay = useMemo(() => {
|
||||
if (!nextMessage || !nextIsMessage) return true
|
||||
const isInCluster = !(isFirstInCluster && isLastInCluster)
|
||||
const isInMiddleOfCluster =
|
||||
isInCluster && !isFirstInCluster && !isLastInCluster
|
||||
|
||||
const thisDate = new Date(message.sentAt)
|
||||
const prevDate = new Date(nextMessage.sentAt)
|
||||
const hasReactions = message.reactions && message.reactions.length > 0
|
||||
const squaredBottomCorner =
|
||||
!hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster)
|
||||
const squaredTopCorner =
|
||||
isInCluster && (isInMiddleOfCluster || isLastInCluster)
|
||||
|
||||
return localDateString(thisDate) !== localDateString(prevDate)
|
||||
}, [message.sentAt, nextIsMessage, nextMessage])
|
||||
|
||||
const needsTail = isLastMessageOfDay || !isNextFromSameSender
|
||||
|
||||
const isLastInGroup = useMemo(() => {
|
||||
// if this message is pending, it means the next message is pending too
|
||||
if (isPending && nextMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
// or, if there's a 5 minute gap between this message and the next
|
||||
if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
|
||||
const thisDate = new Date(message.sentAt)
|
||||
const nextDate = new Date(nextMessage.sentAt)
|
||||
|
||||
const diff = nextDate.getTime() - thisDate.getTime()
|
||||
|
||||
// 5 minutes
|
||||
return diff > 5 * 60 * 1000
|
||||
}
|
||||
|
||||
return true
|
||||
}, [message, nextMessage, isPending])
|
||||
|
||||
const pendingColor = t.palette.primary_200
|
||||
const pendingColor = t.palette.primary_300
|
||||
|
||||
const rt = useMemo(() => {
|
||||
return new RichTextAPI({text: message.text, facets: message.facets})
|
||||
}, [message.text, message.facets])
|
||||
|
||||
const hasEmbedAndText =
|
||||
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
|
||||
|
||||
const avatar = profile ? (
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
moderationOpts={moderationOpts!}
|
||||
disabledPreview
|
||||
/>
|
||||
) : (
|
||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||
)
|
||||
|
||||
const groupedReactions = useMemo(() => {
|
||||
const reactions = message.reactions ?? []
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
key: string
|
||||
value: string
|
||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
||||
count: number
|
||||
}
|
||||
>()
|
||||
for (const reaction of reactions) {
|
||||
if (!reaction) continue
|
||||
const existing = grouped.get(reaction.value)
|
||||
if (existing) {
|
||||
existing.senders.push(reaction.sender)
|
||||
existing.count++
|
||||
} else {
|
||||
grouped.set(reaction.value, {
|
||||
key: reaction.value,
|
||||
value: reaction.value,
|
||||
senders: [reaction.sender],
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.values())
|
||||
}, [message.reactions])
|
||||
|
||||
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
|
||||
|
||||
const reactionsLabel = useMemo(() => {
|
||||
if (reactions.length === 0) return ''
|
||||
if (reactions.length === 1) {
|
||||
const reaction = reactions[0]
|
||||
const sender = reaction.sender
|
||||
if (sender.did === currentAccount?.did) {
|
||||
return l`You reacted ${reaction.value}`
|
||||
} else {
|
||||
const senderDid = reaction.sender.did
|
||||
const sender = convo.members.find(member => member.did === senderDid)
|
||||
if (sender) {
|
||||
return l`${sanitizeDisplayName(
|
||||
sender.displayName || sender.handle,
|
||||
)} reacted ${reaction.value}`
|
||||
}
|
||||
return l`Someone reacted ${reaction.value}`
|
||||
}
|
||||
}
|
||||
return l`${plural(reactions.length, {
|
||||
one: '# person',
|
||||
other: '# people',
|
||||
})} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
|
||||
}, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
|
||||
|
||||
const appliedReactions = (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
{message.reactions && message.reactions.length > 0 && (
|
||||
<View
|
||||
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
|
||||
{hasReactions ? (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.py_xs,
|
||||
a.px_xs,
|
||||
a.justify_center,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.pb_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_lg,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
// vibe coded number
|
||||
transform: [{translateY: -11}],
|
||||
},
|
||||
isFromSelf ? a.align_end : a.align_start,
|
||||
a.px_sm,
|
||||
a.pb_2xs,
|
||||
]}>
|
||||
{message.reactions.map((reaction, _i, reactions) => {
|
||||
let label
|
||||
if (reaction.sender.did === currentAccount?.did) {
|
||||
label = _(msg`You reacted ${reaction.value}`)
|
||||
} else {
|
||||
const senderDid = reaction.sender.did
|
||||
const sender = convo.members.find(
|
||||
member => member.did === senderDid,
|
||||
)
|
||||
if (sender) {
|
||||
label = _(
|
||||
msg`${sanitizeDisplayName(
|
||||
sender.displayName || sender.handle,
|
||||
)} reacted ${reaction.value}`,
|
||||
)
|
||||
} else {
|
||||
label = _(msg`Someone reacted ${reaction.value}`)
|
||||
}
|
||||
<Pressable
|
||||
accessible={true}
|
||||
accessibilityLabel={reactionsLabel}
|
||||
accessibilityHint={
|
||||
isGroupChat ? l`Tap to view reactions` : undefined
|
||||
}
|
||||
return (
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.py_xs,
|
||||
a.px_xs,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
transform: [{translateY: -8}],
|
||||
},
|
||||
]}
|
||||
onPress={() =>
|
||||
isGroupChat ? reactionsControl.open() : undefined
|
||||
}>
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
|
||||
exiting={
|
||||
groupedReactions.length > 1 && native(ZoomOut.delay(200))
|
||||
}
|
||||
layout={native(LinearTransition.delay(300))}
|
||||
key={reaction.sender.did + reaction.value}
|
||||
style={[a.p_2xs]}
|
||||
accessible={true}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={_(
|
||||
msg`Double tap or long press the message to add a reaction`,
|
||||
)}>
|
||||
key={group.value}
|
||||
style={[a.p_2xs]}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{reaction.value}
|
||||
{group.value}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
{groupedReactions.length !== reactions.length &&
|
||||
reactions.length > 1 ? (
|
||||
<View style={[a.p_2xs, a.justify_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
{includeFontPadding: false},
|
||||
]}>
|
||||
{reactions.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
members={convo.members}
|
||||
reactions={message.reactions}
|
||||
groupedReactions={groupedReactions}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isNewDay && <DateDivider date={message.sentAt} />}
|
||||
{showDateDivider && (
|
||||
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
||||
<DateDivider date={message.sentAt} />
|
||||
</Animated.View>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
isFromSelf ? a.mr_md : a.ml_md,
|
||||
nextIsMessage && !isNextFromSameSender && a.mb_md,
|
||||
isFromSelf ? a.mr_sm : a.ml_sm,
|
||||
isFirstInCluster && !showDateDivider && a.mt_sm,
|
||||
]}>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed embed={message.embed} />
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
style={
|
||||
!isOnlyEmoji(message.text) && [
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: needsTail ? 2 : 17}
|
||||
: {borderBottomLeftRadius: needsTail ? 2 : 17},
|
||||
]
|
||||
}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
<View style={[a.relative]}>
|
||||
{isGroupChat && !isFromSelf && isLastInCluster ? (
|
||||
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
|
||||
{avatar}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{IS_NATIVE && appliedReactions}
|
||||
</ActionsWrapper>
|
||||
|
||||
{!IS_NATIVE && appliedReactions}
|
||||
|
||||
{isLastInGroup && (
|
||||
) : null}
|
||||
<View
|
||||
style={[
|
||||
a.flex_grow,
|
||||
!isFromSelf &&
|
||||
isGroupChat && {
|
||||
paddingLeft: AVATAR_SIZE,
|
||||
},
|
||||
]}>
|
||||
{isGroupChat &&
|
||||
!isFromSelf &&
|
||||
isFirstInCluster &&
|
||||
!isOnlyEmoji(message.text) ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pt_xs,
|
||||
a.pb_2xs,
|
||||
{
|
||||
paddingLeft: DISPLAY_NAME_INSET,
|
||||
},
|
||||
]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
) : null}
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
||||
style={[
|
||||
!isFromSelf && a.ml_sm,
|
||||
...(isOnlyEmoji(message.text)
|
||||
? []
|
||||
: [
|
||||
a.rounded_md,
|
||||
a.rounded_xl,
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
{
|
||||
marginTop: isFirstInCluster
|
||||
? 0
|
||||
: CLUSTERED_MESSAGE_GAP,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {
|
||||
borderBottomRightRadius:
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopRightRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
}
|
||||
: {
|
||||
borderBottomLeftRadius:
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopLeftRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
},
|
||||
]),
|
||||
]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
squaredBottomCorner={squaredBottomCorner}
|
||||
squaredTopCorner={squaredTopCorner || hasEmbedAndText}
|
||||
/>
|
||||
)}
|
||||
{appliedReactions}
|
||||
</ActionsWrapper>
|
||||
</View>
|
||||
</View>
|
||||
{isLastInCluster && (
|
||||
<MessageItemMetadata
|
||||
item={item}
|
||||
style={isFromSelf ? a.text_right : a.text_left}
|
||||
style={[isFromSelf ? a.text_right : a.text_left]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -244,8 +451,7 @@ let MessageItemMetadata = ({
|
||||
style: StyleProp<TextStyle>
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {message} = item
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const handleRetry = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
@@ -258,75 +464,251 @@ let MessageItemMetadata = ({
|
||||
[item],
|
||||
)
|
||||
|
||||
const relativeTimestamp = useCallback(
|
||||
(i18n: I18n, timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const errorColor = t.palette.negative_400
|
||||
|
||||
const time = i18n.date(date, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// if under 30 seconds
|
||||
if (diff < 1000 * 30) {
|
||||
return _(msg`Now`)
|
||||
}
|
||||
|
||||
return time
|
||||
},
|
||||
[_],
|
||||
)
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.mt_2xs,
|
||||
a.mb_lg,
|
||||
t.atoms.text_contrast_medium,
|
||||
style,
|
||||
]}>
|
||||
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
|
||||
{item.type === 'pending-message' && item.failed && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
{
|
||||
color: t.palette.negative_400,
|
||||
},
|
||||
]}>
|
||||
{_(msg`Failed to send`)}
|
||||
switch (item.type) {
|
||||
case 'pending-message':
|
||||
return item.failed ? (
|
||||
<Text style={[a.text_xs, a.my_2xs, {color: errorColor}, style]}>
|
||||
<Text style={[a.text_xs, {color: errorColor}]}>
|
||||
<Trans>Message failed to send.</Trans>
|
||||
</Text>
|
||||
{item.retry && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Click to retry failed message`)}
|
||||
label={l`Click to retry failed message`}
|
||||
to="#"
|
||||
onPress={handleRetry}
|
||||
style={[a.text_xs]}>
|
||||
{_(msg`Retry`)}
|
||||
style={[a.text_xs, {color: errorColor}]}>
|
||||
<Trans>Tap to retry</Trans>
|
||||
</InlineLinkText>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
</Text>
|
||||
) : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
MessageItemMetadata = memo(MessageItemMetadata)
|
||||
export {MessageItemMetadata}
|
||||
|
||||
function ReactionsDialog({
|
||||
control,
|
||||
members,
|
||||
reactions,
|
||||
groupedReactions,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
members: bsky.profile.AnyProfileView[]
|
||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||
groupedReactions?: Reaction[]
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const [selected, setSelected] = useState('all')
|
||||
|
||||
const handleFilter = (value: string) => {
|
||||
setSelected(value)
|
||||
}
|
||||
|
||||
const filteredMembers =
|
||||
selected === 'all'
|
||||
? members
|
||||
: members.filter(m =>
|
||||
reactions?.some(r => r.sender.did === m.did && r.value === selected),
|
||||
)
|
||||
|
||||
const minHeight = members.length * ROW_HEIGHT
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={() => setSelected('all')}
|
||||
nativeOptions={{preventExpansion: true, minHeight}}>
|
||||
<Dialog.Handle />
|
||||
<View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}>
|
||||
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
|
||||
<Trans>Reactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<ReactionTabs
|
||||
groupedReactions={groupedReactions}
|
||||
selected={selected}
|
||||
totalReactions={reactions?.length ?? 0}
|
||||
onFilter={handleFilter}
|
||||
/>
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Reactions`}
|
||||
contentContainerStyle={[a.pt_0]}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
{filteredMembers.map(profile => {
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
|
||||
)
|
||||
const handle = sanitizeHandle(profile?.handle ?? '', '@')
|
||||
const reaction = reactions?.find(
|
||||
({sender}) => sender.did === profile.did,
|
||||
)
|
||||
const rt = reaction
|
||||
? new RichTextAPI({text: reaction.value})
|
||||
: undefined
|
||||
|
||||
return rt ? (
|
||||
<View
|
||||
key={profile.did}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.my_sm,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={42}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
/>
|
||||
<View>
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
{handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={2}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null
|
||||
})}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionTabs({
|
||||
groupedReactions,
|
||||
selected,
|
||||
totalReactions,
|
||||
onFilter,
|
||||
}: {
|
||||
groupedReactions?: Reaction[]
|
||||
selected: string
|
||||
totalReactions: number
|
||||
onFilter: (value: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const contentSize = useSharedValue(0)
|
||||
const scrollX = useSharedValue(0)
|
||||
|
||||
const handlePress = (value: string) => {
|
||||
onFilter(value)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'all',
|
||||
value: l`All`,
|
||||
senders: [],
|
||||
count: totalReactions,
|
||||
} as Reaction,
|
||||
...(groupedReactions ?? []),
|
||||
]
|
||||
|
||||
return (
|
||||
<View accessibilityRole="list" style={[t.atoms.bg]}>
|
||||
<DraggableScrollView
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={e => {
|
||||
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
|
||||
}}>
|
||||
<Animated.View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_grow,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
]}
|
||||
onLayout={e => {
|
||||
contentSize.set(e.nativeEvent.layout.width)
|
||||
}}>
|
||||
{tabs?.map((reaction, index) => (
|
||||
<ReactionTab
|
||||
key={reaction.value}
|
||||
index={index}
|
||||
reaction={reaction}
|
||||
selected={selected}
|
||||
total={tabs.length}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</DraggableScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionTab({
|
||||
index,
|
||||
reaction,
|
||||
selected,
|
||||
total,
|
||||
onPress,
|
||||
}: {
|
||||
index: number
|
||||
reaction: Reaction
|
||||
selected: string
|
||||
total: number
|
||||
onPress: (value: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={
|
||||
reaction.key === 'all'
|
||||
? l`Tap to show all reactions `
|
||||
: l`Tap to show ${reaction.value} reactions`
|
||||
}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.border,
|
||||
a.justify_center,
|
||||
a.rounded_lg,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.mb_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
|
||||
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
|
||||
]}
|
||||
onPress={() => onPress(reaction.key)}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{l`${reaction.value} ${reaction.count}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,24 @@ import {memo} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
|
||||
|
||||
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {MessageContextProvider} from './MessageContext'
|
||||
|
||||
const CLUSTERED_MESSAGE_GAP = 2
|
||||
const BORDER_RADIUS = 20
|
||||
const SQUARED_BORDER_RADIUS = 4
|
||||
|
||||
let MessageItemEmbed = ({
|
||||
embed,
|
||||
isFromSelf,
|
||||
squaredTopCorner,
|
||||
squaredBottomCorner,
|
||||
}: {
|
||||
embed: $Typed<AppBskyEmbedRecord.View>
|
||||
isFromSelf: boolean
|
||||
squaredTopCorner: boolean
|
||||
squaredBottomCorner: boolean
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const screen = useWindowDimensions()
|
||||
@@ -18,7 +28,7 @@ let MessageItemEmbed = ({
|
||||
<MessageContextProvider>
|
||||
<View
|
||||
style={[
|
||||
a.my_xs,
|
||||
isFromSelf ? a.mr_sm : a.ml_sm,
|
||||
t.atoms.bg,
|
||||
a.rounded_md,
|
||||
native({
|
||||
@@ -30,12 +40,38 @@ let MessageItemEmbed = ({
|
||||
minWidth: 280,
|
||||
maxWidth: 360,
|
||||
}),
|
||||
{
|
||||
marginTop: CLUSTERED_MESSAGE_GAP,
|
||||
},
|
||||
]}>
|
||||
<View style={{marginTop: tokens.space.sm * -1}}>
|
||||
<View style={{marginTop: -8}}>
|
||||
<Embed
|
||||
embed={embed}
|
||||
allowNestedQuotes
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
style={[
|
||||
a.rounded_xl,
|
||||
a.border_0,
|
||||
isFromSelf
|
||||
? {
|
||||
backgroundColor: t.palette.primary_50,
|
||||
borderBottomRightRadius: squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopRightRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
}
|
||||
: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
borderBottomLeftRadius: squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopLeftRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -5,24 +5,29 @@ import {
|
||||
type ModerationCause,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Link} from '#/components/Link'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||
|
||||
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
|
||||
|
||||
@@ -48,7 +53,7 @@ export function MessagesListHeader({
|
||||
}, [moderation])
|
||||
|
||||
return (
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.Outer noBottomBorder={IS_LIQUID_GLASS}>
|
||||
<View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
|
||||
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
|
||||
<Layout.Header.BackButton />
|
||||
@@ -72,19 +77,12 @@ export function MessagesListHeader({
|
||||
<View style={a.gap_xs}>
|
||||
<View
|
||||
style={[
|
||||
{width: 120, height: 16},
|
||||
{width: 150, height: 16},
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.mt_xs,
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
{width: 175, height: 12},
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -108,22 +106,27 @@ function HeaderReady({
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const convoState = useConvo()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const groupInfo = convoState.getGroupInfo?.()
|
||||
const isGroupChat = groupInfo != null
|
||||
|
||||
const isDeletedAccount = profile?.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
? _(msg`Deleted Account`)
|
||||
: sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const displayName = isGroupChat
|
||||
? (groupInfo.name ?? l`${profile.handle}'s group chat`)
|
||||
: isDeletedAccount
|
||||
? l`Deleted Account`
|
||||
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||
|
||||
// @ts-ignore findLast is polyfilled - esb
|
||||
const latestMessageFromOther = convoState.items.findLast(
|
||||
(item: ConvoItem) =>
|
||||
item.type === 'message' && item.message.sender.did === profile.did,
|
||||
item.type === 'message' &&
|
||||
item.message.sender.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
const latestReportableMessage =
|
||||
@@ -131,85 +134,95 @@ function HeaderReady({
|
||||
? latestMessageFromOther.message
|
||||
: undefined
|
||||
|
||||
const handleNavigateToSettings = () => {
|
||||
const convoId = convoState.convo?.id
|
||||
if (convoId) {
|
||||
navigation.navigate('MessagesConversationSettings', {
|
||||
conversation: convoId,
|
||||
})
|
||||
} else {
|
||||
logger.error(`handleNavigateToSettings: missing convo ID`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Link
|
||||
label={_(msg`View ${displayName}'s profile`)}
|
||||
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
||||
to={makeProfileLink(profile)}>
|
||||
<PreviewableUserAvatar
|
||||
size={PFP_SIZE}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
disableHoverCard={moderation.blocked}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
a.self_start,
|
||||
web(a.leading_normal),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
||||
</View>
|
||||
{!isDeletedAccount && (
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_xs,
|
||||
web([a.leading_normal, {marginTop: -2}]),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
@{profile.handle}
|
||||
{isGroupChat ? (
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}>
|
||||
<AvatarBubbles
|
||||
size="small"
|
||||
profiles={convoState.recipients ?? []}
|
||||
/>
|
||||
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Link
|
||||
label={l`View ${displayName}'s profile`}
|
||||
style={[a.flex_row, a.gap_md, a.flex_1, a.pr_md]}
|
||||
to={makeProfileLink(profile)}>
|
||||
<PreviewableUserAvatar
|
||||
size={PFP_SIZE}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
disableHoverCard={moderation.blocked}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_semi_bold, a.self_start]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
||||
{convoState.convo?.muted && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<BellStroke
|
||||
size="xs"
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{' '}
|
||||
·{' '}
|
||||
</Text>
|
||||
<BellOffIcon
|
||||
size="sm"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
|
||||
<Layout.Header.Slot>
|
||||
{isConvoActive(convoState) && (
|
||||
<ConvoMenu
|
||||
convo={convoState.convo}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
)}
|
||||
{isConvoActive(convoState) ? (
|
||||
isGroupChat ? (
|
||||
<Button
|
||||
label={l`Open group chat settings`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
style={[a.bg_transparent]}
|
||||
onPress={handleNavigateToSettings}>
|
||||
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
|
||||
</Button>
|
||||
) : (
|
||||
<ConvoMenu
|
||||
convo={convoState.convo}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Layout.Header.Slot>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingLeft: PFP_SIZE + a.gap_md.gap,
|
||||
},
|
||||
]}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="lg"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||
import {logger} from '#/logger'
|
||||
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
|
||||
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
@@ -38,12 +39,28 @@ export function NewChat({
|
||||
},
|
||||
onError: error => {
|
||||
logger.error('Failed to create chat', {safeMessage: error})
|
||||
Toast.show(l`An issue occurred starting the chat`, {
|
||||
Toast.show(l`An issue occurred starting the chat, please try again`, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: createGroupChat} = useCreateGroupChat({
|
||||
onSuccess: data => {
|
||||
onNewChat(data.convo.id)
|
||||
ax.metric('groupchat:create', {logContext: 'NewChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
logger.error('Failed to create groupchat', {safeMessage: error})
|
||||
Toast.show(
|
||||
l`An issue occurred creating the group chat, please try again`,
|
||||
{
|
||||
type: 'error',
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
@@ -52,10 +69,12 @@ export function NewChat({
|
||||
)
|
||||
|
||||
const onCreateGroupChat = useCallback(
|
||||
(_dids: string[], _groupName: string) => {
|
||||
control.close()
|
||||
(members: string[], name: string) => {
|
||||
control.close(() => {
|
||||
createGroupChat({members, name})
|
||||
})
|
||||
},
|
||||
[control],
|
||||
[control, createGroupChat],
|
||||
)
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
@@ -74,7 +93,7 @@ export function NewChat({
|
||||
<FAB
|
||||
testID="newChatFAB"
|
||||
onPress={wrappedOnPress}
|
||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
||||
icon={<NewChatIcon size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`New chat`}
|
||||
accessibilityHint=""
|
||||
|
||||
@@ -15,3 +15,7 @@ export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({
|
||||
export const Message_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
|
||||
export const MessagePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,8 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z',
|
||||
})
|
||||
|
||||
export const PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded =
|
||||
createSinglePathSVG({
|
||||
path: 'M10.655 3.718c.55-1.116 2.14-1.116 2.69 0l7.548 15.317c.578 1.172-.515 2.471-1.768 2.103L13 19.336V15a1 1 0 0 0-2 0v4.336l-6.124 1.802c-1.254.369-2.346-.93-1.769-2.103l7.548-15.317Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ITEM_GAP = 8 // tokens.space.sm
|
||||
export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit
|
||||
export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit
|
||||
@@ -0,0 +1,531 @@
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
useAnimatedRef,
|
||||
} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedImages} from '@atproto/api'
|
||||
import {utils} from '@bsky.app/alf'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {
|
||||
ITEM_GAP,
|
||||
MAX_ASPECT_RATIO,
|
||||
MIN_ASPECT_RATIO,
|
||||
} from '#/components/images/Gallery/const'
|
||||
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
|
||||
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
|
||||
import {getAspectRatio} from '#/components/images/Gallery/utils'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export * from './const'
|
||||
export * from './maybeApplyGalleryOffsetStyles'
|
||||
|
||||
interface GalleryProps {
|
||||
images: AppBskyEmbedImages.ViewImage[]
|
||||
onPress?: (
|
||||
index: number,
|
||||
containerRefs: AnimatedRef<any>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => void
|
||||
onPressIn?: (index: number) => void
|
||||
viewContext?: PostEmbedViewContext
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
bleedRef: React.RefObject<View | null>
|
||||
bleedWidth: number
|
||||
}>({
|
||||
bleedRef: {current: null},
|
||||
bleedWidth: 0,
|
||||
})
|
||||
|
||||
export function GalleryBleed({children}: {children: React.ReactNode}) {
|
||||
const ref = useRef<View>(null)
|
||||
const [bleedWidth, setBleedWidth] = useState(0)
|
||||
|
||||
if (!isValidElement(children)) {
|
||||
throw new Error('GalleryBleed children must be a single React element')
|
||||
}
|
||||
|
||||
const node = children as React.ReactElement<any>
|
||||
|
||||
return (
|
||||
<Context.Provider value={{bleedRef: ref, bleedWidth}}>
|
||||
{cloneElement(node, {
|
||||
ref: mergeRefs([ref, node?.props?.ref]),
|
||||
onLayout: (e: {nativeEvent: {layout: {width: number}}}) => {
|
||||
setBleedWidth(e.nativeEvent.layout.width)
|
||||
node.props.onLayout?.(e)
|
||||
},
|
||||
style: [node.props.style, a.overflow_hidden],
|
||||
})}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useGalleryBleed() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export function Gallery({
|
||||
images,
|
||||
onPress,
|
||||
onPressIn,
|
||||
viewContext,
|
||||
}: GalleryProps) {
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {screenReaderEnabled} = useA11y()
|
||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||
const bps = useBreakpoints()
|
||||
const window = useWindowDimensions()
|
||||
const contentHeight = useMemo(() => {
|
||||
if (bps.gtMobile) {
|
||||
return 300
|
||||
} else if (bps.gtPhone) {
|
||||
return 260
|
||||
} else {
|
||||
return 200
|
||||
}
|
||||
}, [bps])
|
||||
const isWithinQuote =
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const hideBadges = isWithinQuote
|
||||
|
||||
/*
|
||||
* Container overflow styles
|
||||
*
|
||||
* Uses measureLayout to get the Gallery's offset relative to the GalleryBleed
|
||||
* ancestor. This is a layout-relative measurement that doesn't depend on
|
||||
* scroll position, so it works correctly for off-screen FlatList items.
|
||||
*/
|
||||
const {bleedRef, bleedWidth} = useGalleryBleed()
|
||||
const contentRef = useRef<View>(null)
|
||||
const [contentDims, setContentDims] = useState<{x: number; width: number}>()
|
||||
const measure = () => {
|
||||
if (contentRef.current && bleedRef.current) {
|
||||
contentRef.current.measureLayout(
|
||||
bleedRef.current,
|
||||
(x, _y, w) => {
|
||||
setContentDims({x, width: w})
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
}
|
||||
const width = bleedWidth || Math.min(600, window.width)
|
||||
const insetLeft = contentDims?.x ?? 0
|
||||
const insetRight =
|
||||
bleedWidth > 0
|
||||
? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0)
|
||||
: 0
|
||||
/* End container overflow styles */
|
||||
|
||||
const flatListRef = useRef<FlatList>(null)
|
||||
const itemWidthsRef = useRef<Map<number, number>>(new Map())
|
||||
const itemRefsRef = useRef<Map<number, View>>(new Map())
|
||||
const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map())
|
||||
const thumbDimsRef = useRef<Map<number, Dimensions>>(new Map())
|
||||
const currentIndexRef = useRef(0)
|
||||
|
||||
const emitSwipeMetric = useMemo(
|
||||
() =>
|
||||
debounce((fromIndex: number, toIndex: number) => {
|
||||
ax.metric('post:gallery:swipe', {
|
||||
fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
|
||||
toImage: toIndex + 1, // convert to 1-based index for easier analysis
|
||||
totalImages: images.length,
|
||||
})
|
||||
}, 200),
|
||||
[ax, images.length],
|
||||
)
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
const prev = currentIndexRef.current
|
||||
if (prev !== index) {
|
||||
currentIndexRef.current = index
|
||||
emitSwipeMetric(prev, index)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollTo = (offset: number) => {
|
||||
flatListRef.current?.scrollToOffset({offset, animated: false})
|
||||
}
|
||||
|
||||
const onSettle = (index: number) => {
|
||||
setCurrentIndex(index)
|
||||
if (!IS_WEB) return
|
||||
// Update tabIndex: only the active image is tab-focusable
|
||||
itemRefsRef.current.forEach((node, i) => {
|
||||
const el = node as unknown as HTMLElement
|
||||
el.tabIndex = i === index ? 0 : -1
|
||||
})
|
||||
const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null
|
||||
el?.focus({preventScroll: true})
|
||||
}
|
||||
|
||||
useKeyboardHandlers({
|
||||
flatListRef,
|
||||
itemWidthsRef,
|
||||
currentIndexRef,
|
||||
scrollTo,
|
||||
onSettle,
|
||||
imageCount: images.length,
|
||||
})
|
||||
|
||||
usePointerHandlers({
|
||||
flatListRef,
|
||||
itemWidthsRef,
|
||||
currentIndexRef,
|
||||
scrollTo,
|
||||
onSettle,
|
||||
imageCount: images.length,
|
||||
})
|
||||
|
||||
if (screenReaderEnabled) {
|
||||
return (
|
||||
<View style={[a.relative, a.gap_sm]}>
|
||||
{images.map((image, index) => (
|
||||
<AutoSizedImage
|
||||
key={image.thumb + index}
|
||||
crop={
|
||||
viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={(containerRef, dims) =>
|
||||
onPress?.(index, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn?.(index)}
|
||||
hideBadge={
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={contentRef}
|
||||
style={[
|
||||
a.w_full,
|
||||
{
|
||||
height: contentHeight,
|
||||
overflow: 'visible',
|
||||
},
|
||||
]}
|
||||
onLayout={measure}>
|
||||
<BlockDrawerGesture>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
role="group"
|
||||
aria-roledescription={l`carousel`}
|
||||
aria-label={l`Image gallery, ${images.length} images`}
|
||||
horizontal
|
||||
pagingEnabled={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate={0.993}
|
||||
directionalLockEnabled
|
||||
nestedScrollEnabled
|
||||
alwaysBounceVertical={false}
|
||||
scrollEventThrottle={16}
|
||||
data={images}
|
||||
keyExtractor={(item, index) => item.thumb + index}
|
||||
renderItem={({item, index}) => {
|
||||
return (
|
||||
<GalleryImage
|
||||
hideBadges={hideBadges}
|
||||
largeAltBadge={largeAltBadge}
|
||||
image={item}
|
||||
contentHeight={contentHeight}
|
||||
index={index}
|
||||
imageCount={images.length}
|
||||
onWidthChange={(i, w) => {
|
||||
itemWidthsRef.current.set(i, w)
|
||||
}}
|
||||
itemRef={node => {
|
||||
if (node) {
|
||||
itemRefsRef.current.set(index, node)
|
||||
} else {
|
||||
itemRefsRef.current.delete(index)
|
||||
}
|
||||
}}
|
||||
onContainerRef={(i, ref) => {
|
||||
containerRefsRef.current.set(i, ref)
|
||||
}}
|
||||
onThumbDims={(i, dims) => {
|
||||
thumbDimsRef.current.set(i, dims)
|
||||
}}
|
||||
onPress={
|
||||
onPress
|
||||
? () => {
|
||||
ax.metric('post:gallery:openLightbox', {
|
||||
fromImage: index + 1, // convert to 1-based index for easier analysis
|
||||
totalImages: images.length,
|
||||
})
|
||||
const refs: AnimatedRef<any>[] = []
|
||||
const dims: (Dimensions | null)[] = []
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
refs.push(containerRefsRef.current.get(i)!)
|
||||
dims.push(thumbDimsRef.current.get(i) ?? null)
|
||||
}
|
||||
onPress(index, refs, dims)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
onScroll={e => {
|
||||
// web handles via onSettle in the web hooks
|
||||
if (IS_WEB) return
|
||||
const offsetX = e.nativeEvent.contentOffset.x
|
||||
let accumulated = 0
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
|
||||
if (offsetX < accumulated + w / 2) {
|
||||
setCurrentIndex(i)
|
||||
break
|
||||
}
|
||||
accumulated += w
|
||||
if (i === images.length - 1) {
|
||||
setCurrentIndex(i)
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={[
|
||||
{
|
||||
height: contentHeight,
|
||||
marginLeft: -insetLeft,
|
||||
width,
|
||||
},
|
||||
]}
|
||||
contentContainerStyle={{
|
||||
gap: ITEM_GAP,
|
||||
paddingLeft: insetLeft,
|
||||
paddingRight: insetRight,
|
||||
}}
|
||||
/>
|
||||
</BlockDrawerGesture>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function computeDims({
|
||||
height,
|
||||
aspectRatio,
|
||||
}: {
|
||||
height: number
|
||||
aspectRatio?: number
|
||||
}) {
|
||||
/*
|
||||
* Old images, or images from other clients can sometimes not have
|
||||
* aspectRatio populated. In these cases, default to square and we'll
|
||||
* resize once the image loads.
|
||||
*
|
||||
* Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO
|
||||
* (landscape) so items stay a reasonable size in the carousel.
|
||||
*/
|
||||
const raw = aspectRatio ?? 1
|
||||
const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO))
|
||||
const width = Math.floor(height * clamped)
|
||||
return {width, height, aspectRatio: clamped, isCropped: raw !== clamped}
|
||||
}
|
||||
|
||||
function GalleryImage({
|
||||
contentHeight: height,
|
||||
image,
|
||||
index,
|
||||
imageCount,
|
||||
onWidthChange,
|
||||
itemRef,
|
||||
hideBadges,
|
||||
largeAltBadge,
|
||||
onContainerRef,
|
||||
onThumbDims,
|
||||
onPress,
|
||||
onPressIn,
|
||||
}: {
|
||||
contentHeight: number
|
||||
image: AppBskyEmbedImages.ViewImage
|
||||
index: number
|
||||
imageCount: number
|
||||
onWidthChange: (index: number, width: number) => void
|
||||
itemRef: (node: View | null) => void
|
||||
hideBadges?: boolean
|
||||
largeAltBadge?: boolean
|
||||
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
|
||||
onThumbDims: (index: number, dims: Dimensions) => void
|
||||
onPress?: () => void
|
||||
onPressIn?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const [focused, setFocused] = useState(false)
|
||||
const containerRef = useAnimatedRef()
|
||||
const [aspectRatio, setAspectRatio] = useState(() =>
|
||||
getAspectRatio(image.aspectRatio),
|
||||
)
|
||||
const {isCropped, ...dims} = computeDims({height, aspectRatio})
|
||||
const hasAlt = !!image.alt
|
||||
|
||||
useEffect(() => {
|
||||
onWidthChange(index, dims.width)
|
||||
}, [index, dims.width, onWidthChange])
|
||||
|
||||
useEffect(() => {
|
||||
onContainerRef(index, containerRef)
|
||||
}, [index, containerRef, onContainerRef])
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
ref={containerRef}
|
||||
collapsable={false}
|
||||
aria-roledescription={l`slide`}
|
||||
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
|
||||
<Pressable
|
||||
ref={itemRef}
|
||||
tabIndex={index === 0 ? 0 : -1}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={image.alt || l`Image ${index + 1}`}
|
||||
accessibilityHint={l`Opens full image`}
|
||||
android_ripple={{
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
web({
|
||||
cursor: 'inherit',
|
||||
outline: 0,
|
||||
border: 0,
|
||||
}),
|
||||
]}>
|
||||
<Image
|
||||
source={{uri: image.thumb}}
|
||||
contentFit="cover"
|
||||
accessible={true}
|
||||
accessibilityLabel={image.alt}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
style={[dims]}
|
||||
onLoad={e => {
|
||||
const ar = getAspectRatio(e.source)
|
||||
if (ar && ar !== aspectRatio) {
|
||||
setAspectRatio(ar)
|
||||
}
|
||||
onThumbDims(index, {
|
||||
width: e.source.width,
|
||||
height: e.source.height,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{(hasAlt || isCropped) && !hideBadges ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
{
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
gap: 3,
|
||||
},
|
||||
largeAltBadge && {
|
||||
gap: 4,
|
||||
},
|
||||
]}>
|
||||
{isCropped && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAltBadge && {
|
||||
padding: 6,
|
||||
},
|
||||
]}>
|
||||
<Fullscreen
|
||||
fill={t.atoms.text_contrast_high.color}
|
||||
width={largeAltBadge ? 18 : 12}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{hasAlt && (
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAltBadge && {
|
||||
padding: 6,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
largeAltBadge ? a.text_xs : {fontSize: 8},
|
||||
]}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<MediaInsetBorder
|
||||
style={
|
||||
focused && {
|
||||
borderWidth: 2,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
type ModerationCause,
|
||||
type ModerationUI,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {unique} from '#/lib/moderation'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export const POST_META_NO_CONTENT_OFFSET = {paddingTop: 10}
|
||||
export const POST_EMBED_NO_CONTENT_OFFSET = {paddingTop: 6}
|
||||
|
||||
export function maybeApplyGalleryOffsetStyles(
|
||||
placement: 'meta' | 'embed',
|
||||
{
|
||||
post,
|
||||
modui,
|
||||
additionalCauses,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
modui: ModerationUI
|
||||
additionalCauses?: ModerationCause[] | AppModerationCause[]
|
||||
},
|
||||
) {
|
||||
// don't ever check gates like this, except this one time
|
||||
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
|
||||
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
* First check if we even have images
|
||||
*/
|
||||
const embed = post.record.embed
|
||||
const isImageEmbed =
|
||||
embed &&
|
||||
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
|
||||
embed,
|
||||
AppBskyEmbedImages.isMain,
|
||||
)
|
||||
const isRecordWithMedia =
|
||||
embed &&
|
||||
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
|
||||
embed,
|
||||
AppBskyEmbedRecordWithMedia.isMain,
|
||||
)
|
||||
let hasImages = false
|
||||
if (isImageEmbed) {
|
||||
// one image, not a gallery
|
||||
if (embed.images.length === 1) return
|
||||
hasImages = true
|
||||
}
|
||||
if (isRecordWithMedia) {
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
|
||||
embed.media,
|
||||
AppBskyEmbedImages.isMain,
|
||||
)
|
||||
) {
|
||||
// one image, not a gallery
|
||||
if (embed.media.images.length === 1) return
|
||||
}
|
||||
hasImages = true
|
||||
}
|
||||
if (!hasImages) return
|
||||
|
||||
/*
|
||||
* Then check if we have any text
|
||||
*/
|
||||
let hasLabels = false
|
||||
if (modui.alert) {
|
||||
hasLabels = modui.alerts.filter(unique).length > 0
|
||||
}
|
||||
if (modui.inform) {
|
||||
hasLabels = hasLabels || modui.informs.filter(unique).length > 0
|
||||
}
|
||||
if (additionalCauses?.length) {
|
||||
hasLabels = true
|
||||
}
|
||||
|
||||
/*
|
||||
* If no text or labels, then we need a lil bump
|
||||
*/
|
||||
const shouldApplyOffset = !post.record.text && !hasLabels
|
||||
|
||||
return shouldApplyOffset
|
||||
? placement === 'meta'
|
||||
? POST_META_NO_CONTENT_OFFSET
|
||||
: POST_EMBED_NO_CONTENT_OFFSET
|
||||
: {}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
function ease(t: number, b: number, c: number, d: number) {
|
||||
return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
|
||||
}
|
||||
|
||||
/**
|
||||
* Tween from `start` to `end` over `duration` ms using an exponential ease-out.
|
||||
* Returns a function that starts the tween. That function returns a stop handle.
|
||||
*
|
||||
* Adapted from tinkerbell.
|
||||
*/
|
||||
export function tween(start: number, end: number, duration: number) {
|
||||
return function run(cb: (v: number) => void, done?: () => void) {
|
||||
let ts: number | undefined
|
||||
let frame: number
|
||||
|
||||
frame = (function tick(last: number) {
|
||||
return requestAnimationFrame(t => {
|
||||
if (!ts) ts = t
|
||||
const te = t - ts
|
||||
const next = Math.round(ease(te, start, end - start, duration))
|
||||
if (
|
||||
(end > start
|
||||
? next < end && last <= end
|
||||
: next > end && last >= end) &&
|
||||
te <= duration
|
||||
) {
|
||||
frame = tick(next)
|
||||
cb(next)
|
||||
} else {
|
||||
cb(end)
|
||||
done?.()
|
||||
}
|
||||
})
|
||||
})(start)
|
||||
|
||||
return function stop() {
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function useKeyboardHandlers(_args: {
|
||||
flatListRef: any
|
||||
itemWidthsRef: any
|
||||
currentIndexRef: any
|
||||
scrollTo: any
|
||||
onSettle: any
|
||||
imageCount: any
|
||||
}) {}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {useEffect} from 'react'
|
||||
import {type FlatList} from 'react-native'
|
||||
|
||||
import {tween} from '#/components/images/Gallery/tween'
|
||||
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
|
||||
|
||||
const SETTLE_DURATION = 700
|
||||
|
||||
export function useKeyboardHandlers({
|
||||
flatListRef,
|
||||
itemWidthsRef,
|
||||
currentIndexRef,
|
||||
scrollTo,
|
||||
onSettle,
|
||||
imageCount,
|
||||
}: {
|
||||
flatListRef: React.RefObject<FlatList | null>
|
||||
itemWidthsRef: React.RefObject<Map<number, number>>
|
||||
currentIndexRef: React.RefObject<number>
|
||||
scrollTo: (offset: number) => void
|
||||
onSettle: (index: number) => void
|
||||
imageCount: number
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (imageCount <= 1) return
|
||||
|
||||
let stopTween: (() => void) | null = null
|
||||
let pendingIndex: number | null = null
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const el =
|
||||
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
|
||||
if (!el || !el.contains(document.activeElement)) return
|
||||
|
||||
const current = pendingIndex ?? currentIndexRef.current
|
||||
let targetIndex: number | undefined
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
if (current < imageCount - 1) {
|
||||
targetIndex = current + 1
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
if (current > 0) {
|
||||
targetIndex = current - 1
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex != null) {
|
||||
e.preventDefault()
|
||||
|
||||
if (stopTween) {
|
||||
stopTween()
|
||||
stopTween = null
|
||||
}
|
||||
|
||||
pendingIndex = targetIndex
|
||||
const from = el.scrollLeft
|
||||
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
|
||||
const idx = targetIndex
|
||||
|
||||
stopTween = tween(
|
||||
from,
|
||||
to,
|
||||
SETTLE_DURATION,
|
||||
)(
|
||||
v => {
|
||||
scrollTo(v)
|
||||
},
|
||||
() => {
|
||||
stopTween = null
|
||||
pendingIndex = null
|
||||
onSettle(idx)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
if (stopTween) stopTween()
|
||||
}
|
||||
}, [
|
||||
flatListRef,
|
||||
itemWidthsRef,
|
||||
currentIndexRef,
|
||||
scrollTo,
|
||||
onSettle,
|
||||
imageCount,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function usePointerHandlers(_args: {
|
||||
flatListRef: any
|
||||
itemWidthsRef: any
|
||||
currentIndexRef: any
|
||||
scrollTo: any
|
||||
onSettle: any
|
||||
imageCount: any
|
||||
}) {}
|
||||
@@ -0,0 +1,270 @@
|
||||
import {useEffect} from 'react'
|
||||
import {type FlatList} from 'react-native'
|
||||
|
||||
import {ITEM_GAP} from '#/components/images/Gallery/const'
|
||||
import {tween} from '#/components/images/Gallery/tween'
|
||||
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
|
||||
|
||||
const DRAG_THRESHOLD = 3
|
||||
const FLICK_DECAY = 0.85
|
||||
const FLICK_MIN_VELOCITY = 0.1
|
||||
const ADVANCE_THRESHOLD = 0.15
|
||||
const FRAME_MS = 1000 / 60
|
||||
const SETTLE_DURATION = 700
|
||||
const OVERSCROLL_RESISTANCE = 0.4
|
||||
const BOUNCE_DURATION = 700
|
||||
|
||||
function whichByDistance(
|
||||
itemWidths: Map<number, number>,
|
||||
currentIndex: number,
|
||||
distance: number,
|
||||
direction: -1 | 1,
|
||||
imageCount: number,
|
||||
): number {
|
||||
let remaining = distance
|
||||
let i = currentIndex
|
||||
|
||||
while (remaining > 0 && i >= 0 && i < imageCount) {
|
||||
const w = (itemWidths.get(i) ?? 0) + ITEM_GAP
|
||||
if (remaining > w) {
|
||||
remaining -= w
|
||||
i -= direction
|
||||
} else if (remaining > w * ADVANCE_THRESHOLD) {
|
||||
i -= direction
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(i, imageCount - 1))
|
||||
}
|
||||
|
||||
export function usePointerHandlers({
|
||||
flatListRef,
|
||||
itemWidthsRef,
|
||||
currentIndexRef,
|
||||
scrollTo,
|
||||
onSettle,
|
||||
imageCount,
|
||||
}: {
|
||||
flatListRef: React.RefObject<FlatList | null>
|
||||
itemWidthsRef: React.RefObject<Map<number, number>>
|
||||
currentIndexRef: React.RefObject<number>
|
||||
scrollTo: (offset: number) => void
|
||||
onSettle: (index: number) => void
|
||||
imageCount: number
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (imageCount <= 1) return
|
||||
|
||||
const el =
|
||||
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
|
||||
if (!el) return
|
||||
|
||||
let isDragging = false
|
||||
let isMouseDown = false
|
||||
let startX = 0
|
||||
let dragScrollLeft = 0
|
||||
let delta = 0
|
||||
let prevDelta = 0
|
||||
let velo = 0
|
||||
let t = 0
|
||||
let stopTween: (() => void) | null = null
|
||||
let localIndex = currentIndexRef.current
|
||||
let overscrollX = 0
|
||||
|
||||
el.style.cursor = 'grab'
|
||||
|
||||
const clearOverscroll = () => {
|
||||
overscrollX = 0
|
||||
el.style.transform = ''
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault() // prevent native image drag
|
||||
|
||||
// Cancel any in-progress tween
|
||||
if (stopTween) {
|
||||
stopTween()
|
||||
stopTween = null
|
||||
}
|
||||
clearOverscroll()
|
||||
|
||||
isMouseDown = true
|
||||
isDragging = false
|
||||
localIndex = currentIndexRef.current
|
||||
startX = e.pageX
|
||||
dragScrollLeft = el.scrollLeft
|
||||
delta = 0
|
||||
prevDelta = 0
|
||||
velo = 0
|
||||
t = e.timeStamp
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isMouseDown) return
|
||||
|
||||
const x = e.pageX - startX
|
||||
|
||||
// Require minimum movement before starting drag
|
||||
if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return
|
||||
|
||||
if (!isDragging) {
|
||||
isDragging = true
|
||||
el.style.cursor = 'grabbing'
|
||||
el.style.userSelect = 'none'
|
||||
|
||||
// Blur focused element within the gallery
|
||||
if (el.contains(document.activeElement)) {
|
||||
;(document.activeElement as HTMLElement)?.blur?.()
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
// Track velocity
|
||||
const elapsed = e.timeStamp - t || 1
|
||||
prevDelta = delta
|
||||
delta = x
|
||||
velo = (delta - prevDelta) / (elapsed * FRAME_MS)
|
||||
t = e.timeStamp
|
||||
|
||||
const desiredScroll = dragScrollLeft - delta
|
||||
const maxScroll = el.scrollWidth - el.clientWidth
|
||||
|
||||
if (desiredScroll < 0) {
|
||||
// Overscroll at start — rubber band
|
||||
scrollTo(0)
|
||||
overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
|
||||
el.style.transform = `translateX(${-overscrollX}px)`
|
||||
} else if (desiredScroll > maxScroll) {
|
||||
// Overscroll at end — rubber band
|
||||
scrollTo(maxScroll)
|
||||
overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE
|
||||
el.style.transform = `translateX(${-overscrollX}px)`
|
||||
} else {
|
||||
// Normal scroll range
|
||||
scrollTo(desiredScroll)
|
||||
if (overscrollX !== 0) clearOverscroll()
|
||||
}
|
||||
|
||||
// Update local index from scroll position (only in normal range)
|
||||
if (overscrollX === 0) {
|
||||
const offsetX = desiredScroll
|
||||
let accumulated = 0
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
|
||||
if (offsetX < accumulated + w / 2) {
|
||||
localIndex = i
|
||||
break
|
||||
}
|
||||
accumulated += w
|
||||
if (i === imageCount - 1) localIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isMouseDown) return
|
||||
|
||||
const wasDragging = isDragging
|
||||
isMouseDown = false
|
||||
isDragging = false
|
||||
|
||||
el.style.cursor = 'grab'
|
||||
el.style.userSelect = ''
|
||||
|
||||
if (wasDragging) {
|
||||
// Suppress the click that follows mouseup after a drag
|
||||
el.addEventListener('click', e => e.stopPropagation(), {
|
||||
once: true,
|
||||
capture: true,
|
||||
})
|
||||
|
||||
if (overscrollX !== 0) {
|
||||
// Bounce back from overscroll
|
||||
const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
|
||||
const fromOverscroll = overscrollX
|
||||
|
||||
stopTween = tween(
|
||||
fromOverscroll,
|
||||
0,
|
||||
BOUNCE_DURATION,
|
||||
)(
|
||||
v => {
|
||||
el.style.transform = `translateX(${-v}px)`
|
||||
},
|
||||
() => {
|
||||
stopTween = null
|
||||
clearOverscroll()
|
||||
onSettle(targetIndex)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Normal flick settle
|
||||
let v = Math.abs(velo)
|
||||
let restingDistance = 0
|
||||
while (v > FLICK_MIN_VELOCITY) {
|
||||
v *= FLICK_DECAY
|
||||
restingDistance += v
|
||||
}
|
||||
|
||||
const direction: -1 | 1 = delta < 0 ? -1 : 1
|
||||
const totalDistance = Math.abs(delta) + restingDistance
|
||||
|
||||
const targetIndex = whichByDistance(
|
||||
itemWidthsRef.current,
|
||||
localIndex,
|
||||
totalDistance,
|
||||
direction,
|
||||
imageCount,
|
||||
)
|
||||
|
||||
const from = el.scrollLeft
|
||||
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
|
||||
|
||||
if (from === to) {
|
||||
onSettle(targetIndex)
|
||||
return
|
||||
}
|
||||
|
||||
stopTween = tween(
|
||||
from,
|
||||
to,
|
||||
SETTLE_DURATION,
|
||||
)(
|
||||
v => {
|
||||
scrollTo(v)
|
||||
},
|
||||
() => {
|
||||
stopTween = null
|
||||
onSettle(targetIndex)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('mousedown', onMouseDown)
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
if (stopTween) stopTween()
|
||||
clearOverscroll()
|
||||
el.style.cursor = ''
|
||||
el.style.userSelect = ''
|
||||
}
|
||||
}, [
|
||||
flatListRef,
|
||||
itemWidthsRef,
|
||||
currentIndexRef,
|
||||
scrollTo,
|
||||
onSettle,
|
||||
imageCount,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {ITEM_GAP} from '#/components/images/Gallery/const'
|
||||
|
||||
export function getOffsetForIndex(
|
||||
itemWidths: Map<number, number>,
|
||||
index: number,
|
||||
): number {
|
||||
let offset = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
offset += (itemWidths.get(i) ?? 0) + ITEM_GAP
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
export function getAspectRatio({
|
||||
width,
|
||||
height,
|
||||
}: {width?: number; height?: number} = {}) {
|
||||
if (width && width > 0 && height && height > 0) {
|
||||
return width / height
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
|
||||
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {GalleryItem} from './Gallery'
|
||||
import {GalleryItem} from './ImageLayoutGridItem'
|
||||
|
||||
interface ImageLayoutGridProps {
|
||||
images: AppBskyEmbedImages.ViewImage[]
|
||||
|
||||
@@ -10,8 +10,8 @@ import {useVerificationCreateMutation} from '#/state/queries/verification/useVer
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from '#/state/queries/preferences'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
import * as env from '#/env'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {
|
||||
type LiveEventFeed,
|
||||
type LiveEventFeedMetricContext,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {useCallback} from 'react'
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyEmbedExternal,
|
||||
moderateStatus,
|
||||
} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -19,8 +21,10 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {Globe_Stroke2_Corner0_Rounded} from '#/components/icons/Globe'
|
||||
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '#/components/icons/SquareArrowTopRight'
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -64,7 +68,7 @@ function DialogInner({
|
||||
navigation: NavigationProp
|
||||
status: AppBskyActorDefs.StatusView
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
const onPressOpenProfile = useCallback(() => {
|
||||
@@ -77,7 +81,7 @@ function DialogInner({
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`${sanitizeHandle(profile.handle)} is live`)}
|
||||
label={l`${sanitizeHandle(profile.handle)} is live`}
|
||||
contentContainerStyle={[a.pt_0, a.px_0]}
|
||||
style={[web({maxWidth: 420}), a.overflow_hidden]}>
|
||||
<LiveStatus
|
||||
@@ -105,45 +109,56 @@ export function LiveStatus({
|
||||
onPressOpenProfile: () => void
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const queryClient = useQueryClient()
|
||||
const openLink = useOpenLink()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const reportDialogControl = useGlobalReportDialogControl()
|
||||
const dialogContext = Dialog.useDialogContext()
|
||||
const moderation = useMemo(() => {
|
||||
if (!moderationOpts) return undefined
|
||||
return moderateStatus(profile, moderationOpts)
|
||||
}, [profile, moderationOpts])
|
||||
|
||||
return (
|
||||
<>
|
||||
{embed.external.thumb && (
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
a.w_full,
|
||||
a.aspect_card,
|
||||
android([
|
||||
a.overflow_hidden,
|
||||
{
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
},
|
||||
]),
|
||||
]}>
|
||||
<Image
|
||||
source={embed.external.thumb}
|
||||
contentFit="cover"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<LiveIndicator
|
||||
size="large"
|
||||
style={[
|
||||
a.absolute,
|
||||
{top: tokens.space.lg, left: tokens.space.lg},
|
||||
a.align_start,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Hider.Outer modui={moderation?.ui('contentMedia')}>
|
||||
<Hider.Mask>
|
||||
<ModeratedImage />
|
||||
</Hider.Mask>
|
||||
<Hider.Content>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
a.w_full,
|
||||
a.aspect_card,
|
||||
android([
|
||||
a.overflow_hidden,
|
||||
{
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
},
|
||||
]),
|
||||
]}>
|
||||
<Image
|
||||
source={embed.external.thumb}
|
||||
contentFit="cover"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<LiveIndicator
|
||||
size="large"
|
||||
style={[
|
||||
a.absolute,
|
||||
{top: tokens.space.lg, left: tokens.space.lg},
|
||||
a.align_start,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</Hider.Content>
|
||||
</Hider.Outer>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
@@ -171,7 +186,7 @@ export function LiveStatus({
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Watch now`)}
|
||||
label={l`Watch now`}
|
||||
size={platform({native: 'large', web: 'small'})}
|
||||
color="primary"
|
||||
variant="solid"
|
||||
@@ -200,7 +215,7 @@ export function LiveStatus({
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Open profile`)}
|
||||
label={l`Open profile`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
@@ -231,7 +246,7 @@ export function LiveStatus({
|
||||
</View>
|
||||
{status && (
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Report this livestream`)}
|
||||
label={l`Report this livestream`}
|
||||
{...createStaticClick(() => {
|
||||
function open() {
|
||||
reportDialogControl.open({
|
||||
@@ -256,3 +271,57 @@ export function LiveStatus({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeratedImage() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const hider = Hider.useHider()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
a.py_xl,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={[a.align_center, a.gap_sm, {maxWidth: 200}]}>
|
||||
<ImageIcon size="lg" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text
|
||||
style={[
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{hider.meta.allowOverride ? (
|
||||
<Trans comment="Image has been moderated and user has the option of showing it temporarily">
|
||||
Image is hidden due to your moderation settings.
|
||||
</Trans>
|
||||
) : (
|
||||
/*
|
||||
* In practice, if `allowOverride` is false, we won't even allow this
|
||||
* dialog to open. That is handled in
|
||||
* `#/features/liveNow/index.tsx`. But for clarity, I've included
|
||||
* this here.
|
||||
*/
|
||||
<Trans comment="Image has been moderated and is not visible to the user">
|
||||
Image is unavailable.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{hider.meta.allowOverride && (
|
||||
<SimpleInlineLinkText
|
||||
label={l`Show anyway`}
|
||||
{...createStaticClick(() => {
|
||||
hider.setIsContentVisible(true)
|
||||
})}>
|
||||
<Trans>Show anyway</Trans>
|
||||
</SimpleInlineLinkText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppBskyEmbedExternal,
|
||||
AtUri,
|
||||
ComAtprotoRepoPutRecord,
|
||||
moderateStatus,
|
||||
} from '@atproto/api'
|
||||
import {retry} from '@atproto/common-web'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
updateProfileShadow,
|
||||
useMaybeProfileShadow,
|
||||
} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useTickEveryMinute} from '#/state/shell'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
@@ -37,6 +39,13 @@ export const DEFAULT_ALLOWED_DOMAINS = [
|
||||
'bluecast.app',
|
||||
]
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
status: '',
|
||||
isDisabled: false,
|
||||
isActive: false,
|
||||
record: {},
|
||||
} satisfies AppBskyActorDefs.StatusView
|
||||
|
||||
export type LiveNowConfig = {
|
||||
canGoLive: boolean
|
||||
currentAccountAllowedHosts: Set<string>
|
||||
@@ -87,10 +96,24 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
|
||||
const shadowed = useMaybeProfileShadow(actor)
|
||||
const tick = useTickEveryMinute()
|
||||
const config = useLiveNowConfig()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const moderation = useMemo(() => {
|
||||
if (!actor || !('status' in actor && actor.status)) return undefined
|
||||
if (!moderationOpts) return undefined
|
||||
return moderateStatus(actor, moderationOpts)
|
||||
}, [actor, moderationOpts])
|
||||
|
||||
return useMemo(() => {
|
||||
void tick // revalidate every minute
|
||||
|
||||
/*
|
||||
* Do not even allow Live Now to show if filtered for `contentList`.
|
||||
*/
|
||||
if (moderation && moderation.ui('contentList').filter) {
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
|
||||
if (shadowed && 'status' in shadowed && shadowed.status) {
|
||||
const isValid = isStatusValidForViewers(shadowed.status, config)
|
||||
const isDisabled = shadowed.status.isDisabled || false
|
||||
@@ -118,14 +141,9 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
|
||||
record: shadowed.status.record,
|
||||
} satisfies AppBskyActorDefs.StatusView
|
||||
} else {
|
||||
return {
|
||||
status: '',
|
||||
isDisabled: false,
|
||||
isActive: false,
|
||||
record: {},
|
||||
} satisfies AppBskyActorDefs.StatusView
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
}, [shadowed, config, tick])
|
||||
}, [shadowed, config, tick, moderation])
|
||||
}
|
||||
|
||||
export function isStatusStillActive(timeStr: string | undefined) {
|
||||
|
||||
@@ -53,6 +53,7 @@ interface PostOpts {
|
||||
|
||||
type FeatureFlags = {
|
||||
highResolutionImages?: boolean
|
||||
increasedBlobSizeLimit?: boolean
|
||||
}
|
||||
|
||||
export async function post(
|
||||
@@ -320,6 +321,7 @@ async function resolveMedia(
|
||||
logger.debug(`Compressing image #${i}`)
|
||||
const {path, width, height, mime} = await compressImage(image, {
|
||||
highResolution: featureFlags?.highResolutionImages,
|
||||
increasedBlobSizeLimit: featureFlags?.increasedBlobSizeLimit,
|
||||
})
|
||||
logger.debug(`Uploading image #${i}`)
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as React from 'react'
|
||||
import {useRef} from 'react'
|
||||
import {Animated} from 'react-native'
|
||||
|
||||
export function useAnimatedValue(initialValue: number) {
|
||||
const lazyRef = React.useRef<Animated.Value>(undefined)
|
||||
const lazyRef = useRef<Animated.Value>(undefined)
|
||||
|
||||
if (lazyRef.current === undefined) {
|
||||
lazyRef.current = new Animated.Value(initialValue)
|
||||
}
|
||||
|
||||
return lazyRef.current as Animated.Value
|
||||
return lazyRef.current
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user