Merge branch 'dx-atproto-modules' into hailey/oauth

# Conflicts:
#	metro.config.js
#	src/screens/Login/LoginForm.tsx
This commit is contained in:
Hailey
2024-04-29 07:57:29 -07:00
387 changed files with 31152 additions and 22754 deletions
@@ -0,0 +1,57 @@
name: build-and-push-embedr-aws
on:
push:
branches:
- main
- bnewbold/embedr
- bnewbold/embedr-rebase
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
IMAGE_NAME: embed
jobs:
embedr-container-aws:
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
file: ./Dockerfile.embedr
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2
View File
@@ -72,3 +72,5 @@ CMD ["/usr/bin/bskyweb"]
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
LABEL org.opencontainers.image.description="bsky.app Web App"
LABEL org.opencontainers.image.licenses=MIT
# NOOP
+78
View File
@@ -0,0 +1,78 @@
FROM golang:1.21-bullseye AS build-env
WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
# Node
ENV NODE_VERSION=18
ENV NVM_DIR=/usr/share/nvm
# Go
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
COPY . .
#
# Generate the JavaScript webpack. NOTE: this will change
#
RUN mkdir --parents $NVM_DIR && \
wget \
--output-document=/tmp/nvm-install.sh \
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \
bash /tmp/nvm-install.sh
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global yarn && \
yarn && \
cd bskyembed && yarn install --frozen-lockfile && cd .. && \
yarn intl:build && \
yarn build-embed
# DEBUG
RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bskyembed/dist
# hack around issue with empty directory and go:embed
RUN touch bskyweb/static/js/empty.txt
#
# Generate the embedr Go binary.
#
RUN cd bskyweb/ && \
go mod download && \
go mod verify
RUN cd bskyweb/ && \
go build \
-v \
-trimpath \
-tags timetzdata \
-o /embedr \
./cmd/embedr
FROM debian:bullseye-slim
ENV GODEBUG=netdns=go
ENV TZ=Etc/UTC
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install --yes \
dumb-init \
ca-certificates
ENTRYPOINT ["dumb-init", "--"]
WORKDIR /embedr
COPY --from=build-env /embedr /usr/bin/embedr
CMD ["/usr/bin/embedr"]
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
LABEL org.opencontainers.image.description="embed.bsky.app Web App"
LABEL org.opencontainers.image.licenses=MIT
+6
View File
@@ -13,6 +13,11 @@ build-web: ## Compile web bundle, copy to bskyweb directory
yarn intl:build
yarn build-web
.PHONY: build-web-embed
build-web-embed: ## Compile web embed bundle, copy to bskyweb/embedr* directories
yarn intl:build
yarn build-embed
.PHONY: test
test: ## Run all tests
NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test
@@ -28,6 +33,7 @@ lint: ## Run style checks and verify syntax
.PHONY: deps
deps: ## Installs dependent libs using 'yarn install'
yarn install --frozen-lockfile
cd bskyembed && yarn install --frozen-lockfile
.PHONY: nvm-setup
nvm-setup: ## Use NVM to install and activate node+yarn
+3 -5
View File
@@ -1,8 +1,9 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
import {createServer, loginAsAlice, openApp, sleep} from '../util'
describe('Composer', () => {
beforeAll(async () => {
@@ -41,7 +42,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'Post with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
@@ -72,7 +72,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'Reply with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
@@ -104,7 +103,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'QP with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
+2 -2
View File
@@ -1,5 +1,5 @@
import {resolveConfig} from 'detox/internals'
import {execSync} from 'child_process'
import {resolveConfig} from 'detox/internals'
import http from 'http'
const platform = device.getPlatform()
@@ -52,7 +52,7 @@ export async function login(
if (await isVisible('chooseAccountForm')) {
await element(by.id('chooseNewAccountBtn')).tap()
}
await element(by.id('loginSelectServiceButton')).tap()
await element(by.id('selectServiceButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('2- opened service selector')
}
+27 -2
View File
@@ -1,9 +1,10 @@
import ImageResizer from '@bam.tech/react-native-image-resizer'
import RNFetchBlob from 'rn-fetch-blob'
import {
downloadAndResize,
DownloadAndResizeOpts,
} from '../../src/lib/media/manip'
import ImageResizer from '@bam.tech/react-native-image-resizer'
import RNFetchBlob from 'rn-fetch-blob'
describe('downloadAndResize', () => {
const errorSpy = jest.spyOn(global.console, 'error')
@@ -30,6 +31,7 @@ describe('downloadAndResize', () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image.jpg'),
info: jest.fn().mockReturnValue({status: 200}),
flush: jest.fn(),
})
@@ -84,6 +86,7 @@ describe('downloadAndResize', () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image'),
info: jest.fn().mockReturnValue({status: 200}),
flush: jest.fn(),
})
@@ -118,4 +121,26 @@ describe('downloadAndResize', () => {
{mode: 'cover'},
)
})
it('should return undefined for non-200 response', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image'),
info: jest.fn().mockReturnValue({status: 400}),
flush: jest.fn(),
})
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image',
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
const result = await downloadAndResize(opts)
expect(errorSpy).not.toHaveBeenCalled()
expect(result).toBeUndefined()
})
})
+57 -37
View File
@@ -1,17 +1,18 @@
import {RichText} from '@atproto/api'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen, pluralize} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {ago} from '../../src/lib/strings/time'
import {
makeRecordUri,
toNiceDomain,
toShortUrl,
toShareUrl,
toShortUrl,
} from '../../src/lib/strings/url-helpers'
import {pluralize, enforceLen} from '../../src/lib/strings/helpers'
import {ago} from '../../src/lib/strings/time'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
import {cleanError} from '../../src/lib/strings/errors'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
describe('detectLinkables', () => {
const inputs = [
@@ -395,6 +396,7 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId',
'https://m.youtube.com/watch?v=videoId',
'https://music.youtube.com/watch?v=videoId',
'https://youtube.com/shorts/',
'https://youtube.com/',
@@ -434,6 +436,8 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://giphy.com/gif/some-random-gif-name-gifId',
'https://giphy.com/gifs/',
'https://giphy.com/gifs/39248209509382934029?hh=100&ww=100',
'https://media.giphy.com/media/gifId/giphy.webp',
'https://media0.giphy.com/media/gifId/giphy.webp',
'https://media1.giphy.com/media/gifId/giphy.gif',
@@ -456,6 +460,12 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://tenor.com/view',
'https://tenor.com/view/gifId.gif',
'https://tenor.com/intl/view/gifId.gif',
'https://media.tenor.com/someID_AAAAC/someName.gif?hh=100&ww=100',
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
'https://media.tenor.com/someID',
'https://media.tenor.com',
]
const outputs = [
@@ -495,6 +505,11 @@ describe('parseEmbedPlayerFromUrl', () => {
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
undefined,
undefined,
@@ -621,18 +636,17 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
undefined,
undefined,
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
metaUri: 'https://giphy.com/gifs/39248209509382934029',
playerUri: 'https://i.giphy.com/media/39248209509382934029/200.webp',
},
{
type: 'giphy_gif',
@@ -640,7 +654,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -648,7 +662,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -656,7 +670,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -664,7 +678,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -672,7 +686,15 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
undefined,
undefined,
@@ -684,7 +706,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
@@ -693,7 +715,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -701,7 +723,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -709,7 +731,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -717,32 +739,30 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
undefined,
undefined,
undefined,
undefined,
undefined,
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/view/gifId.gif',
playerUri: 'https://t.gifs.bsky.app/someID_AAAAM/someName.gif',
dimensions: {
width: 100,
height: 100,
},
},
undefined,
undefined,
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/view/gifId.gif',
},
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/intl/view/gifId.gif',
},
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
+4 -3
View File
@@ -1,10 +1,10 @@
import {it, describe, expect} from '@jest/globals'
import {describe, expect, it} from '@jest/globals'
import {
linkRequiresWarning,
isPossiblyAUrl,
splitApexDomain,
isTrustedUrl,
linkRequiresWarning,
splitApexDomain,
} from '../../../src/lib/strings/url-helpers'
describe('linkRequiresWarning', () => {
@@ -170,6 +170,7 @@ describe('isTrustedUrl', () => {
['https://google.com', false],
['https://docs.google.com', false],
['https://google.com/#', false],
['https://blueskywebxzendesk.com', false],
]
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
+20 -12
View File
@@ -35,15 +35,20 @@ module.exports = function (config) {
*/
const PLATFORM = process.env.EAS_BUILD_PLATFORM
const DIST_BUILD_NUMBER =
PLATFORM === 'android'
? process.env.BSKY_ANDROID_VERSION_CODE
: process.env.BSKY_IOS_BUILD_NUMBER
const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
const UPDATES_CHANNEL = IS_TESTFLIGHT
? 'testflight'
: IS_PRODUCTION
? 'production'
: undefined
const UPDATES_ENABLED = !!UPDATES_CHANNEL
const SENTRY_DIST = `${PLATFORM}.${VERSION}.${IS_TESTFLIGHT ? 'tf' : ''}${
IS_DEV ? 'dev' : ''
}`
return {
expo: {
@@ -89,6 +94,11 @@ module.exports = function (config) {
barStyle: 'light-content',
backgroundColor: '#00000000',
},
// Dark nav bar in light mode is better than light nav bar in dark mode
androidNavigationBar: {
barStyle: 'light-content',
backgroundColor: DARK_SPLASH_CONFIG_ANDROID.backgroundColor,
},
android: {
icon: './assets/icon.png',
adaptiveIcon: {
@@ -126,14 +136,12 @@ module.exports = function (config) {
},
updates: {
url: 'https://updates.bsky.app/manifest',
// TODO Eventually we want to enable this for all environments, but for now it will only be used for
// TestFlight builds
enabled: IS_TESTFLIGHT,
enabled: UPDATES_ENABLED,
fallbackToCacheTimeout: 30000,
codeSigningCertificate: IS_TESTFLIGHT
codeSigningCertificate: UPDATES_ENABLED
? './code-signing/certificate.pem'
: undefined,
codeSigningMetadata: IS_TESTFLIGHT
codeSigningMetadata: UPDATES_ENABLED
? {
keyid: 'main',
alg: 'rsa-v1_5-sha256',
@@ -208,7 +216,7 @@ module.exports = function (config) {
organization: 'blueskyweb',
project: 'react-native',
release: VERSION,
dist: `${PLATFORM}.${VERSION}.${DIST_BUILD_NUMBER}`,
dist: SENTRY_DIST,
},
},
],
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 11a7 7 0 0 1 14 0c0 2.625-1.547 5.138-3.354 7.066a17.23 17.23 0 0 1-2.55 2.242 8.246 8.246 0 0 1-.924.577 2.904 2.904 0 0 1-.172.083 2.904 2.904 0 0 1-.172-.083 8.246 8.246 0 0 1-.923-.577 17.227 17.227 0 0 1-2.55-2.242C6.547 16.138 5 13.625 5 11Zm6.882 10.012Zm.232-.001h-.003a.047.047 0 0 0 .007.001l-.004-.001ZM12 2a9 9 0 0 0-9 9c0 3.375 1.953 6.362 3.895 8.434a19.216 19.216 0 0 0 2.856 2.508c.425.3.82.545 1.159.72.168.087.337.164.498.222.14.05.356.116.592.116s.451-.066.592-.116c.16-.058.33-.135.498-.222.339-.175.734-.42 1.159-.72.85-.6 1.87-1.457 2.856-2.508C19.047 17.362 21 14.375 21 11a9 9 0 0 0-9-9ZM7.38 9.927c2.774-.094 3.459 1.31 3.591 3.19a.89.89 0 0 1-.855.956c-2.774.094-3.458-1.31-3.59-3.19a.89.89 0 0 1 .854-.956Zm9.236 0c-2.774-.094-3.458 1.31-3.59 3.19a.89.89 0 0 0 .854.956c2.774.094 3.459-1.31 3.591-3.19a.89.89 0 0 0-.855-.956Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 997 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.148 2.034a1 1 0 0 1 1.35-.419c1.054.556 1.873 1.266 2.46 2.174.369.57.63 1.197.807 1.873 1.143-.349 2.194-.46 3.15-.342a5.122 5.122 0 0 1 3.134 1.556c1.564 1.63 2.086 4.183 1.922 6.58-.166 2.414-1.043 4.938-2.607 6.619-.792.851-1.78 1.505-2.95 1.782-1.063.251-2.213.176-3.415-.262-1.202.438-2.351.513-3.414.262-1.17-.277-2.158-.93-2.95-1.782-1.563-1.682-2.44-4.205-2.606-6.62-.164-2.396.358-4.949 1.921-6.579A5.121 5.121 0 0 1 8.085 5.32c.777-.095 1.617-.04 2.518.172a4.011 4.011 0 0 0-.325-.618c-.372-.576-.912-1.068-1.712-1.49a1 1 0 0 1-.418-1.35Zm.897 17.877c.71.167 1.557.117 2.562-.31a1 1 0 0 1 .784 0c1.005.428 1.853.477 2.563.31.715-.17 1.37-.579 1.946-1.198 1.171-1.26 1.932-3.303 2.076-5.394.144-2.109-.353-3.998-1.37-5.059a3.124 3.124 0 0 0-1.936-.955c-.83-.102-1.912.043-3.282.621a1 1 0 0 1-.778 0c-1.37-.578-2.45-.723-3.281-.62-.815.1-1.445.443-1.935.954-1.017 1.06-1.514 2.95-1.37 5.059.144 2.09.905 4.135 2.076 5.394.575.62 1.23 1.029 1.945 1.198Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 281 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6.17 5.004c-.553-.029-.814.107-.937.23-.122.122-.258.383-.23.936.03.552.222 1.28.611 2.15.282.628.655 1.304 1.112 2.005a28.26 28.26 0 0 1 1.72-1.88 28.258 28.258 0 0 1 1.88-1.719 14.886 14.886 0 0 0-2.007-1.112c-.868-.39-1.597-.581-2.15-.61Zm5.83.44c-.985-.688-1.953-1.247-2.863-1.655-.998-.447-1.978-.736-2.863-.782-.885-.047-1.791.148-2.455.812-.664.664-.859 1.57-.812 2.455.046.885.335 1.865.782 2.863.408.91.967 1.878 1.655 2.863-.688.985-1.247 1.953-1.655 2.863-.447.998-.736 1.978-.782 2.863-.047.885.148 1.791.812 2.455.664.663 1.57.859 2.455.812.885-.046 1.865-.335 2.863-.782.91-.408 1.878-.967 2.863-1.655.985.688 1.952 1.247 2.863 1.655.998.447 1.978.736 2.863.782.885.047 1.791-.148 2.455-.812.663-.664.859-1.57.812-2.455-.046-.885-.335-1.865-.782-2.863-.408-.91-.967-1.878-1.655-2.863.688-.985 1.247-1.952 1.655-2.863.447-.998.736-1.978.782-2.863.047-.885-.148-1.791-.812-2.455-.664-.664-1.57-.859-2.455-.812-.885.046-1.865.335-2.863.782-.91.408-1.878.967-2.863 1.655Zm0 2.497A25.9 25.9 0 0 0 9.86 9.86 25.899 25.899 0 0 0 7.94 12c.569.711 1.211 1.431 1.92 2.14.709.709 1.429 1.351 2.14 1.92a25.925 25.925 0 0 0 2.14-1.92A25.925 25.925 0 0 0 16.06 12a25.921 25.921 0 0 0-1.92-2.14A25.904 25.904 0 0 0 12 7.94Zm5.274 2.384a28.232 28.232 0 0 0-1.72-1.88 28.27 28.27 0 0 0-1.88-1.719 14.89 14.89 0 0 1 2.007-1.112c.868-.39 1.597-.581 2.15-.61.552-.029.813.107.936.23.123.122.258.383.23.936-.03.552-.222 1.28-.611 2.15a14.883 14.883 0 0 1-1.112 2.005Zm0 3.35a28.24 28.24 0 0 1-1.72 1.88 28.24 28.24 0 0 1-1.88 1.719c.702.457 1.378.83 2.007 1.112.868.39 1.597.581 2.15.61.552.03.813-.106.936-.23.123-.122.258-.383.23-.935-.03-.553-.222-1.282-.611-2.15a14.888 14.888 0 0 0-1.112-2.006Zm-6.949 3.599a28.23 28.23 0 0 1-1.88-1.72 28.27 28.27 0 0 1-1.719-1.88 14.89 14.89 0 0 0-1.112 2.007c-.39.868-.581 1.597-.61 2.15-.029.552.107.813.23.936.122.123.383.258.936.23.552-.03 1.28-.222 2.15-.611a14.884 14.884 0 0 0 2.005-1.112Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.002 6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-14Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 386 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M13.832 1.014a1 1 0 0 1 1.154.818L14 2l.986-.168v.003l.001.005.002.014.007.045.021.156c.017.13.035.312.048.527.025.42.028 1.01-.082 1.6-.127.69-.447 1.31-.701 1.726a7.107 7.107 0 0 1-.498.712l-.01.014-.004.005-.002.001v.002L13 6l.767.642a1 1 0 0 1-1.535-1.282l.002-.003.017-.02a5.13 5.13 0 0 0 .324-.47c.198-.325.378-.705.442-1.049.068-.37.071-.78.051-1.12a6.268 6.268 0 0 0-.05-.504l-.004-.025m.818-1.155a1 1 0 0 0-.818 1.155Zm5.257 1.545a1 1 0 0 1 .602 1.28l-.45 1.25a1 1 0 0 1-1.882-.678l.45-1.25a1 1 0 0 1 1.28-.602ZM6.524 7.136a2 2 0 0 1 3.294-.732l7.778 7.778a2 2 0 0 1-.732 3.294L4.653 21.91c-1.596.579-3.142-.967-2.563-2.563L6.524 7.136Zm9.658 8.46L8.404 7.818 3.97 20.03l12.212-4.434Zm5.712-8.543a1 1 0 0 1-.447 1.341l-1 .5a1 1 0 1 1-.894-1.788l1-.5a1 1 0 0 1 1.341.447Zm-4.687-.26a1 1 0 0 1 0 1.414l-1 1a1 1 0 1 1-1.414-1.414l1-1a1 1 0 0 1 1.414 0Zm-.206 4.165A1 1 0 0 1 18.042 10L18 11l.042-1 .003.001h.014l.035.003.117.008a7.693 7.693 0 0 1 1.594.306c.423.135.861.352 1.168.516a11.873 11.873 0 0 1 .508.288l.032.02.01.006.004.002L21 12l.527-.85a1 1 0 0 1-1.054 1.7l-.005-.003-.023-.014a7.477 7.477 0 0 0-.415-.236 5.497 5.497 0 0 0-.835-.374A5.684 5.684 0 0 0 17.973 12l-.015-.002h-.002a1 1 0 0 1-.955-1.041Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M14.242 3.03a1 1 0 0 1 .728 1.213l-4 16a1 1 0 1 1-1.94-.485l4-16a1 1 0 0 1 1.213-.728ZM6.707 7.293a1 1 0 0 1 0 1.414L3.414 12l3.293 3.293a1 1 0 1 1-1.414 1.414l-4-4a1 1 0 0 1 0-1.414l4-4a1 1 0 0 1 1.414 0Zm10.586 0a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1 0 1.414l-4 4a1 1 0 1 1-1.414-1.414L20.586 12l-3.293-3.293a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 466 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 2a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0V3a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1ZM4 9a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2h.5a3.5 3.5 0 1 1 0 7H18v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9Zm12 0H6v11h10V9Zm2 5h.5a1.5 1.5 0 0 0 0-3H18v3Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 436 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm-5.894 7.803a1 1 0 0 1 1.341-.447c1.719.859 3.387.859 5.106 0a1 1 0 1 1 .894 1.788c-2.281 1.141-4.613 1.141-6.894 0a1 1 0 0 1-.447-1.341Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 489 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2ZM9.351 12.13c1.898-1.507 2.176-2.95 1.613-3.83a1.524 1.524 0 0 0-1.225-.707 1.562 1.562 0 0 0-1.218.53 1.561 1.561 0 0 0-1.326-.082c-.456.186-.8.588-.91 1.083-.227 1.02.527 2.282 2.826 3.048a.256.256 0 0 0 .24-.043Zm5.538.042c2.299-.766 3.053-2.027 2.826-3.048a1.524 1.524 0 0 0-.91-1.082 1.561 1.561 0 0 0-1.326.081 1.562 1.562 0 0 0-1.217-.53 1.524 1.524 0 0 0-1.226.706c-.563.881-.285 2.325 1.613 3.83.068.054.158.07.24.043Zm1.072 2.38a4 4 0 0 1-7.924 0c-.04-.293.218-.525.514-.499 2.309.206 4.587.206 6.896 0 .296-.026.555.206.514.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 786 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 303 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 2a1 1 0 0 1 .889.542l1.679 3.259 3.491-1.117a1 1 0 0 1 1.257 1.257L18.2 9.432l3.259 1.679a1 1 0 0 1 0 1.778l-3.124 1.61 2.609 5.098a1 1 0 0 1-1.346 1.346l-5.098-2.61-1.61 3.125a1 1 0 0 1-1.778 0l-1.679-3.259-3.491 1.117a1 1 0 0 1-1.257-1.257L5.8 14.568l-3.259-1.679a1 1 0 0 1 0-1.778l3.124-1.61-2.609-5.098a1 1 0 0 1 1.346-1.346l5.098 2.61-.455.89.455-.89 1.61-3.125A1 1 0 0 1 12 2Zm0 3.183-.72 1.4a2 2 0 0 1-2.69.864L6.248 6.248 7.447 8.59a2 2 0 0 1-.865 2.69L5.183 12l1.534.79a2 2 0 0 1 .989 2.387L7.18 16.82l1.643-.526a2 2 0 0 1 2.387.99l.79 1.533.72-1.4a2 2 0 0 1 2.69-.864l2.342 1.199-1.199-2.342a2 2 0 0 1 .864-2.69l1.4-.72-1.534-.79a2 2 0 0 1-.989-2.387l.526-1.643-1.643.526a2 2 0 0 1-2.387-.99L12 5.184Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 857 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M1 8a3 3 0 0 1 3-3h16a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V8Zm3-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H4Zm4 2a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2H9v1a1 1 0 1 1-2 0v-1H6a1 1 0 1 1 0-2h1v-1a1 1 0 0 1 1-1Zm5.5 4.5a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm3-3a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 477 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V5h14v14H5Zm10.725-5.2c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Zm-3.367.872c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm-3.879.078C6.92 14.75 6 13.827 6 12.287v-.617c0-1.47.955-2.42 2.472-2.42.589 0 1.139.147 1.548.388.404.236.664.562.664.915 0 .373-.271.636-.656.636a.82.82 0 0 1-.41-.108 2.34 2.34 0 0 1-.271-.177c-.208-.148-.421-.3-.746-.3-.644 0-.95.38-.95 1.155v.52c0 .768.306 1.168.903 1.168.436 0 .735-.248.735-.61v-.061h-.146c-.412 0-.632-.194-.632-.551 0-.353.216-.535.632-.535h.806c.617 0 .884.256.884.834v.166c0 1.253-.92 2.06-2.354 2.06Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1000 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3Zm1 14V6h16v12H4Zm2-5.713c0 1.54.92 2.463 2.48 2.463 1.434 0 2.353-.807 2.353-2.06v-.166c0-.578-.267-.834-.884-.834h-.806c-.416 0-.632.182-.632.535 0 .357.22.55.632.55h.146v.063c0 .36-.299.609-.735.609-.597 0-.904-.4-.904-1.168v-.52c0-.775.307-1.155.951-1.155.325 0 .538.152.746.3.089.064.176.127.272.177a.82.82 0 0 0 .409.108c.385 0 .656-.263.656-.636 0-.353-.26-.679-.664-.915-.409-.24-.96-.388-1.548-.388C6.955 9.25 6 10.2 6 11.67v.617Zm6.358 2.385c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm3.367-.872c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 993 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 513 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M13.5 4a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM10 5a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM8 6a1 1 0 0 0 0 2v2.64c-.212.25-.45.515-.711.8l-.129.142c-.312.342-.649.711-.974 1.092-.731.857-1.488 1.866-1.89 2.99A4.845 4.845 0 0 0 4 17.297 4.702 4.702 0 0 0 8.702 22h6.596A4.702 4.702 0 0 0 20 17.298c0-.575-.114-1.122-.297-1.634-.401-1.124-1.157-2.133-1.89-2.99-.324-.38-.66-.75-.973-1.092l-.129-.141c-.26-.286-.5-.55-.711-.8V8a1 1 0 1 0 0-2H8Zm2 5.35V8h4v3.35l.22.275c.306.383.661.777 1.013 1.163l.13.143c.315.345.628.688.93 1.042.372.435.704.861.974 1.28l-.159.025c-.845.13-1.838.242-2.581.222-.842-.022-1.475-.217-2.227-.454l-.027-.008c-.746-.235-1.61-.507-2.746-.538-.743-.02-1.617.064-2.38.165.173-.228.36-.459.56-.692.302-.354.615-.697.93-1.042l.13-.143c.352-.386.707-.78 1.014-1.163L10 11.35Zm7.41 5.905c.21-.032.407-.064.586-.095A2.702 2.702 0 0 1 15.298 20H8.702A2.702 2.702 0 0 1 6 17.298c0-.142.013-.286.039-.434.236-.043.53-.093.853-.142.845-.13 1.837-.242 2.581-.222.842.022 1.475.217 2.227.454l.027.008c.746.235 1.61.507 2.746.538.931.024 2.07-.113 2.937-.245Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 348 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M18.423 2.428a2 2 0 0 1 2.575 1.916V15.5c0 2.096-1.97 3.5-4 3.5-2.03 0-4-1.404-4-3.5s1.97-3.5 4-3.5c.7 0 1.392.167 2 .471V4.344l-8 2.4V18.5c0 2.096-1.97 3.5-4 3.5-2.03 0-4-1.404-4-3.5s1.97-3.5 4-3.5c.7 0 1.393.167 2 .471V6.744a2 2 0 0 1 1.425-1.916l8-2.4ZM8.998 18.5c0-.666-.717-1.5-2-1.5s-2 .834-2 1.5c0 .665.717 1.5 2 1.5s2-.835 2-1.5Zm10-3c0-.665-.717-1.5-2-1.5s-2 .835-2 1.5.717 1.5 2 1.5 2-.835 2-1.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 548 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 21"><path fill="#000" fill-rule="evenodd" d="M13.586 1.5a2 2 0 0 1 2.828 0L19.5 4.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 5.086 21H1a1 1 0 0 1-1-1v-4.086A2 2 0 0 1 .586 14.5l13-13ZM15 2.914l-13 13V19h3.086l13-13L15 2.914ZM11 20a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 371 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 463 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6.5 4.999Zm0 0a.054.054 0 0 1-.016.024.196.196 0 0 1-.152.043c.057.01.113.02.168.032V7.5a1 1 0 0 1-.33.742c-.543.49-.917 1.176-1.23 2.036a1 1 0 0 1-.94.657H3V14h1a1 1 0 0 1 .866.5c.436.754.879 1.195 1.637 1.636A1 1 0 0 1 7 17v2h2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2h2v-2.537a1 1 0 0 1 .332-.744A4.978 4.978 0 0 0 19 11.999 5 5 0 0 0 14 7H9.474a1 1 0 0 1-.893-.55v-.001l-.001-.002-.005-.008L9.474 6l-.9.438h.001v.002l.002.001.001.003.002.003v.003a2.62 2.62 0 0 0-.443-.538c-.319-.298-.839-.648-1.637-.814V5Zm2.082 1.452ZM9.5 4.447c.211.196.379.387.508.553H14a7 7 0 0 1 6.72 8.96 1.003 1.003 0 0 0 1.146-1.46 1 1 0 1 1 1.731-1 3 3 0 0 1-3.714 4.286A7.074 7.074 0 0 1 19 16.888V19a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2h-2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-1.45A5.783 5.783 0 0 1 3.448 16H3a2 2 0 0 1-2-2v-3.065a2 2 0 0 1 2-2h.324c.286-.649.657-1.291 1.176-1.852V5c0-1.047.89-2.12 2.161-1.907 1.33.223 2.248.805 2.839 1.354ZM8.25 12a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M18.438 3.253a2 2 0 0 1 2.309 2.31L18.243 20.17c-.186 1.083-1.244 1.868-2.387 1.588a18.525 18.525 0 0 1-8.702-4.912 18.525 18.525 0 0 1-4.912-8.702C1.962 7 2.747 5.943 3.83 5.757l14.608-2.504Zm-1.474 2.282a3 3 0 0 1-5.914 1.014l-3.368.577a13.022 13.022 0 0 0 3.376 5.816c.35.35.713.675 1.09.976a4.002 4.002 0 0 1 5.571-2.53l1.056-6.164-1.81.311Zm.388 7.991a2 2 0 0 0-3.346 1.634c.916.504 1.879.89 2.868 1.158l.478-2.792Zm-.817 4.77a15 15 0 0 1-6.891-3.94 15.02 15.02 0 0 1-3.94-6.89l-1.505.257a16.525 16.525 0 0 0 4.369 7.709 16.525 16.525 0 0 0 7.709 4.37l.258-1.505ZM13.022 6.212a1 1 0 0 0 1.97-.338l-1.97.338Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 754 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4c0-1.012.894-2.187 2.237-1.846a5.002 5.002 0 0 1 3.218 7.119 4.001 4.001 0 0 1 2.345 4.976A3.501 3.501 0 0 1 18.5 21h-13a3.5 3.5 0 0 1-1.3-6.75 4 4 0 0 1 2.052-4.848A4 4 0 0 1 10 4h2.001Zm-4.187 7.009A2 2 0 0 0 6 13c0 .366.098.706.271 1H12a1 1 0 1 1 0 2H5.5a1.5 1.5 0 0 0 0 3h13a1.5 1.5 0 1 0 0-3H17a1 1 0 1 1 0-2h.729c.173-.294.271-.634.271-1a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h1.236a3.002 3.002 0 0 0-1.243-4.832A2 2 0 0 1 12 6h-2a2 2 0 0 0-1.728 3.009H9a1 1 0 0 1 0 2H7.813Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 620 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 470 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M10.75 2.469a2 2 0 0 1 2.5 0l1.42 1.136 1.44-.576A1.378 1.378 0 0 1 18 4.309V8a6.002 6.002 0 0 1-5 5.917v1.69a6.12 6.12 0 0 1 4.224-1.606A1.796 1.796 0 0 1 19 15.857a6.143 6.143 0 0 1-6 6.141V22h-2v-.002a6.143 6.143 0 0 1-6-6.222A1.796 1.796 0 0 1 6.858 14 6.12 6.12 0 0 1 11 15.607v-1.69A6.002 6.002 0 0 1 6 8V4.308c0-.975.985-1.641 1.89-1.28l1.44.577 1.42-1.136ZM7.004 16.003a4.143 4.143 0 0 1 3.995 3.994 4.143 4.143 0 0 1-3.995-3.994Zm9.994 0a4.143 4.143 0 0 1-3.995 3.994 4.143 4.143 0 0 1 3.995-3.994ZM13.42 5.167 12 4.03l-1.42 1.136a2 2 0 0 1-1.992.295L8 5.227V8a4 4 0 0 0 8 0V5.227l-.588.235a2 2 0 0 1-1.992-.295Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 763 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 367 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.275 4.312A1 1 0 0 1 3 4h.55a4 4 0 0 1 3.656 2.375l.616 1.388.47-.47 1.001-1a2.414 2.414 0 0 1 3.948.807 2.41 2.41 0 0 1 2.5 1.5 2.41 2.41 0 0 1 2.234 1.01l.805-.804c.95-.95 2.49-.95 3.44 0 .93.93.958 2.439.035 3.395-1.228 1.271-3.406 3.497-5.078 5.035a94.045 94.045 0 0 1-2.82 2.467c-2.481 2.1-6.156 1.748-8.264-.684L3.87 16.452a6 6 0 0 1-1.458-3.614l-.41-7.785a1 1 0 0 1 .274-.741Zm14.022 6.977-1.004 1.004-.007.006-.493.494a.414.414 0 1 1-.586-.586l1.5-1.5a.414.414 0 0 1 .59.582Zm-4.18 1.595a2.414 2.414 0 0 0 4.09 1.323l1.5-1.5.01-.01 2.477-2.477c.169-.169.443-.169.612 0a.42.42 0 0 1 .01.591c-1.228 1.272-3.37 3.46-4.993 4.953a92.183 92.183 0 0 1-2.757 2.412c-1.62 1.371-4.05 1.162-5.461-.467L5.38 15.143a4 4 0 0 1-.972-2.41l-.35-6.668a2 2 0 0 1 1.32 1.123l1.208 2.718a1 1 0 0 0 1.42.456A2.421 2.421 0 0 0 10.26 11.4c.118.294.296.57.534.807.373.373.839.599 1.323.677Zm.676-2.091 1-1a.414.414 0 1 0-.586-.586l-1 1a.414.414 0 1 0 .586.586Zm-2-2 .5-.5a.414.414 0 1 0-.586-.586l-1 1a.414.414 0 0 0 .586.586l.5-.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 346 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.03 6.443c-.889.17-1.707.385-2.431.638-.966.338-1.82.764-2.45 1.286C1.523 8.884 1 9.6 1 10.5c0 1.321 1.098 2.24 2.203 2.821.854.45 1.928.817 3.142 1.093l-3.19 5.052a1 1 0 1 0 1.69 1.068l5.767-9.13a8.502 8.502 0 0 1 2.776 0l5.766 9.13a1 1 0 0 0 1.692-1.068l-3.191-5.052c1.214-.276 2.288-.644 3.142-1.093 1.105-.58 2.203-1.5 2.203-2.821 0-.9-.524-1.616-1.148-2.133-.631-.522-1.485-.948-2.45-1.286a17.147 17.147 0 0 0-2.433-.638 5 5 0 0 0-9.938 0Zm2.095-.301A28.736 28.736 0 0 1 12 6c.992 0 1.957.049 2.875.142a3.001 3.001 0 0 0-5.75 0Zm7.389 6.466c1.403-.262 2.55-.635 3.352-1.057C20.87 11.023 21 10.611 21 10.5c0-.066-.036-.271-.423-.592-.381-.315-.993-.644-1.836-.939C17.063 8.382 14.68 8 12 8c-2.68 0-5.063.382-6.74.969-.844.295-1.456.624-1.837.94-.387.32-.423.525-.423.591 0 .11.13.523 1.134 1.051.802.422 1.95.795 3.352 1.057l1.441-2.282a1.952 1.952 0 0 1 1.328-.89 10.51 10.51 0 0 1 3.49 0c.57.094 1.042.437 1.328.89l1.44 2.282Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="m9.368 4-4 8h2.944a1.5 1.5 0 0 1 1.427 1.963l-1.65 5.087L19.374 9h-3.49a1.5 1.5 0 0 1-1.287-2.272L16.234 4H9.368Zm-1.65-1.17A1.5 1.5 0 0 1 9.058 2h8.058a1.5 1.5 0 0 1 1.286 2.272L16.766 7h3.92c1.38 0 2.028 1.703.998 2.62L8.042 21.77c-1.142 1.018-2.896-.127-2.424-1.583L7.624 14H4.56a1.5 1.5 0 0 1-1.342-2.17l4.5-9Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 456 B

+20
View File
@@ -0,0 +1,20 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "simple-import-sort"],
"extends": [
"eslint:recommended",
"preact",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"rules": {
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn"
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest",
"project": "./tsconfig.json"
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 287 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(10,122,255)" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>

After

Width:  |  Height:  |  Size: 232 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(66,87,108)" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 361 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#ec4899" d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"/></svg>

After

Width:  |  Height:  |  Size: 339 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 320 286"><path fill="rgb(10,122,255)" d="M69.364 19.146c36.687 27.806 76.147 84.186 90.636 114.439 14.489-30.253 53.948-86.633 90.636-114.439C277.107-.917 320-16.44 320 32.957c0 9.865-5.603 82.875-8.889 94.729-11.423 41.208-53.045 51.719-90.071 45.357 64.719 11.12 81.182 47.953 45.627 84.785-80 82.874-106.667-44.333-106.667-44.333s-26.667 127.207-106.667 44.333c-35.555-36.832-19.092-73.665 45.627-84.785-37.026 6.362-78.648-4.149-90.071-45.357C5.603 115.832 0 42.822 0 32.957 0-16.44 42.893-.917 69.364 19.147Z"/></svg>

After

Width:  |  Height:  |  Size: 588 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#20bc07" d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 473 B

+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bluesky Embed</title>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/screens/landing.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "bskyembed",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build-snippet": "tsc --project tsconfig.snippet.json",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
},
"dependencies": {
"@atproto/api": "^0.12.2",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
"terser": "^5.30.3"
},
"devDependencies": {
"autoprefixer": "^10.4.19",
"eslint": "^8.19.0",
"eslint-config-preact": "^1.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^4.0.5",
"vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2"
}
}
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bluesky Embed</title>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/screens/post.tsx"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+100
View File
@@ -0,0 +1,100 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Window {
bluesky: {
scan: (element?: Pick<Element, 'querySelectorAll'>) => void
}
}
const EMBED_URL = 'https://embed.bsky.app'
window.bluesky = window.bluesky || {
scan,
}
/**
* Listen for messages from the Bluesky embed iframe and adjust the height of
* the iframe accordingly.
*/
window.addEventListener('message', event => {
if (event.origin !== EMBED_URL) {
return
}
const id = (event.data as {id: string}).id
if (!id) {
return
}
const embed = document.querySelector<HTMLIFrameElement>(
`[data-bluesky-id="${id}"]`,
)
if (!embed) {
return
}
const height = (event.data as {height: number}).height
if (height) {
embed.style.height = `${height}px`
}
})
/**
* Scan the document for all elements with the data-bluesky-aturi attribute,
* and initialize them as Bluesky embeds.
*
* @param element Only scan this specific element @default document @optional
* @returns
*/
function scan(node = document) {
const embeds = node.querySelectorAll('[data-bluesky-uri]')
for (let i = 0; i < embeds.length; i++) {
const id = String(Math.random()).slice(2)
const embed = embeds[i]
const aturi = embed.getAttribute('data-bluesky-uri')
if (!aturi) {
continue
}
const ref_url = location.origin + location.pathname
const searchParams = new URLSearchParams()
searchParams.set('id', id)
if (ref_url.startsWith('http')) {
searchParams.set('ref_url', encodeURIComponent(ref_url))
}
const iframe = document.createElement('iframe')
iframe.setAttribute('data-bluesky-id', id)
iframe.src = `${EMBED_URL}/embed/${aturi.slice(
'at://'.length,
)}?${searchParams.toString()}`
iframe.width = '100%'
iframe.style.border = 'none'
iframe.style.display = 'block'
iframe.style.flexGrow = '1'
iframe.frameBorder = '0'
iframe.scrolling = 'no'
const container = document.createElement('div')
container.style.maxWidth = '600px'
container.style.width = '100%'
container.style.marginTop = '10px'
container.style.marginBottom = '10px'
container.style.display = 'flex'
container.className = 'bluesky-embed'
container.appendChild(iframe)
embed.replaceWith(container)
}
}
if (['interactive', 'complete'].indexOf(document.readyState) !== -1) {
scan()
} else {
document.addEventListener('DOMContentLoaded', () => scan())
}
+55
View File
@@ -0,0 +1,55 @@
import {ComponentChildren, h} from 'preact'
import {useEffect, useRef} from 'preact/hooks'
import {Link} from './link'
export function Container({
children,
href,
}: {
children: ComponentChildren
href?: string
}) {
const ref = useRef<HTMLDivElement>(null)
const prevHeight = useRef(0)
useEffect(() => {
if (ref.current) {
const observer = new ResizeObserver(entries => {
const entry = entries[0]
if (!entry) return
let {height} = entry.contentRect
height += 2 // border top and bottom
if (height !== prevHeight.current) {
prevHeight.current = height
window.parent.postMessage(
{height, id: new URLSearchParams(window.location.search).get('id')},
'*',
)
}
})
observer.observe(ref.current)
return () => observer.disconnect()
}
}, [])
return (
<div
ref={ref}
className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl"
onClick={() => {
if (ref.current && href) {
// forwardRef requires preact/compat - let's keep it simple
// to keep the bundle size down
const anchor = ref.current.querySelector('a')
if (anchor) {
anchor.click()
}
}
}}>
{href && <Link href={href} />}
<div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
</div>
)
}
+351
View File
@@ -0,0 +1,351 @@
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
AppBskyLabelerDefs,
} from '@atproto/api'
import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils'
import {Link} from './link'
export function Embed({
content,
labels,
hideRecord,
}: {
content: AppBskyFeedDefs.PostView['embed']
labels: AppBskyFeedDefs.PostView['labels']
hideRecord?: boolean
}) {
const labelInfo = useMemo(() => labelsToInfo(labels), [labels])
if (!content) return null
try {
// Case 1: Image
if (AppBskyEmbedImages.isView(content)) {
return <ImageEmbed content={content} labelInfo={labelInfo} />
}
// Case 2: External link
if (AppBskyEmbedExternal.isView(content)) {
return <ExternalEmbed content={content} labelInfo={labelInfo} />
}
// Case 3: Record (quote or linked post)
if (AppBskyEmbedRecord.isView(content)) {
if (hideRecord) {
return null
}
const record = content.record
// Case 3.1: Post
if (AppBskyEmbedRecord.isViewRecord(record)) {
const pwiOptOut = !!record.author.labels?.find(
label => label.val === '!no-unauthenticated',
)
if (pwiOptOut) {
return (
<Info>
The author of the quoted post has requested their posts not be
displayed on external sites.
</Info>
)
}
let text
if (AppBskyFeedPost.isRecord(record.value)) {
text = record.value.text
}
const isAuthorLabeled = record.author.labels?.some(label =>
CONTENT_LABELS.includes(label.val),
)
return (
<Link
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col">
<div className="flex gap-1.5 items-center">
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 shrink-0">
<img
src={record.author.avatar}
style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
/>
</div>
<p className="line-clamp-1 text-sm">
<span className="font-bold">{record.author.displayName}</span>
<span className="text-textLight ml-1">
@{record.author.handle}
</span>
</p>
</div>
{text && <p className="text-sm">{text}</p>}
{record.embeds?.map(embed => (
<Embed
key={embed.$type}
content={embed}
labels={record.labels}
hideRecord
/>
))}
</Link>
)
}
// Case 3.2: List
if (AppBskyGraphDefs.isListView(record)) {
return (
<GenericWithImage
image={record.avatar}
title={record.name}
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
subtitle={
record.purpose === AppBskyGraphDefs.MODLIST
? `Moderation list by @${record.creator.handle}`
: `User list by @${record.creator.handle}`
}
description={record.description}
/>
)
}
// Case 3.3: Feed
if (AppBskyFeedDefs.isGeneratorView(record)) {
return (
<GenericWithImage
image={record.avatar}
title={record.displayName}
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
subtitle={`Feed by @${record.creator.handle}`}
description={`Liked by ${record.likeCount ?? 0} users`}
/>
)
}
// Case 3.4: Labeler
if (AppBskyLabelerDefs.isLabelerView(record)) {
return (
<GenericWithImage
image={record.creator.avatar}
title={record.creator.displayName || record.creator.handle}
href={`/profile/${record.creator.did}`}
subtitle="Labeler"
description={`Liked by ${record.likeCount ?? 0} users`}
/>
)
}
// Case 3.5: Post not found
if (AppBskyEmbedRecord.isViewNotFound(record)) {
return <Info>Quoted post not found, it may have been deleted.</Info>
}
// Case 3.6: Post blocked
if (AppBskyEmbedRecord.isViewBlocked(record)) {
return <Info>The quoted post is blocked.</Info>
}
throw new Error('Unknown embed type')
}
// Case 4: Record with media
if (
AppBskyEmbedRecordWithMedia.isView(content) &&
AppBskyEmbedRecord.isViewRecord(content.record.record)
) {
return (
<div className="flex flex-col gap-2">
<Embed
content={content.media}
labels={labels}
hideRecord={hideRecord}
/>
<Embed
content={{
$type: 'app.bsky.embed.record#view',
record: content.record.record,
}}
labels={content.record.record.labels}
hideRecord={hideRecord}
/>
</div>
)
}
throw new Error('Unsupported embed type')
} catch (err) {
return (
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
)
}
}
function Info({children}: {children: ComponentChildren}) {
return (
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
<img src={infoIcon as string} className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-sm text-textLight">{children}</p>
</div>
)
}
function ImageEmbed({
content,
labelInfo,
}: {
content: AppBskyEmbedImages.View
labelInfo?: string
}) {
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
switch (content.images.length) {
case 1:
return (
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
/>
)
case 2:
return (
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="w-1/2 h-full object-cover rounded-sm"
/>
))}
</div>
)
case 3:
return (
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="flex-[3] object-cover rounded-sm"
/>
<div className="flex flex-col gap-1 flex-[2]">
{content.images.slice(1).map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="w-full h-full object-cover rounded-sm"
/>
))}
</div>
</div>
)
case 4:
return (
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="aspect-square w-full object-cover rounded-sm"
/>
))}
</div>
)
default:
return null
}
}
function ExternalEmbed({
content,
labelInfo,
}: {
content: AppBskyEmbedExternal.View
labelInfo?: string
}) {
function toNiceDomain(url: string): string {
try {
const urlp = new URL(url)
return urlp.host ? urlp.host : url
} catch (e) {
return url
}
}
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
return (
<Link
href={content.external.uri}
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
{content.external.thumb && (
<img
src={content.external.thumb}
className="aspect-[1.91/1] object-cover"
/>
)}
<div className="py-3 px-4">
<p className="text-sm text-textLight line-clamp-1">
{toNiceDomain(content.external.uri)}
</p>
<p className="font-semibold line-clamp-3">{content.external.title}</p>
<p className="text-sm text-textLight line-clamp-2 mt-0.5">
{content.external.description}
</p>
</div>
</Link>
)
}
function GenericWithImage({
title,
subtitle,
href,
image,
description,
}: {
title: string
subtitle: string
href: string
image?: string
description?: string
}) {
return (
<Link
href={href}
className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2">
<div className="flex gap-2.5 items-center">
{image ? (
<img
src={image}
alt={title}
className="w-8 h-8 rounded-md bg-neutral-300 shrink-0"
/>
) : (
<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="text-textLight text-sm">{subtitle}</p>
</div>
</div>
{description && <p className="text-textLight text-sm">{description}</p>}
</Link>
)
}
+32
View File
@@ -0,0 +1,32 @@
import {h} from 'preact'
export function Link({
href,
className,
...props
}: {
href: string
className?: string
} & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
const searchParam = new URLSearchParams(window.location.search)
const ref_url = searchParam.get('ref_url')
const newSearchParam = new URLSearchParams()
newSearchParam.set('ref_src', 'embed')
if (ref_url) {
newSearchParam.set('ref_url', ref_url)
}
return (
<a
href={`${
href.startsWith('http') ? href : `https://bsky.app${href}`
}?${newSearchParam.toString()}`}
target="_blank"
rel="noopener noreferrer nofollow"
onClick={evt => evt.stopPropagation()}
className={`cursor-pointer ${className || ''}`}
{...props}
/>
)
}
+160
View File
@@ -0,0 +1,160 @@
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
import {h} from 'preact'
import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg'
import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
import {CONTENT_LABELS} from '../labels'
import {getRkey, niceDate} from '../utils'
import {Container} from './container'
import {Embed} from './embed'
import {Link} from './link'
interface Props {
thread: AppBskyFeedDefs.ThreadViewPost
}
export function Post({thread}: Props) {
const post = thread.post
const isAuthorLabeled = post.author.labels?.some(label =>
CONTENT_LABELS.includes(label.val),
)
let record: AppBskyFeedPost.Record | null = null
if (AppBskyFeedPost.isRecord(post.record)) {
record = post.record
}
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
return (
<Container href={href}>
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
<div className="flex gap-2.5 items-center cursor-pointer">
<Link href={`/profile/${post.author.did}`} className="rounded-full">
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 shrink-0">
<img
src={post.author.avatar}
style={isAuthorLabeled ? {filter: 'blur(2.5px)'} : undefined}
/>
</div>
</Link>
<div>
<Link
href={`/profile/${post.author.did}`}
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
<p>{post.author.displayName}</p>
</Link>
<Link
href={`/profile/${post.author.did}`}
className="text-[15px] text-textLight hover:underline line-clamp-1">
<p>@{post.author.handle}</p>
</Link>
</div>
<div className="flex-1" />
<Link
href={href}
className="transition-transform hover:scale-110 shrink-0 self-start">
<img src={logo as string} className="h-8" />
</Link>
</div>
<PostContent record={record} />
<Embed content={post.embed} labels={post.labels} />
<Link href={href}>
<time
datetime={new Date(post.indexedAt).toISOString()}
className="text-textLight mt-1 text-sm hover:underline">
{niceDate(post.indexedAt)}
</time>
</Link>
<div className="border-t w-full pt-2.5 flex items-center gap-5 text-sm cursor-pointer">
{!!post.likeCount && (
<div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.likeCount}
</p>
</div>
)}
{!!post.repostCount && (
<div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.repostCount}
</p>
</div>
)}
<div className="flex items-center gap-2 cursor-pointer">
<img src={replyIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">Reply</p>
</div>
<div className="flex-1" />
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
{post.replyCount
? `Read ${post.replyCount} ${
post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky`
: `View on Bluesky`}
</p>
<p className="cursor-pointer text-brand font-bold hover:underline min-[450px]:hidden">
<span className="hidden min-[380px]:inline">View on </span>Bluesky
</p>
</div>
</div>
</Container>
)
}
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
if (!record) return null
const rt = new RichText({
text: record.text,
facets: record.facets,
})
const richText = []
let counter = 0
for (const segment of rt.segments()) {
if (segment.isLink() && segment.link) {
richText.push(
<Link
key={counter}
href={segment.link.uri}
className="text-blue-400 hover:underline">
{segment.text}
</Link>,
)
} else if (segment.isMention() && segment.mention) {
richText.push(
<Link
key={counter}
href={`/profile/${segment.mention.did}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
)
} else if (segment.isTag() && segment.tag) {
richText.push(
<Link
key={counter}
href={`/tag/${segment.tag.tag}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
)
} else {
richText.push(segment.text)
}
counter++
}
return (
<p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
{richText}
</p>
)
}
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.break-word {
word-break: break-word;
}
+21
View File
@@ -0,0 +1,21 @@
import {AppBskyFeedDefs} from '@atproto/api'
export const CONTENT_LABELS = ['porn', 'sexual', 'nudity', 'graphic-media']
export function labelsToInfo(
labels?: AppBskyFeedDefs.PostView['labels'],
): string | undefined {
const label = labels?.find(label => CONTENT_LABELS.includes(label.val))
switch (label?.val) {
case 'porn':
case 'sexual':
return 'Adult Content'
case 'nudity':
return 'Non-sexual Nudity'
case 'graphic-media':
return 'Graphic Media'
default:
return undefined
}
}
+294
View File
@@ -0,0 +1,294 @@
import '../index.css'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api'
import {h, render} from 'preact'
import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import {Container} from '../components/container'
import {Link} from '../components/link'
import {Post} from '../components/post'
import {niceDate} from '../utils'
const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y'
const DEFAULT_URI =
'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
const agent = new BskyAgent({
service: 'https://public.api.bsky.app',
})
render(<LandingPage />, root)
function LandingPage() {
const [uri, setUri] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
null,
)
useEffect(() => {
void (async () => {
setError(null)
setThread(null)
setLoading(true)
try {
let atUri = DEFAULT_URI
if (uri) {
if (uri.startsWith('at://')) {
atUri = uri
} else {
try {
const urlp = new URL(uri)
if (!urlp.hostname.endsWith('bsky.app')) {
throw new Error('Invalid hostname')
}
const split = urlp.pathname.slice(1).split('/')
if (split.length < 4) {
throw new Error('Invalid pathname')
}
const [profile, didOrHandle, type, rkey] = split
if (profile !== 'profile' || type !== 'post') {
throw new Error('Invalid profile or type')
}
let did = didOrHandle
if (!didOrHandle.startsWith('did:')) {
const resolution = await agent.resolveHandle({
handle: didOrHandle,
})
if (!resolution.data.did) {
throw new Error('No DID found')
}
did = resolution.data.did
}
atUri = `at://${did}/app.bsky.feed.post/${rkey}`
} catch (err) {
console.log(err)
throw new Error('Invalid Bluesky URL')
}
}
}
const {data} = await agent.getPostThread({
uri: atUri,
depth: 0,
parentHeight: 0,
})
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
throw new Error('Post not found')
}
const pwiOptOut = !!data.thread.post.author.labels?.find(
label => label.val === '!no-unauthenticated',
)
if (pwiOptOut) {
throw new Error(
'The author of this post has requested their posts not be displayed on external sites.',
)
}
setThread(data.thread)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Invalid Bluesky URL')
} finally {
setLoading(false)
}
})()
}, [uri])
return (
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32">
<Link
href="https://bsky.social/about"
className="transition-transform hover:scale-110">
<img src={logo as string} className="h-10" />
</Link>
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
<input
type="text"
value={uri}
onInput={e => setUri(e.currentTarget.value)}
className="border rounded-lg py-3 w-full max-w-[600px] px-4"
placeholder={DEFAULT_POST}
/>
<img src={arrowBottom as string} className="w-6" />
{loading ? (
<Skeleton />
) : (
<div className="w-full max-w-[600px] gap-8 flex flex-col">
{!error && thread && uri && <Snippet thread={thread} />}
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
{error && (
<div className="w-full border border-red-500 bg-red-50 px-4 py-3 rounded-lg">
<p className="text-red-500 text-center">{error}</p>
</div>
)}
</div>
)}
</main>
)
}
function Skeleton() {
return (
<Container>
<div className="flex-1 flex-col flex gap-2 pb-8">
<div className="flex gap-2.5 items-center">
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 shrink-0 animate-pulse" />
<div className="flex-1">
<div className="bg-neutral-100 animate-pulse w-64 h-4 rounded" />
<div className="bg-neutral-100 animate-pulse w-32 h-3 mt-1 rounded" />
</div>
</div>
<div className="w-full h-4 mt-2 bg-neutral-100 rounded animate-pulse" />
<div className="w-5/6 h-4 bg-neutral-100 rounded animate-pulse" />
<div className="w-3/4 h-4 bg-neutral-100 rounded animate-pulse" />
</div>
</Container>
)
}
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
const ref = useRef<HTMLInputElement>(null)
const [copied, setCopied] = useState(false)
// reset copied state after 2 seconds
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => {
setCopied(false)
}, 2000)
return () => clearTimeout(timeout)
}
}, [copied])
const snippet = useMemo(() => {
const record = thread.post.record
if (!AppBskyFeedPost.isRecord(record)) {
return ''
}
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
const profileHref = toShareUrl(
['/profile', thread.post.author.did].join('/'),
)
const urip = new AtUri(thread.post.uri)
const href = toShareUrl(
['/profile', thread.post.author.did, 'post', urip.rkey].join('/'),
)
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
// DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
// Also, keep this code synced with the app code in Embed.tsx.
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
thread.post.uri,
)}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml(
lang,
)}">${escapeHtml(record.text)}${
record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: ''
}</p>&mdash; ${escapeHtml(
thread.post.author.displayName || thread.post.author.handle,
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
thread.post.author.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(thread.post.indexedAt),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [thread])
return (
<div className="flex gap-2 w-full">
<input
ref={ref}
type="text"
value={snippet}
className="border rounded-lg py-3 w-full px-4"
readOnly
autoFocus
onFocus={() => {
ref.current?.select()
}}
/>
<button
className="rounded-lg bg-brand text-white color-white py-3 px-4 whitespace-nowrap min-w-28"
onClick={() => {
ref.current?.focus()
ref.current?.select()
void navigator.clipboard.writeText(snippet)
setCopied(true)
}}>
{copied ? 'Copied!' : 'Copy code'}
</button>
</div>
)
}
function toShareUrl(path: string) {
return `https://bsky.app${path}?ref_src=embed`
}
/**
* Based on a snippet of code from React, which itself was based on the escape-html library.
* Copyright (c) Meta Platforms, Inc. and affiliates
* Copyright (c) 2012-2013 TJ Holowaychuk
* Copyright (c) 2015 Andreas Lubbe
* Copyright (c) 2015 Tiancheng "Timothy" Gu
* Licensed as MIT.
*/
const matchHtmlRegExp = /["'&<>]/
function escapeHtml(string: string) {
const str = String(string)
const match = matchHtmlRegExp.exec(str)
if (!match) {
return str
}
let escape
let html = ''
let index
let lastIndex = 0
for (index = match.index; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: // "
escape = '&quot;'
break
case 38: // &
escape = '&amp;'
break
case 39: // '
escape = '&#x27;'
break
case 60: // <
escape = '&lt;'
break
case 62: // >
escape = '&gt;'
break
default:
continue
}
if (lastIndex !== index) {
html += str.slice(lastIndex, index)
}
lastIndex = index + 1
html += escape
}
return lastIndex !== index ? html + str.slice(lastIndex, index) : html
}
+85
View File
@@ -0,0 +1,85 @@
import '../index.css'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {h, render} from 'preact'
import logo from '../../assets/logo.svg'
import {Container} from '../components/container'
import {Link} from '../components/link'
import {Post} from '../components/post'
import {getRkey} from '../utils'
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
const agent = new BskyAgent({
service: 'https://public.api.bsky.app',
})
const uri = `at://${window.location.pathname.slice('/embed/'.length)}`
if (!uri) {
throw new Error('No uri in path')
}
agent
.getPostThread({
uri,
depth: 0,
parentHeight: 0,
})
.then(({data}) => {
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
throw new Error('Expected a ThreadViewPost')
}
const pwiOptOut = !!data.thread.post.author.labels?.find(
label => label.val === '!no-unauthenticated',
)
if (pwiOptOut) {
render(<PwiOptOut thread={data.thread} />, root)
} else {
render(<Post thread={data.thread} />, root)
}
})
.catch(err => {
console.error(err)
render(<ErrorMessage />, root)
})
function PwiOptOut({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
const href = `/profile/${thread.post.author.did}/post/${getRkey(thread.post)}`
return (
<Container href={href}>
<Link
href={href}
className="transition-transform hover:scale-110 absolute top-4 right-4">
<img src={logo as string} className="h-6" />
</Link>
<div className="w-full py-12 gap-4 flex flex-col items-center">
<p className="max-w-80 text-center w-full text-textLight">
The author of this post has requested their posts not be displayed on
external sites.
</p>
<Link
href={href}
className="max-w-80 rounded-lg bg-brand text-white color-white text-center py-1 px-4 w-full mx-auto">
View on Bluesky
</Link>
</div>
</Container>
)
}
function ErrorMessage() {
return (
<Container href="https://bsky.app/">
<Link
href="https://bsky.app/"
className="transition-transform hover:scale-110 absolute top-4 right-4">
<img src={logo as string} className="h-6" />
</Link>
<p className="my-16 text-center w-full text-textLight">
Post not found, it may have been deleted.
</p>
</Container>
)
}
+18
View File
@@ -0,0 +1,18 @@
import {AtUri} from '@atproto/api'
export function niceDate(date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})} at ${d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`
}
export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
brand: 'rgb(10,122,255)',
textLight: 'rgb(66,87,108)',
},
},
},
plugins: [],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["vite/client"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"strict": true,
"outDir": "dist"
},
"include": ["snippet"],
}
+27
View File
@@ -0,0 +1,27 @@
import {resolve} from 'node:path'
import preact from '@preact/preset-vite'
import legacy from '@vitejs/plugin-legacy'
import type {UserConfig} from 'vite'
import paths from 'vite-tsconfig-paths'
const config: UserConfig = {
plugins: [
preact(),
paths(),
legacy({
targets: ['defaults', 'not IE 11'],
}),
],
build: {
assetsDir: 'static',
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
post: resolve(__dirname, 'post.html'),
},
},
},
}
export default config
+4194
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -3,12 +3,22 @@ test-coverage.out
# Don't check in the binary.
/bskyweb
/embedr
# Don't accidentally commit JS-generated code
static/js/*.js
static/js/*.map
static/js/*.js.LICENSE.txt
static/js/empty.txt
templates/scripts.html
templates/*-embed.html
static/embed/*.html
static/embed/assets/*.js
static/embed/assets/*.css
embedr-static/post-*.js
embedr-static/post-*.css
embedr-static/index-*.js
embedr-static/polyfills-*.js
# Don't ignore this file
!.gitignore
+5
View File
@@ -14,6 +14,7 @@ help: ## Print info about all commands
.PHONY: build
build: ## Build all executables
go build ./cmd/bskyweb
go build ./cmd/embedr
.PHONY: test
test: ## Run all tests
@@ -43,3 +44,7 @@ check: ## Compile everything, checking syntax (does not output binaries)
.PHONY: run-dev-bskyweb
run-dev-bskyweb: .env ## Runs 'bskyweb' for local dev
GOLOG_LOG_LEVEL=info go run ./cmd/bskyweb serve
.PHONY: run-dev-embedr
run-dev-embedr: .env ## Runs 'embedr' for local dev
GOLOG_LOG_LEVEL=info go run ./cmd/embedr serve
+52
View File
@@ -0,0 +1,52 @@
## oEmbed
<https://oembed.com/>
* URL scheme: `https://bsky.app/profile/*/post/*`
* API endpoint: `https://embed.bsky.app/oembed`
Request params:
- `url` (required): support both AT-URI and bsky.app URL
- `maxwidth` (optional): [220..550], 325 is default
- `maxheight` (not supported!)
- `format` (optional): only `json` supported
Response format:
- `type` (required): "rich"
- `version` (required): "1.0"
- `author_name` (optional): display name
- `author_url` (optional): profile URL
- `provider_name` (optional): "Bluesky Social"
- `provider_url` (optional): "https://bsky.app"
- `cache_age` (optional, integer seconds): 86400 (24 hours) (?)
- `width` (required): ?
- `height` (required): ?
Not used:
- title (optional): A text title, describing the resource.
- thumbnail_url (optional): A URL to a thumbnail image representing the resource. The thumbnail must respect any maxwidth and maxheight parameters. If this parameter is present, thumbnail_width and thumbnail_height must also be present.
- thumbnail_width (optional): The width of the optional thumbnail. If this parameter is present, thumbnail_url and thumbnail_height must also be present.
- thumbnail_height (optional): The height of the optional thumbnail. If this parameter is present, thumbnail_url and thumbnail_width must also be present.
Only `json` is supported; `xml` is a 501.
```
<link rel="alternate" type="application/json+oembed" href="https://embed.bsky.app/oembed?format=json&url=https://bsky.app/profile/bnewbold.net/post/abc123" />
```
## iframe URL
`https://embed.bsky.app/embed/<did>/app.bsky.feed.post/<rkey>`
`https://embed.bsky.app/static/embed.js`
```
<blockquote class="bluesky-post" data-lang="en" data-align="center">
<p lang="en" dir="ltr">{{ post-text }}</p>
&mdash; US Department of the Interior (@Interior) <a href="https://twitter.com/Interior/status/463440424141459456?ref_src=twsrc%5Etfw">May 5, 2014</a>
</blockquote>
```
+20
View File
@@ -170,6 +170,9 @@ func serve(cctx *cli.Context) error {
// home
e.GET("/", server.WebHome)
// download
e.GET("/download", server.Download)
// generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
e.GET("/search", server.WebGeneric)
@@ -187,6 +190,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
@@ -196,6 +200,8 @@ func serve(cctx *cli.Context) error {
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
// profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile)
@@ -271,6 +277,20 @@ func (srv *Server) errorHandler(err error, c echo.Context) {
c.Render(code, "error.html", data)
}
// Handler for redirecting to the download page.
func (srv *Server) Download(c echo.Context) error {
ua := c.Request().UserAgent()
if strings.Contains(ua, "Android") {
return c.Redirect(http.StatusFound, "https://play.google.com/store/apps/details?id=xyz.blueskyweb.app")
}
if strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad") || strings.Contains(ua, "iPod") {
return c.Redirect(http.StatusFound, "https://apps.apple.com/tr/app/bluesky-social/id6444370199")
}
return c.Redirect(http.StatusFound, "/")
}
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
data := pongo2.Context{}
+1
View File
@@ -0,0 +1 @@
/bskyweb
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
appbsky "github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/labstack/echo/v4"
)
var ErrPostNotFound = errors.New("post not found")
var ErrPostNotPublic = errors.New("post is not publicly accessible")
func (srv *Server) getBlueskyPost(ctx context.Context, did syntax.DID, rkey syntax.RecordKey) (*appbsky.FeedDefs_PostView, error) {
// fetch the post post (with extra context)
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
// TODO: detect 404, specifically?
return nil, ErrPostNotFound
}
if tpv.Thread.FeedDefs_BlockedPost != nil {
return nil, ErrPostNotPublic
} else if tpv.Thread.FeedDefs_ThreadViewPost.Post == nil {
return nil, ErrPostNotFound
}
postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
for _, label := range postView.Author.Labels {
if label.Src == postView.Author.Did && label.Val == "!no-unauthenticated" {
return nil, ErrPostNotPublic
}
}
return postView, nil
}
func (srv *Server) WebHome(c echo.Context) error {
return c.Render(http.StatusOK, "home.html", nil)
}
type OEmbedResponse struct {
Type string `json:"type"`
Version string `json:"version"`
AuthorName string `json:"author_name,omitempty"`
AuthorURL string `json:"author_url,omitempty"`
ProviderName string `json:"provider_url,omitempty"`
CacheAge int `json:"cache_age,omitempty"`
Width *int `json:"width"`
Height *int `json:"height"`
HTML string `json:"html,omitempty"`
}
func (srv *Server) parseBlueskyURL(ctx context.Context, raw string) (*syntax.ATURI, error) {
if raw == "" {
return nil, fmt.Errorf("empty url")
}
// first try simple AT-URI
uri, err := syntax.ParseATURI(raw)
if nil == err {
return &uri, nil
}
// then try bsky.app post URL
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
if u.Hostname() != "bsky.app" {
return nil, fmt.Errorf("only bsky.app URLs currently supported")
}
pathParts := strings.Split(u.Path, "/") // NOTE: pathParts[0] will be empty string
if len(pathParts) != 5 || pathParts[1] != "profile" || pathParts[3] != "post" {
return nil, fmt.Errorf("only bsky.app post URLs currently supported")
}
atid, err := syntax.ParseAtIdentifier(pathParts[2])
if err != nil {
return nil, err
}
rkey, err := syntax.ParseRecordKey(pathParts[4])
if err != nil {
return nil, err
}
var did syntax.DID
if atid.IsHandle() {
ident, err := srv.dir.Lookup(ctx, *atid)
if err != nil {
return nil, err
}
did = ident.DID
} else {
did, err = atid.AsDID()
if err != nil {
return nil, err
}
}
// TODO: don't really need to re-parse here, if we had test coverage
aturi, err := syntax.ParseATURI(fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey))
if err != nil {
return nil, err
} else {
return &aturi, nil
}
}
func (srv *Server) WebOEmbed(c echo.Context) error {
formatParam := c.QueryParam("format")
if formatParam != "" && formatParam != "json" {
return c.String(http.StatusNotImplemented, "Unsupported oEmbed format: "+formatParam)
}
// TODO: do we actually do something with width?
width := 600
maxWidthParam := c.QueryParam("maxwidth")
if maxWidthParam != "" {
maxWidthInt, err := strconv.Atoi(maxWidthParam)
if err != nil {
return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer)")
}
if maxWidthInt < 220 {
width = 220
} else if maxWidthInt > 600 {
width = 600
} else {
width = maxWidthInt
}
}
// NOTE: maxheight ignored
aturi, err := srv.parseBlueskyURL(c.Request().Context(), c.QueryParam("url"))
if err != nil {
return c.String(http.StatusBadRequest, fmt.Sprintf("Expected 'url' to be bsky.app URL or AT-URI: %v", err))
}
if aturi.Collection() != syntax.NSID("app.bsky.feed.post") {
return c.String(http.StatusNotImplemented, "Only posts (app.bsky.feed.post records) can be embedded currently")
}
did, err := aturi.Authority().AsDID()
if err != nil {
return err
}
post, err := srv.getBlueskyPost(c.Request().Context(), did, aturi.RecordKey())
if err == ErrPostNotFound {
return c.String(http.StatusNotFound, fmt.Sprintf("%v", err))
} else if err == ErrPostNotPublic {
return c.String(http.StatusForbidden, fmt.Sprintf("%v", err))
} else if err != nil {
return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
}
html, err := srv.postEmbedHTML(post)
if err != nil {
return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
}
data := OEmbedResponse{
Type: "rich",
Version: "1.0",
AuthorName: "@" + post.Author.Handle,
AuthorURL: fmt.Sprintf("https://bsky.app/profile/%s", post.Author.Handle),
ProviderName: "Bluesky Social",
CacheAge: 86400,
Width: &width,
Height: nil,
HTML: html,
}
if post.Author.DisplayName != nil {
data.AuthorName = fmt.Sprintf("%s (@%s)", *post.Author.DisplayName, post.Author.Handle)
}
return c.JSON(http.StatusOK, data)
}
func (srv *Server) WebPostEmbed(c echo.Context) error {
// sanity check arguments. don't 4xx, just let app handle if not expected format
rkeyParam := c.Param("rkey")
rkey, err := syntax.ParseRecordKey(rkeyParam)
if err != nil {
return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid RecordKey: %v", err))
}
didParam := c.Param("did")
did, err := syntax.ParseDID(didParam)
if err != nil {
return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid DID: %v", err))
}
_ = rkey
_ = did
// NOTE: this request was't really necessary; the JS will do the same fetch
/*
postView, err := srv.getBlueskyPost(ctx, did, rkey)
if err == ErrPostNotFound {
return c.String(http.StatusNotFound, fmt.Sprintf("%v", err))
} else if err == ErrPostNotPublic {
return c.String(http.StatusForbidden, fmt.Sprintf("%v", err))
} else if err != nil {
return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
}
*/
return c.Render(http.StatusOK, "postEmbed.html", nil)
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"os"
_ "github.com/joho/godotenv/autoload"
logging "github.com/ipfs/go-log"
"github.com/urfave/cli/v2"
)
var log = logging.Logger("embedr")
func init() {
logging.SetAllLoggers(logging.LevelDebug)
//logging.SetAllLoggers(logging.LevelWarn)
}
func main() {
run(os.Args)
}
func run(args []string) {
app := cli.App{
Name: "embedr",
Usage: "web server for embed.bsky.app post embeds",
}
app.Commands = []*cli.Command{
&cli.Command{
Name: "serve",
Usage: "run the server",
Action: serve,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "appview-host",
Usage: "method, hostname, and port of PDS instance",
Value: "https://public.api.bsky.app",
EnvVars: []string{"ATP_APPVIEW_HOST"},
},
&cli.StringFlag{
Name: "http-address",
Usage: "Specify the local IP/port to bind to",
Required: false,
Value: ":8100",
EnvVars: []string{"HTTP_ADDRESS"},
},
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug mode",
Value: false,
Required: false,
EnvVars: []string{"DEBUG"},
},
},
},
}
app.RunAndExitOnError()
}
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"html/template"
"io"
"github.com/labstack/echo/v4"
)
type Template struct {
templates *template.Template
}
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
return t.templates.ExecuteTemplate(w, name, data)
}
+236
View File
@@ -0,0 +1,236 @@
package main
import (
"context"
"errors"
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/util/cliutil"
"github.com/bluesky-social/indigo/xrpc"
"github.com/bluesky-social/social-app/bskyweb"
"github.com/klauspost/compress/gzhttp"
"github.com/klauspost/compress/gzip"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/urfave/cli/v2"
)
type Server struct {
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
dir identity.Directory
}
func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
appviewHost := cctx.String("appview-host")
// Echo
e := echo.New()
// create a new session (no auth)
xrpcc := &xrpc.Client{
Client: cliutil.NewHttpClient(),
Host: appviewHost,
}
// httpd
var (
httpTimeout = 2 * time.Minute
httpMaxHeaderBytes = 2 * (1024 * 1024)
gzipMinSizeBytes = 1024 * 2
gzipCompressionLevel = gzip.BestSpeed
gzipExceptMIMETypes = []string{"image/png"}
)
// Wrap the server handler in a gzip handler to compress larger responses.
gzipHandler, err := gzhttp.NewWrapper(
gzhttp.MinSize(gzipMinSizeBytes),
gzhttp.CompressionLevel(gzipCompressionLevel),
gzhttp.ExceptContentTypes(gzipExceptMIMETypes),
)
if err != nil {
return err
}
//
// server
//
server := &Server{
echo: e,
xrpcc: xrpcc,
dir: identity.DefaultDirectory(),
}
// Create the HTTP server.
server.httpd = &http.Server{
Handler: gzipHandler(server),
Addr: httpAddress,
WriteTimeout: httpTimeout,
ReadTimeout: httpTimeout,
MaxHeaderBytes: httpMaxHeaderBytes,
}
e.HideBanner = true
tmpl := &Template{
templates: template.Must(template.ParseFS(bskyweb.EmbedrTemplateFS, "embedr-templates/*.html")),
}
e.Renderer = tmpl
e.HTTPErrorHandler = server.errorHandler
e.IPExtractor = echo.ExtractIPFromXFFHeader()
// SECURITY: Do not modify without due consideration.
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
ContentTypeNosniff: "nosniff",
// diable XFrameOptions; we're embedding here!
HSTSMaxAge: 31536000, // 365 days
// TODO:
// ContentSecurityPolicy
// XSSProtection
}))
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
// Don't log requests for static content.
Skipper: func(c echo.Context) bool {
return strings.HasPrefix(c.Request().URL.Path, "/static")
},
}))
e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
Skipper: middleware.DefaultSkipper,
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
middleware.RateLimiterMemoryStoreConfig{
Rate: 10, // requests per second
Burst: 30, // allow bursts
ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes
},
),
IdentifierExtractor: func(ctx echo.Context) (string, error) {
id := ctx.RealIP()
return id, nil
},
DenyHandler: func(c echo.Context, identifier string, err error) error {
return c.String(http.StatusTooManyRequests, "Your request has been rate limited. Please try again later. Contact support@bsky.app if you believe this was a mistake.\n")
},
}))
// redirect trailing slash to non-trailing slash.
// all of our current endpoints have no trailing slash.
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
RedirectCode: http.StatusFound,
}))
//
// configure routes
//
// static files
staticHandler := http.FileServer(func() http.FileSystem {
if debug {
log.Debugf("serving static file from the local file system")
return http.FS(os.DirFS("embedr-static"))
}
fsys, err := fs.Sub(bskyweb.EmbedrStaticFS, "embedr-static")
if err != nil {
log.Fatal(err)
}
return http.FS(fsys)
}())
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
e.GET("/security.txt", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
})
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
path := c.Request().URL.Path
maxAge := 1 * (60 * 60) // default is 1 hour
// Cache javascript and images files for 1 week, which works because
// they're always versioned (e.g. /static/js/main.64c14927.js)
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
maxAge = 7 * (60 * 60 * 24) // 1 week
}
c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
return next(c)
}
})
// actual routes
e.GET("/", server.WebHome)
e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
e.GET("/embed.js", echo.WrapHandler(staticHandler))
e.GET("/oembed", server.WebOEmbed)
e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
// Start the server.
log.Infof("starting server address=%s", httpAddress)
go func() {
if err := server.httpd.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Errorf("HTTP server shutting down unexpectedly: %s", err)
}
}
}()
// Wait for a signal to exit.
log.Info("registering OS exit signal handler")
quit := make(chan struct{})
exitSignals := make(chan os.Signal, 1)
signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-exitSignals
log.Infof("received OS exit signal: %s", sig)
// Shut down the HTTP server.
if err := server.Shutdown(); err != nil {
log.Errorf("HTTP server shutdown error: %s", err)
}
// Trigger the return that causes an exit.
close(quit)
}()
<-quit
log.Infof("graceful shutdown complete")
return nil
}
func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
srv.echo.ServeHTTP(rw, req)
}
func (srv *Server) Shutdown() error {
log.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.httpd.Shutdown(ctx)
}
func (srv *Server) errorHandler(err error, c echo.Context) {
code := http.StatusInternalServerError
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
}
c.Logger().Error(err)
data := map[string]interface{}{
"statusCode": code,
}
c.Render(code, "error.html", data)
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"bytes"
"fmt"
"html/template"
appbsky "github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
)
func (srv *Server) postEmbedHTML(postView *appbsky.FeedDefs_PostView) (string, error) {
// ensure that there isn't an injection from the URI
aturi, err := syntax.ParseATURI(postView.Uri)
if err != nil {
log.Error("bad AT-URI in reponse", "aturi", aturi, "err", err)
return "", err
}
post, ok := postView.Record.Val.(*appbsky.FeedPost)
if !ok {
log.Error("bad post record value", "err", err)
return "", err
}
const tpl = `<blockquote class="bluesky-embed" data-bluesky-uri="{{ .PostURI }}" data-bluesky-cid="{{ .PostCID }}"><p{{ if .PostLang }} lang="{{ .PostLang }}"{{ end }}>{{ .PostText }}</p>&mdash; <a href="{{ .ProfileURL }}">{{ .PostAuthor }}</a> <a href="{{ .PostURL }}">{{ .PostIndexedAt }}</a></blockquote><script async src="{{ .WidgetURL }}" charset="utf-8"></script>`
t, err := template.New("snippet").Parse(tpl)
if err != nil {
log.Error("template parse error", "err", err)
return "", err
}
sortAt := postView.IndexedAt
createdAt, err := syntax.ParseDatetime(post.CreatedAt)
if nil == err && createdAt.String() < sortAt {
sortAt = createdAt.String()
}
var lang string
if len(post.Langs) > 0 {
lang = post.Langs[0]
}
var authorName string
if postView.Author.DisplayName != nil {
authorName = fmt.Sprintf("%s (@%s)", *postView.Author.DisplayName, postView.Author.Handle)
} else {
authorName = fmt.Sprintf("@%s", postView.Author.Handle)
}
data := struct {
PostURI template.URL
PostCID string
PostLang string
PostText string
PostAuthor string
PostIndexedAt string
ProfileURL template.URL
PostURL template.URL
WidgetURL template.URL
}{
PostURI: template.URL(postView.Uri),
PostCID: postView.Cid,
PostLang: lang,
PostText: post.Text,
PostAuthor: authorName,
PostIndexedAt: sortAt,
ProfileURL: template.URL(fmt.Sprintf("https://bsky.app/profile/%s?ref_src=embed", aturi.Authority())),
PostURL: template.URL(fmt.Sprintf("https://bsky.app/profile/%s/post/%s?ref_src=embed", aturi.Authority(), aturi.RecordKey())),
WidgetURL: template.URL("https://embed.bsky.app/static/embed.js"),
}
var buf bytes.Buffer
err = t.Execute(&buf, data)
if err != nil {
log.Error("template parse error", "err", err)
return "", err
}
return buf.String(), nil
}
@@ -0,0 +1,4 @@
Contact: mailto:security@bsky.app
Preferred-Languages: en
Canonical: https://bsky.app/.well-known/security.txt
Acknowledgements: https://github.com/bluesky-social/atproto/blob/main/CONTRIBUTORS.md
+1
View File
@@ -0,0 +1 @@
/* embed javascript widget will go here */
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
/* script to resize embed ifame would go here? */
+30
View File
@@ -0,0 +1,30 @@
13.59.225.103/32
3.18.47.21/32
18.191.104.94/32
3.129.134.255/32
3.129.237.113/32
3.138.56.230/32
44.218.10.163/32
54.89.116.251/32
44.217.166.202/32
54.208.221.149/32
54.166.110.54/32
54.208.146.65/32
3.129.234.15/32
3.138.168.48/32
3.23.53.192/32
52.14.89.53/32
3.18.126.246/32
3.136.69.4/32
3.22.137.152/32
3.132.247.113/32
3.141.186.104/32
18.222.43.214/32
3.14.35.197/32
3.23.182.70/32
18.224.144.69/32
3.129.98.29/32
3.130.134.20/32
3.17.197.213/32
18.223.234.21/32
3.20.248.177/32
View File
+9
View File
@@ -0,0 +1,9 @@
# Hello Friends!
# If you are considering bulk or automated crawling, you may want to look in
# to our protocol (API), including a firehose of updates. See: https://atproto.com/
# By default, may crawl anything on this domain. HTTP 429 ("backoff") status
# codes are used for rate-limiting. Up to a handful concurrent requests should
# be ok.
User-Agent: *
Allow: /
+1
View File
@@ -0,0 +1 @@
placeholder!
+8
View File
@@ -0,0 +1,8 @@
<html>
<head>
</head>
<body>
<h1>embed.bsky.app homepage</h1>
<p>could redirect to bsky.app? or show a "create embed" widget?
</body>
</html>
+1
View File
@@ -0,0 +1 @@
oembed JSON response will go here
+1
View File
@@ -0,0 +1 @@
embed post HTML will go here
+3
View File
@@ -4,3 +4,6 @@ import "embed"
//go:embed static/*
var StaticFS embed.FS
//go:embed embedr-static/*
var EmbedrStaticFS embed.FS
+3
View File
@@ -4,3 +4,6 @@ import "embed"
//go:embed templates/*
var TemplateFS embed.FS
//go:embed embedr-templates/*
var EmbedrTemplateFS embed.FS
+11
View File
@@ -235,6 +235,17 @@
inset:0;
animation: rotate 500ms linear infinite;
}
@keyframes avatarHoverFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes avatarHoverFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
</style>
{% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
+2
View File
@@ -36,6 +36,8 @@
<meta name="twitter:label1" content="Posted At">
<meta name="twitter:value1" content="{{ postView.IndexedAt }}">
<meta name="article:published_time" content="{{ postView.IndexedAt }}">
<meta name="article:published_time" content="{{ postView.IndexedAt }}">
<link rel="alternate" type="application/json+oembed" href="https://embed.bsky.app/oembed?format=json&url={{ postView.Uri | urlencode }}" />
{% endif -%}
{%- endblock %}
+18 -7
View File
@@ -2,7 +2,8 @@
## App Build
- Set up your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
- Set up your environment [using the expo instructions](https://docs.expo.dev/guides/local-app-development/).
- make sure that the JAVA_HOME points to the zulu-17 directory in your `.zshrc` or `.bashrc` file: `export JAVA_HOME=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home`. DO NOT use another JDK or you will encounter build errors.
- If you're running macOS, make sure you are running the correct versions of Ruby and Cocoapods:
- Check if you've installed Cocoapods through `homebrew`. If you have, remove it:
- `brew info cocoapods`
@@ -14,7 +15,7 @@
- `rbenv global 2.7.6`
- Add `eval "$(rbenv init - zsh)"` to your `~/.zshrc`
- From inside the project directory:
- `bundler install`
- `bundler install` (this will install Cocoapods)
- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started):
- `yarn global add detox-cli`
- `brew tap wix/brew`
@@ -27,7 +28,7 @@
- `git clone git@github.com:bluesky-social/atproto.git`
- `cd atproto`
- `brew install pnpm`
- `brew install jq`
- optional: `brew install jq`
- `pnpm i`
- `pnpm build`
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
@@ -38,11 +39,22 @@
- Xcode must be installed for this to run.
- A simulator must be preconfigured in Xcode settings.
- if no iOS versions are available, install the iOS runtime at `Xcode > Settings > Platforms`.
- if the simulator download keeps failing you can download it from the developer website.
- [Apple Developer](https://developer.apple.com/download/all/?q=Simulator%20Runtime)
- `xcode-select -s /Applications/Xcode.app`
- `xcodebuild -runFirstLaunch`
- `xcrun simctl runtime add "~/Downloads/iOS_17.4_Simulator_Runtime.dmg"` (adapt the path to the downloaded file)
- In addition, ensure Xcode Command Line Tools are installed using `xcode-select --install`.
- Pods must be installed:
- From the project directory root: `cd ios && pod install`.
- Expo will require you to configure Xcode Signing. Follow the linked instructions. Error messages in Xcode related to the signing process can be safely ignored when installing on the iOS Simulator; Expo merely requires the profile to exist in order to install the app on the Simulator.
- Make sure you do have a certificate: open Xcode > Settings > Accounts > (sign-in) > Manage Certificates > + > Apple Development > Done.
- If you still encounter issues, try `rm -rf ios` before trying to build again (`yarn ios`)
- Android: `yarn android`
- Install "Android Studio"
- Make sure you have the Android SDK installed (Android Studio > Tools > Android SDK).
- In "SDK Platforms": "Android x" (where x is Android's current version).
- In "SDK Tools": "Android SDK Build-Tools" and "Android Emulator" are required.
- Add `export ANDROID_HOME=/Users/<your_username>/Library/Android/sdk` to your `.zshrc` or `.bashrc` (and restart your terminal).
- Setup an emulator (Android Studio > Tools > Device Manager).
- Web: `yarn web`
- If you are cloning or forking this repo as an open-source developer, please check the tips below as well
- Run e2e tests
@@ -83,11 +95,10 @@ To run the build with Go, use staging credentials, your own, or any other accoun
```
cd social-app
yarn && yarn build-web
cp ./web-build/static/js/*.* bskyweb/static/js/
cd bskyweb/
go mod tidy
go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb
./bskyweb serve --pds-host=https://staging.bsky.dev --handle=<HANDLE> --password=<PASSWORD>
./bskyweb serve --appview-host=https://public.api.bsky.app
```
On build success, access the application at [http://localhost:8100/](http://localhost:8100/). Subsequent changes require re-running the above steps in order to be reflected.
+7 -7
View File
@@ -18,14 +18,14 @@ exports.create = function create(context) {
return
}
const source = node.parent.source.value
if (source.startsWith('.') || source.startsWith('#')) {
return
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
context.report({
node,
message:
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
})
}
context.report({
node,
message:
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
})
// TODO: Verify gate() call results aren't stored in variables.
},
}
}
+18 -31
View File
@@ -1,49 +1,36 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const fs = require('fs')
const path = require('path')
const {getDefaultConfig} = require('expo/metro-config')
const cfg = getDefaultConfig(__dirname)
if (process.env.ATPROTO_ROOT) {
const atprotoPackages = path.resolve(process.env.ATPROTO_ROOT, 'packages')
const atprotoRoot = path.resolve(process.cwd(), process.env.ATPROTO_ROOT)
// Watch folders are used as roots for the virtual file system. Any file that
// needs to be resolved by the metro bundler must be within one of the watch
// folders. Since we will be resolving dependencies from the atproto packages,
// we need to add the atproto root to the watch folders so that the
cfg.watchFolders ||= []
cfg.watchFolders.push(
...fs
.readdirSync(atprotoPackages)
.map(dir => path.join(atprotoPackages, dir))
.filter(dir => fs.statSync(dir).isDirectory()),
)
cfg.watchFolders.push(atprotoRoot)
const resolveRequest = cfg.resolver.resolveRequest
cfg.resolver.resolveRequest = (context, moduleName, platform) => {
// When resolving a module from the atproto packages, try finding it there
// first. If it's not found, try resolving it from the project root (here).
if (context.originModulePath.startsWith(atprotoPackages)) {
try {
return context.resolveRequest(context, moduleName, platform)
} catch (err) {
// If a module is not found in the atproto packages, try and resolve it
// from here (e.g. @babel polyfills)
return {
type: 'sourceFile',
filePath: require.resolve(moduleName),
}
}
// Alias @atproto/* modules to the corresponding package in the atproto root
if (moduleName.startsWith('@atproto/')) {
const [, packageName] = moduleName.split('/', 2)
const packagePath = path.join(atprotoRoot, 'packages', packageName)
return context.resolveRequest(context, packagePath, platform)
}
// When resolving an @atproto/* module, replace the path prefix with
// <atprotoPackages>.
if (moduleName.startsWith('@atproto/')) {
const [prefix, suffix] = moduleName.split('/', 2)
const resolution = context.resolveRequest(context, moduleName, platform)
const relativePathIdx = resolution.filePath.lastIndexOf(moduleName)
const relativePath = resolution.filePath.slice(
relativePathIdx + moduleName.length + 1,
)
// Polyfills are added by the build process and are not actual dependencies
// of the @atproto/* packages. Resolve those from here.
if (
moduleName.startsWith('@babel/') &&
context.originModulePath.startsWith(atprotoRoot)
) {
return {
type: 'sourceFile',
filePath: path.join(atprotoPackages, suffix, relativePath),
filePath: require.resolve(moduleName),
}
}
@@ -0,0 +1,98 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'maven-publish'
group = 'expo.modules.blueskygifview'
version = '0.5.0'
buildscript {
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
if (expoModulesCorePlugin.exists()) {
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
}
// Simple helper that allows the root project to override versions declared by this library.
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
// Ensures backward compatibility
ext.getKotlinVersion = {
if (ext.has("kotlinVersion")) {
ext.kotlinVersion()
} else {
ext.safeExtGet("kotlinVersion", "1.8.10")
}
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
}
}
afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
}
}
repositories {
maven {
url = mavenLocal().url
}
}
}
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 33)
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.majorVersion
}
}
namespace "expo.modules.blueskygifview"
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
versionCode 1
versionName "0.5.0"
}
lintOptions {
abortOnError false
}
publishing {
singleVariant("release") {
withSourcesJar()
}
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
def GLIDE_VERSION = "4.13.2"
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
// Keep glide version up to date with expo-image so that we don't have duplicate deps
implementation 'com.github.bumptech.glide:glide:4.13.2'
}

Some files were not shown because too many files have changed in this diff Show More