mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {AtUri} from '../third-party/uri'
2import {AppBskyFeedPost} from '@atproto/api'
3type Entity = AppBskyFeedPost.Entity
4import {PROD_SERVICE} from '../state'
5import {isNetworkError} from './errors'
6import TLDs from 'tlds'
7
8export const MAX_DISPLAY_NAME = 64
9export const MAX_DESCRIPTION = 256
10
11export function pluralize(n: number, base: string, plural?: string): string {
12 if (n === 1) {
13 return base
14 }
15 if (plural) {
16 return plural
17 }
18 return base + 's'
19}
20
21export function makeRecordUri(
22 didOrName: string,
23 collection: string,
24 rkey: string,
25) {
26 const urip = new AtUri('at://host/')
27 urip.host = didOrName
28 urip.collection = collection
29 urip.rkey = rkey
30 return urip.toString()
31}
32
33const MINUTE = 60
34const HOUR = MINUTE * 60
35const DAY = HOUR * 24
36const MONTH = DAY * 30
37const YEAR = DAY * 365
38export function ago(date: number | string | Date): string {
39 let ts: number
40 if (typeof date === 'string') {
41 ts = Number(new Date(date))
42 } else if (date instanceof Date) {
43 ts = Number(date)
44 } else {
45 ts = date
46 }
47 const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
48 if (diffSeconds < MINUTE) {
49 return `${diffSeconds}s`
50 } else if (diffSeconds < HOUR) {
51 return `${Math.floor(diffSeconds / MINUTE)}m`
52 } else if (diffSeconds < DAY) {
53 return `${Math.floor(diffSeconds / HOUR)}h`
54 } else if (diffSeconds < MONTH) {
55 return `${Math.floor(diffSeconds / DAY)}d`
56 } else if (diffSeconds < YEAR) {
57 return `${Math.floor(diffSeconds / MONTH)}mo`
58 } else {
59 return new Date(ts).toLocaleDateString()
60 }
61}
62
63export function isValidDomain(str: string): boolean {
64 return !!TLDs.find(tld => {
65 let i = str.lastIndexOf(tld)
66 if (i === -1) {
67 return false
68 }
69 return str.charAt(i - 1) === '.' && i === str.length - tld.length
70 })
71}
72
73export function extractEntities(
74 text: string,
75 knownHandles?: Set<string>,
76): Entity[] | undefined {
77 let match
78 let ents: Entity[] = []
79 {
80 // mentions
81 const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
82 while ((match = re.exec(text))) {
83 if (knownHandles && !knownHandles.has(match[3])) {
84 continue // not a known handle
85 } else if (!match[3].includes('.')) {
86 continue // probably not a handle
87 }
88 const start = text.indexOf(match[3], match.index) - 1
89 ents.push({
90 type: 'mention',
91 value: match[3],
92 index: {start, end: start + match[3].length + 1},
93 })
94 }
95 }
96 {
97 // links
98 const re =
99 /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
100 while ((match = re.exec(text))) {
101 let value = match[2]
102 if (!value.startsWith('http')) {
103 const domain = match.groups?.domain
104 if (!domain || !isValidDomain(domain)) {
105 continue
106 }
107 value = `https://${value}`
108 }
109 const start = text.indexOf(match[2], match.index)
110 const index = {start, end: start + match[2].length}
111 // strip ending puncuation
112 if (/[.,;!?]$/.test(value)) {
113 value = value.slice(0, -1)
114 index.end--
115 }
116 if (/[)]$/.test(value) && !value.includes('(')) {
117 value = value.slice(0, -1)
118 index.end--
119 }
120 ents.push({
121 type: 'link',
122 value,
123 index,
124 })
125 }
126 }
127 return ents.length > 0 ? ents : undefined
128}
129
130interface DetectedLink {
131 link: string
132}
133type DetectedLinkable = string | DetectedLink
134export function detectLinkables(text: string): DetectedLinkable[] {
135 const re =
136 /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi
137 const segments = []
138 let match
139 let start = 0
140 while ((match = re.exec(text))) {
141 let matchIndex = match.index
142 let matchValue = match[0]
143
144 if (match.groups?.domain && !isValidDomain(match.groups?.domain)) {
145 continue
146 }
147
148 if (/\s|\(/.test(matchValue)) {
149 // HACK
150 // skip the starting space
151 // we have to do this because RN doesnt support negative lookaheads
152 // -prf
153 matchIndex++
154 matchValue = matchValue.slice(1)
155 }
156
157 // strip ending puncuation
158 if (/[.,;!?]$/.test(matchValue)) {
159 matchValue = matchValue.slice(0, -1)
160 }
161 if (/[)]$/.test(matchValue) && !matchValue.includes('(')) {
162 matchValue = matchValue.slice(0, -1)
163 }
164
165 if (start !== matchIndex) {
166 segments.push(text.slice(start, matchIndex))
167 }
168 segments.push({link: matchValue})
169 start = matchIndex + matchValue.length
170 }
171 if (start < text.length) {
172 segments.push(text.slice(start))
173 }
174 return segments
175}
176
177export function makeValidHandle(str: string): string {
178 if (str.length > 20) {
179 str = str.slice(0, 20)
180 }
181 str = str.toLowerCase()
182 return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
183}
184
185export function createFullHandle(name: string, domain: string): string {
186 name = (name || '').replace(/[.]+$/, '')
187 domain = (domain || '').replace(/^[.]+/, '')
188 return `${name}.${domain}`
189}
190
191export function enforceLen(str: string, len: number, ellipsis = false): string {
192 str = str || ''
193 if (str.length > len) {
194 return str.slice(0, len) + (ellipsis ? '...' : '')
195 }
196 return str
197}
198
199export function cleanError(str: any): string {
200 if (!str) {
201 return str
202 }
203 if (typeof str !== 'string') {
204 str = str.toString()
205 }
206 if (isNetworkError(str)) {
207 return 'Unable to connect. Please check your internet connection and try again.'
208 }
209 if (str.startsWith('Error: ')) {
210 return str.slice('Error: '.length)
211 }
212 return str
213}
214
215export function toNiceDomain(url: string): string {
216 try {
217 const urlp = new URL(url)
218 if (`https://${urlp.host}` === PROD_SERVICE) {
219 return 'Bluesky Social'
220 }
221 return urlp.host
222 } catch (e) {
223 return url
224 }
225}
226
227export function toShortUrl(url: string): string {
228 try {
229 const urlp = new URL(url)
230 const shortened =
231 urlp.host +
232 (urlp.pathname === '/' ? '' : urlp.pathname) +
233 urlp.search +
234 urlp.hash
235 if (shortened.length > 30) {
236 return shortened.slice(0, 27) + '...'
237 }
238 return shortened
239 } catch (e) {
240 return url
241 }
242}
243
244export function toShareUrl(url: string): string {
245 if (!url.startsWith('https')) {
246 const urlp = new URL('https://bsky.app')
247 urlp.pathname = url
248 url = urlp.toString()
249 }
250 return url
251}
252
253export function isBskyAppUrl(url: string): boolean {
254 return url.startsWith('https://bsky.app/')
255}
256
257export function convertBskyAppUrlIfNeeded(url: string): string {
258 if (isBskyAppUrl(url)) {
259 try {
260 const urlp = new URL(url)
261 return urlp.pathname
262 } catch (e) {
263 console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
264 }
265 }
266 return url
267}