Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork

feat: add aturi.to as share option

Add aturi.to universal link sharing to post share menu, allowing users
to share content that recipients can view on any ATProto client.

Changes:
- Add toShareUrlAturi() function to convert witchsky URLs to aturi.to format
- Implement onSharePostAturi() and onCopyLinkAturi() handlers with analytics
- Add "Share via aturi.to..." and "Copy aturi.to link" menu items

aturi.to links let recipients choose their preferred client (Bluesky,
Blacksky, Anisota, etc.) instead of forcing them to a specific platform.

ewancroft.uk 22e8f396 40b6cc32

verified
Changed files
+82 -1
src
components
PostControls
ShareMenu
lib
strings
+41 -1
src/components/PostControls/ShareMenu/ShareMenuItems.tsx
··· 9 9 import {makeProfileLink} from '#/lib/routes/links' 10 10 import {type NavigationProp} from '#/lib/routes/types' 11 11 import {shareText, shareUrl} from '#/lib/sharing' 12 - import {toShareUrl, toShareUrlBsky} from '#/lib/strings/url-helpers' 12 + import {toShareUrl, toShareUrlBsky, toShareUrlAturi} from '#/lib/strings/url-helpers' 13 13 import {logger} from '#/logger' 14 14 import {isIOS} from '#/platform/detection' 15 15 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 87 87 const onCopyLinkBsky = async () => { 88 88 logger.metric('share:press:copyLink', {}, {statsig: true}) 89 89 const url = toShareUrlBsky(href) 90 + if (isIOS) { 91 + // iOS only 92 + await ExpoClipboard.setUrlAsync(url) 93 + } else { 94 + await ExpoClipboard.setStringAsync(url) 95 + } 96 + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 97 + onShareProp() 98 + } 99 + 100 + const onSharePostAturi = () => { 101 + logger.metric('share:press:nativeShare', {}, {statsig: true}) 102 + const url = toShareUrlAturi(href) 103 + shareUrl(url) 104 + onShareProp() 105 + } 106 + 107 + const onCopyLinkAturi = async () => { 108 + logger.metric('share:press:copyLink', {}, {statsig: true}) 109 + const url = toShareUrlAturi(href) 90 110 if (isIOS) { 91 111 // iOS only 92 112 await ExpoClipboard.setUrlAsync(url) ··· 212 232 onPress={onCopyLinkBsky}> 213 233 <Menu.ItemText> 214 234 <Trans>Copy via bsky.app</Trans> 235 + </Menu.ItemText> 236 + <Menu.ItemIcon icon={ChainLinkIcon} position="right" /> 237 + </Menu.Item> 238 + 239 + <Menu.Item 240 + testID="postDropdownShareAturiBtn" 241 + label={_(msg`Share via aturi.to...`)} 242 + onPress={onSharePostAturi}> 243 + <Menu.ItemText> 244 + <Trans>Share via aturi.to...</Trans> 245 + </Menu.ItemText> 246 + <Menu.ItemIcon icon={ArrowOutOfBoxIcon} position="right" /> 247 + </Menu.Item> 248 + 249 + <Menu.Item 250 + testID="postDropdownCopyAturiBtn" 251 + label={_(msg`Copy aturi.to link`)} 252 + onPress={onCopyLinkAturi}> 253 + <Menu.ItemText> 254 + <Trans>Copy aturi.to link</Trans> 215 255 </Menu.ItemText> 216 256 <Menu.ItemIcon icon={ChainLinkIcon} position="right" /> 217 257 </Menu.Item>
+41
src/lib/strings/url-helpers.ts
··· 99 99 return url 100 100 } 101 101 102 + export function toShareUrlAturi(url: string): string { 103 + // Convert witchsky URL to aturi.to format 104 + // Expected input format: /profile/handle/post/rkey 105 + try { 106 + if (!url.startsWith('https')) { 107 + const urlp = new URL('https://witchsky.app') 108 + urlp.pathname = url 109 + url = urlp.toString() 110 + } 111 + 112 + const urlp = new URL(url) 113 + const pathname = urlp.pathname 114 + 115 + // Extract components from /profile/identifier/post/rkey 116 + if (pathname.startsWith('/profile/')) { 117 + const parts = pathname.substring(9).split('/') // Remove "/profile/" 118 + 119 + if (parts.length === 3 && parts[1] === 'post') { 120 + // Post: /profile/identifier/post/rkey 121 + const identifier = parts[0] 122 + const rkey = parts[2] 123 + return `https://aturi.to/${identifier}/app.bsky.feed.post/${rkey}` 124 + } else if (parts.length === 3 && parts[1] === 'lists') { 125 + // List: /profile/identifier/lists/rkey 126 + const identifier = parts[0] 127 + const rkey = parts[2] 128 + return `https://aturi.to/${identifier}/app.bsky.graph.list/${rkey}` 129 + } else if (parts.length === 1) { 130 + // Profile only: /profile/identifier 131 + return `https://aturi.to/${parts[0]}` 132 + } 133 + } 134 + 135 + // Fallback to original URL if we can't parse it 136 + return url 137 + } catch (e) { 138 + console.error('Error converting to aturi.to URL:', e) 139 + return url 140 + } 141 + } 142 + 102 143 export function toBskyAppUrl(url: string): string { 103 144 return new URL(url, BSKY_APP_HOST).toString() 104 145 }