presence.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. const presence = new Presence({
  2. clientId: '1144333935967473685',
  3. })
  4. const browsingTimestamp = Math.floor(Date.now() / 1000)
  5. const slideshow = presence.createSlideshow()
  6. enum ActivityAssets {
  7. Logo = 'https://cdn.rcd.gg/PreMiD/websites/V/VRoid/assets/logo.png',
  8. }
  9. function getImportantPath(): string[] {
  10. const pathList = document.location.pathname.split('/').filter(Boolean)
  11. if (pathList[0] === 'en')
  12. pathList.shift()
  13. if (pathList[pathList.length - 1] !== '')
  14. pathList.push('')
  15. return pathList
  16. }
  17. function getTitle(): string {
  18. const split = document.title.match(/(.*) [|-]/)
  19. return split ? split[1]!.trim() : document.title
  20. }
  21. function applyCharacterSlideshow(presenceData: PresenceData): void {
  22. const characters = [
  23. ...document.querySelectorAll<HTMLAnchorElement>(
  24. 'a[href*=\'/characters/\']:nth-of-type(1)',
  25. ),
  26. ].map(link => link.parentElement)
  27. for (const character of characters) {
  28. const slide = Object.assign({}, presenceData)
  29. const imageUrl = character?.querySelector<HTMLDivElement>(
  30. '[data-background-image-url]',
  31. )?.dataset.backgroundImageUrl
  32. slide.largeImageKey = imageUrl
  33. slide.smallImageText = character?.children[1]?.firstElementChild?.childNodes[0]?.textContent
  34. try {
  35. slide.smallImageKey = character?.children[3]?.querySelector<HTMLDivElement>(
  36. '[data-background-image-url]',
  37. )?.dataset.backgroundImageUrl
  38. }
  39. catch {
  40. /* ignore */
  41. }
  42. slideshow.addSlide(imageUrl ?? '', slide, 5000)
  43. }
  44. }
  45. function applyArtworkSlideshow(presenceData: PresenceData): void {
  46. const artworks = [
  47. ...document.querySelectorAll<HTMLAnchorElement>(
  48. 'a[href*=\'/artworks/\']:nth-of-type(1)',
  49. ),
  50. ].map(link => link.parentElement)
  51. for (const artwork of artworks) {
  52. const slide = Object.assign({}, presenceData)
  53. const imageUrl = artwork?.querySelector<HTMLDivElement>(
  54. '[data-background-image-url]',
  55. )?.dataset.backgroundImageUrl
  56. slide.largeImageKey = imageUrl
  57. slideshow.addSlide(imageUrl ?? '', slide, 5000)
  58. }
  59. }
  60. let oldLang: string | null = null
  61. let currentTargetLang: string | null = null
  62. let oldPath: string | null = null
  63. let strings: Awaited<ReturnType<typeof presence.getStrings>> | null = null
  64. let fetchingStrings = false
  65. let stringFetchTimeout: number | null = null
  66. function fetchStrings() {
  67. if (oldLang === currentTargetLang && strings)
  68. return
  69. if (fetchingStrings)
  70. return
  71. const targetLang = currentTargetLang
  72. fetchingStrings = true
  73. stringFetchTimeout = setTimeout(() => {
  74. presence.error(`Failed to fetch strings for ${targetLang}.`)
  75. fetchingStrings = false
  76. }, 5e3)
  77. presence.info(`Fetching strings for ${targetLang}.`)
  78. presence
  79. .getStrings(
  80. {
  81. browsing: 'general.browsing',
  82. buttonReadArticle: 'general.buttonReadArticle',
  83. buttonViewPage: 'general.buttonViewPage',
  84. buttonViewProfile: 'general.buttonViewProfile',
  85. readingAbout: 'general.readingAbout',
  86. readingAPost: 'general.readingAPost',
  87. readingAnArticle: 'general.readingAnArticle',
  88. viewAProduct: 'general.viewAProduct',
  89. viewAProfile: 'general.viewAProfile',
  90. viewCategory: 'general.viewCategory',
  91. viewHome: 'general.viewHome',
  92. viewList: 'general.viewList',
  93. view: 'general.view',
  94. },
  95. )
  96. .then((result) => {
  97. if (targetLang !== currentTargetLang)
  98. return
  99. if (stringFetchTimeout)
  100. clearTimeout(stringFetchTimeout)
  101. strings = result
  102. fetchingStrings = false
  103. oldLang = targetLang
  104. presence.info(`Fetched strings for ${targetLang}.`)
  105. })
  106. .catch(() => null)
  107. }
  108. setInterval(fetchStrings, 3000)
  109. fetchStrings()
  110. /**
  111. * Sets the current language to fetch strings for and returns whether any strings are loaded.
  112. */
  113. function checkStringLanguage(lang: string) {
  114. currentTargetLang = lang
  115. return !!strings
  116. }
  117. const settingsFetchStatus: Record<string, number> = {}
  118. const cachedSettings: Record<string, unknown> = {}
  119. function startSettingGetter(setting: string) {
  120. if (!settingsFetchStatus[setting]) {
  121. let success = false
  122. settingsFetchStatus[setting] = setTimeout(() => {
  123. if (!success)
  124. presence.error(`Failed to fetch setting '${setting}' in time.`)
  125. delete settingsFetchStatus[setting]
  126. }, 2000)
  127. presence
  128. .getSetting(setting)
  129. .then((result) => {
  130. cachedSettings[setting] = result
  131. success = true
  132. })
  133. .catch(() => null)
  134. }
  135. }
  136. function getSetting<E extends string | boolean | number>(
  137. setting: string,
  138. fallback: E | null = null,
  139. ): E | null {
  140. startSettingGetter(setting)
  141. return (cachedSettings[setting] as E) ?? fallback
  142. }
  143. presence.on('UpdateData', () => {
  144. const presenceData: PresenceData = {
  145. largeImageKey: ActivityAssets.Logo,
  146. startTimestamp: browsingTimestamp,
  147. }
  148. const lang = getSetting<string>('language')
  149. const pathList = getImportantPath()
  150. const { hostname, href, pathname } = document.location
  151. if (!lang)
  152. presence.info('[WARN] Language setting not loaded, using default.')
  153. if (pathname !== oldPath) {
  154. oldPath = pathname
  155. slideshow.deleteAllSlides()
  156. }
  157. if (!checkStringLanguage(lang!))
  158. return
  159. switch (hostname) {
  160. case 'developer.vroid.com': {
  161. if (pathList[1] === 'docs') {
  162. presenceData.details = `VRoid SDK - ${strings?.readingAbout}`
  163. presenceData.state = document.querySelector('h1')?.textContent?.trim()
  164. }
  165. else {
  166. presenceData.details = `VRoid SDK - ${strings?.browsing}`
  167. }
  168. break
  169. }
  170. case 'hub.vroid.com': {
  171. switch (pathList[0]) {
  172. case '': {
  173. presenceData.details = `VRoid Hub - ${strings?.browsing}`
  174. break
  175. }
  176. case 'capture-application':
  177. case 'apps': {
  178. const [selectedTab] = [
  179. ...document.querySelectorAll<HTMLAnchorElement>('[role=nav] a'),
  180. ].sort((a, b) => {
  181. return +![...a.classList].every(name =>
  182. [...b.classList].includes(name),
  183. )
  184. })
  185. const appTitle = document.querySelector<HTMLHeadingElement>(
  186. 'header > h1',
  187. )?.textContent
  188. if (pathList[1]) {
  189. presenceData.details = `VRoid Hub - ${strings?.viewAProduct}`
  190. presenceData.buttons = [
  191. { label: strings?.buttonViewPage ?? '', url: href },
  192. ]
  193. switch (pathList[2]) {
  194. case '': {
  195. presenceData.state = appTitle
  196. break
  197. }
  198. case 'character_models': {
  199. presenceData.state = `${appTitle} - ${selectedTab?.textContent}`
  200. applyCharacterSlideshow(presenceData)
  201. break
  202. }
  203. case 'artworks': {
  204. presenceData.state = `${appTitle} - ${selectedTab?.textContent}`
  205. applyArtworkSlideshow(presenceData)
  206. break
  207. }
  208. }
  209. }
  210. else {
  211. presenceData.details = `VRoid Hub - ${strings?.viewing}`
  212. presenceData.state = getTitle()
  213. }
  214. break
  215. }
  216. case 'characters': {
  217. const container = document.querySelector<HTMLImageElement>(
  218. 'canvas + div img',
  219. )?.parentElement
  220. presenceData.details = `VRoid Hub - ${strings?.viewAProduct}`
  221. presenceData.state = `${container?.querySelector('a')?.textContent} / ${
  222. container?.nextElementSibling?.textContent
  223. }`
  224. presenceData.largeImageKey = container?.querySelector('img')?.src
  225. presenceData.buttons = [{ label: strings?.buttonViewPage ?? '', url: href }]
  226. break
  227. }
  228. case 'artworks': {
  229. presenceData.details = `VRoid Hub - ${strings?.readingAPost}`
  230. presenceData.buttons = [{ label: strings?.buttonViewPage ?? '', url: href }]
  231. applyArtworkSlideshow(presenceData)
  232. break
  233. }
  234. case 'model_assets': {
  235. const container = document.querySelector<HTMLDivElement>(
  236. 'header > div[style]',
  237. )?.parentElement
  238. presenceData.details = `VRoid Hub - ${strings?.viewAProduct}`
  239. presenceData.state = container?.querySelector<HTMLDivElement>(
  240. 'div:nth-of-type(2) > div > div',
  241. )?.textContent
  242. presenceData.largeImageKey = getComputedStyle(
  243. container?.querySelector<HTMLDivElement>('div[style]') as Element,
  244. )?.backgroundImage?.match(/url\("(.*)"\)/)?.[1]
  245. presenceData.buttons = [{ label: strings?.buttonViewPage ?? '', url: href }]
  246. break
  247. }
  248. case 'models': {
  249. presenceData.details = `VRoid Hub - ${strings?.viewList}`
  250. presenceData.state = document.querySelector<HTMLHeadingElement>(
  251. 'header > h1',
  252. )?.textContent
  253. applyCharacterSlideshow(presenceData)
  254. break
  255. }
  256. case 'tags': {
  257. presenceData.details = `VRoid Hub - ${strings?.viewCategory}`
  258. presenceData.state = `#${pathList[1]} - ${
  259. (pathList[2] === 'artworks'
  260. ? document.querySelector<HTMLAnchorElement>(
  261. 'section + div a:nth-of-type(2)',
  262. )
  263. : document.querySelector<HTMLAnchorElement>(
  264. 'section + div a:nth-of-type(1)',
  265. )
  266. )?.textContent
  267. }`
  268. if (pathList[2] === 'artworks')
  269. applyArtworkSlideshow(presenceData)
  270. else applyCharacterSlideshow(presenceData)
  271. break
  272. }
  273. case 'users': {
  274. const username = document.querySelector<HTMLHeadingElement>('a > h1')?.textContent
  275. presenceData.details = `VRoid Hub - ${strings?.viewAProfile}`
  276. presenceData.state = username
  277. presenceData.smallImageKey = document
  278. .querySelector<HTMLDivElement>('header > a > div[style]')
  279. ?.style
  280. ?.backgroundImage
  281. ?.match(/url\("(.*)"\)/)?.[1]
  282. presenceData.smallImageText = username
  283. presenceData.buttons = [
  284. { label: strings?.buttonViewProfile ?? '', url: href },
  285. ]
  286. if (pathList[2] === 'artworks') {
  287. presenceData.state += ` - ${
  288. document.querySelector<HTMLAnchorElement>(
  289. 'header + div header + div a:nth-of-type(2)',
  290. )?.textContent
  291. }`
  292. applyArtworkSlideshow(presenceData)
  293. }
  294. else {
  295. applyCharacterSlideshow(presenceData)
  296. }
  297. break
  298. }
  299. case 'hearts': {
  300. presenceData.details = `VRoid Hub - ${strings?.viewList}`
  301. presenceData.state = [
  302. ...document.querySelector<HTMLHeadingElement>('header + div h1')
  303. ?.childNodes ?? [],
  304. ]
  305. .map((node) => {
  306. return node.nodeName === 'svg' ? '❤️' : node.textContent
  307. })
  308. .join('')
  309. if (pathList[1] === 'artworks')
  310. applyArtworkSlideshow(presenceData)
  311. else applyCharacterSlideshow(presenceData)
  312. break
  313. }
  314. }
  315. break
  316. }
  317. default: {
  318. switch (pathList[0]) {
  319. case '': {
  320. presenceData.details = strings?.viewHome
  321. break
  322. }
  323. case 'studio': {
  324. presenceData.details = strings?.readingAbout
  325. presenceData.state = 'VRoid Studio'
  326. break
  327. }
  328. case 'mobile': {
  329. presenceData.details = strings?.readingAbout
  330. presenceData.state = 'VRoid Mobile'
  331. break
  332. }
  333. case 'wear': {
  334. if (pathList[1]) {
  335. presenceData.details = strings?.viewing
  336. presenceData.state = getTitle()
  337. }
  338. else {
  339. presenceData.details = strings?.readingAbout
  340. presenceData.state = 'VRoid Wear'
  341. }
  342. break
  343. }
  344. case 'news': {
  345. if (pathList[1]) {
  346. presenceData.details = strings?.readingAnArticle
  347. presenceData.state = document.querySelector<HTMLHeadingElement>(
  348. 'article h1',
  349. )?.textContent
  350. presenceData.largeImageKey = document.querySelector<HTMLImageElement>(
  351. 'article img',
  352. )?.src
  353. presenceData.buttons = [
  354. { label: strings?.buttonReadArticle ?? '', url: href },
  355. ]
  356. }
  357. else {
  358. presenceData.details = strings?.readingAnArticle
  359. presenceData.state = document.querySelector<HTMLImageElement>(
  360. 'h1 > img',
  361. )?.alt
  362. }
  363. break
  364. }
  365. }
  366. }
  367. }
  368. const slides = slideshow.getSlides()
  369. if (slides.length) {
  370. if (!slideshow.currentSlide.details)
  371. slideshow.currentSlide = slides[0]!.data
  372. presence.setActivity(slideshow)
  373. }
  374. else if (presenceData.details) {
  375. presence.setActivity(presenceData)
  376. }
  377. else {
  378. presence.setActivity()
  379. }
  380. })