logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/

serverSideStorage.js (16008B)


  1. import { toRaw } from 'vue'
  2. import {
  3. isEqual,
  4. cloneDeep,
  5. set,
  6. get,
  7. clamp,
  8. flatten,
  9. groupBy,
  10. findLastIndex,
  11. takeRight,
  12. uniqWith,
  13. merge as _merge
  14. } from 'lodash'
  15. import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
  16. export const VERSION = 1
  17. export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
  18. export const COMMAND_TRIM_FLAGS = 1000
  19. export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
  20. export const defaultState = {
  21. // do we need to update data on server?
  22. dirty: false,
  23. // storage of flags - stuff that can only be set and incremented
  24. flagStorage: {
  25. updateCounter: 0, // Counter for most recent update notification seen
  26. reset: 0 // special flag that can be used to force-reset all flags, debug purposes only
  27. // special reset codes:
  28. // 1000: trim keys to those known by currently running FE
  29. // 1001: same as above + reset everything to 0
  30. },
  31. prefsStorage: {
  32. _journal: [],
  33. simple: {
  34. dontShowUpdateNotifs: false,
  35. collapseNav: false
  36. },
  37. collections: {
  38. pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
  39. pinnedNavItems: ['home', 'dms', 'chats']
  40. }
  41. },
  42. // raw data
  43. raw: null,
  44. // local cache
  45. cache: null
  46. }
  47. export const newUserFlags = {
  48. ...defaultState.flagStorage,
  49. updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification
  50. }
  51. export const _moveItemInArray = (array, value, movement) => {
  52. const oldIndex = array.indexOf(value)
  53. const newIndex = oldIndex + movement
  54. const newArray = [...array]
  55. // remove old
  56. newArray.splice(oldIndex, 1)
  57. // add new
  58. newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
  59. return newArray
  60. }
  61. const _wrapData = (data, userName) => ({
  62. ...data,
  63. _user: userName,
  64. _timestamp: Date.now(),
  65. _version: VERSION
  66. })
  67. const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
  68. const _verifyPrefs = (state) => {
  69. state.prefsStorage = state.prefsStorage || {
  70. simple: {},
  71. collections: {}
  72. }
  73. Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
  74. if (typeof v === 'number' || typeof v === 'boolean') return
  75. console.warn(`Preference simple.${k} as invalid type, reinitializing`)
  76. set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
  77. })
  78. Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
  79. if (Array.isArray(v)) return
  80. console.warn(`Preference collections.${k} as invalid type, reinitializing`)
  81. set(state.prefsStorage.collections, k, defaultState.prefsStorage.collections[k])
  82. })
  83. }
  84. export const _getRecentData = (cache, live, isTest) => {
  85. const result = { recent: null, stale: null, needUpload: false }
  86. const cacheValid = _checkValidity(cache || {})
  87. const liveValid = _checkValidity(live || {})
  88. if (!liveValid && cacheValid) {
  89. result.needUpload = true
  90. console.debug('Nothing valid stored on server, assuming cache to be source of truth')
  91. result.recent = cache
  92. result.stale = live
  93. } else if (!cacheValid && liveValid) {
  94. console.debug('Valid storage on server found, no local cache found, using live as source of truth')
  95. result.recent = live
  96. result.stale = cache
  97. } else if (cacheValid && liveValid) {
  98. console.debug('Both sources have valid data, figuring things out...')
  99. if (live._timestamp === cache._timestamp && live._version === cache._version) {
  100. console.debug('Same version/timestamp on both source, source of truth irrelevant')
  101. result.recent = cache
  102. result.stale = live
  103. } else {
  104. console.debug('Different timestamp, figuring out which one is more recent')
  105. if (live._timestamp < cache._timestamp) {
  106. result.recent = cache
  107. result.stale = live
  108. } else {
  109. result.recent = live
  110. result.stale = cache
  111. }
  112. }
  113. } else {
  114. console.debug('Both sources are invalid, start from scratch')
  115. result.needUpload = true
  116. }
  117. const merge = (a, b) => ({
  118. _user: a._user ?? b._user,
  119. _version: a._version ?? b._version,
  120. _timestamp: a._timestamp ?? b._timestamp,
  121. needUpload: b.needUpload ?? a.needUpload,
  122. prefsStorage: _merge(a.prefsStorage, b.prefsStorage),
  123. flagStorage: _merge(a.flagStorage, b.flagStorage)
  124. })
  125. result.recent = isTest ? result.recent : (result.recent && merge(defaultState, result.recent))
  126. result.stale = isTest ? result.stale : (result.stale && merge(defaultState, result.stale))
  127. return result
  128. }
  129. export const _getAllFlags = (recent, stale) => {
  130. return Array.from(new Set([
  131. ...Object.keys(toRaw((recent || {}).flagStorage || {})),
  132. ...Object.keys(toRaw((stale || {}).flagStorage || {}))
  133. ]))
  134. }
  135. export const _mergeFlags = (recent, stale, allFlagKeys) => {
  136. if (!stale.flagStorage) return recent.flagStorage
  137. if (!recent.flagStorage) return stale.flagStorage
  138. return Object.fromEntries(allFlagKeys.map(flag => {
  139. const recentFlag = recent.flagStorage[flag]
  140. const staleFlag = stale.flagStorage[flag]
  141. // use flag that is of higher value
  142. return [flag, Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0)]
  143. }))
  144. }
  145. const _mergeJournal = (...journals) => {
  146. // Ignore invalid journal entries
  147. const allJournals = flatten(
  148. journals.map(j => Array.isArray(j) ? j : [])
  149. ).filter(entry =>
  150. Object.prototype.hasOwnProperty.call(entry, 'path') &&
  151. Object.prototype.hasOwnProperty.call(entry, 'operation') &&
  152. Object.prototype.hasOwnProperty.call(entry, 'args') &&
  153. Object.prototype.hasOwnProperty.call(entry, 'timestamp')
  154. )
  155. const grouped = groupBy(allJournals, 'path')
  156. const trimmedGrouped = Object.entries(grouped).map(([path, journal]) => {
  157. // side effect
  158. journal.sort((a, b) => a.timestamp > b.timestamp ? 1 : -1)
  159. if (path.startsWith('collections')) {
  160. const lastRemoveIndex = findLastIndex(journal, ({ operation }) => operation === 'removeFromCollection')
  161. // everything before last remove is unimportant
  162. let remainder
  163. if (lastRemoveIndex > 0) {
  164. remainder = journal.slice(lastRemoveIndex)
  165. } else {
  166. // everything else doesn't need trimming
  167. remainder = journal
  168. }
  169. return uniqWith(remainder, (a, b) => {
  170. if (a.path !== b.path) { return false }
  171. if (a.operation !== b.operation) { return false }
  172. if (a.operation === 'addToCollection') {
  173. return a.args[0] === b.args[0]
  174. }
  175. return false
  176. })
  177. } else if (path.startsWith('simple')) {
  178. // Only the last record is important
  179. return takeRight(journal)
  180. } else {
  181. return journal
  182. }
  183. })
  184. return flatten(trimmedGrouped)
  185. .sort((a, b) => a.timestamp > b.timestamp ? 1 : -1)
  186. }
  187. export const _mergePrefs = (recent, stale) => {
  188. if (!stale) return recent
  189. if (!recent) return stale
  190. const { _journal: recentJournal, ...recentData } = recent
  191. const { _journal: staleJournal } = stale
  192. /** Journal entry format:
  193. * path: path to entry in prefsStorage
  194. * timestamp: timestamp of the change
  195. * operation: operation type
  196. * arguments: array of arguments, depends on operation type
  197. *
  198. * currently only supported operation type is "set" which just sets the value
  199. * to requested one. Intended only to be used with simple preferences (boolean, number)
  200. * shouldn't be used with collections!
  201. */
  202. const resultOutput = { ...recentData }
  203. const totalJournal = _mergeJournal(staleJournal, recentJournal)
  204. totalJournal.forEach(({ path, operation, args }) => {
  205. if (path.startsWith('_')) {
  206. console.error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`)
  207. return
  208. }
  209. switch (operation) {
  210. case 'set':
  211. set(resultOutput, path, args[0])
  212. break
  213. case 'addToCollection':
  214. set(resultOutput, path, Array.from(new Set(get(resultOutput, path)).add(args[0])))
  215. break
  216. case 'removeFromCollection': {
  217. const newSet = new Set(get(resultOutput, path))
  218. newSet.delete(args[0])
  219. set(resultOutput, path, Array.from(newSet))
  220. break
  221. }
  222. case 'reorderCollection': {
  223. const [value, movement] = args
  224. set(resultOutput, path, _moveItemInArray(get(resultOutput, path), value, movement))
  225. break
  226. }
  227. default:
  228. console.error(`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`)
  229. }
  230. })
  231. return { ...resultOutput, _journal: totalJournal }
  232. }
  233. export const _resetFlags = (totalFlags, knownKeys = defaultState.flagStorage) => {
  234. let result = { ...totalFlags }
  235. const allFlagKeys = Object.keys(totalFlags)
  236. // flag reset functionality
  237. if (totalFlags.reset >= COMMAND_TRIM_FLAGS && totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET) {
  238. console.debug('Received command to trim the flags')
  239. const knownKeysSet = new Set(Object.keys(knownKeys))
  240. // Trim
  241. result = {}
  242. allFlagKeys.forEach(flag => {
  243. if (knownKeysSet.has(flag)) {
  244. result[flag] = totalFlags[flag]
  245. }
  246. })
  247. // Reset
  248. if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
  249. // 1001 - and reset everything to 0
  250. console.debug('Received command to reset the flags')
  251. Object.keys(knownKeys).forEach(flag => { result[flag] = 0 })
  252. }
  253. } else if (totalFlags.reset > 0 && totalFlags.reset < 9000) {
  254. console.debug('Received command to reset the flags')
  255. allFlagKeys.forEach(flag => { result[flag] = 0 })
  256. }
  257. result.reset = 0
  258. return result
  259. }
  260. export const _doMigrations = (cache) => {
  261. if (!cache) return cache
  262. if (cache._version < VERSION) {
  263. console.debug('Local cached data has older version, seeing if there any migrations that can be applied')
  264. // no migrations right now since we only have one version
  265. console.debug('No migrations found')
  266. }
  267. if (cache._version > VERSION) {
  268. console.debug('Local cached data has newer version, seeing if there any reverse migrations that can be applied')
  269. // no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
  270. if (window._PLEROMA_HOTPATCH) {
  271. if (window._PLEROMA_HOTPATCH.reverseMigrations) {
  272. console.debug('Found hotpatch migration, applying')
  273. return window._PLEROMA_HOTPATCH.reverseMigrations.call({}, 'serverSideStorage', { from: cache._version, to: VERSION }, cache)
  274. }
  275. }
  276. }
  277. return cache
  278. }
  279. export const mutations = {
  280. clearServerSideStorage (state) {
  281. const blankState = { ...cloneDeep(defaultState) }
  282. Object.keys(state).forEach(k => {
  283. state[k] = blankState[k]
  284. })
  285. },
  286. setServerSideStorage (state, userData) {
  287. const live = userData.storage
  288. state.raw = live
  289. let cache = state.cache
  290. if (cache && cache._user !== userData.fqn) {
  291. console.warn('Cache belongs to another user! reinitializing local cache!')
  292. cache = null
  293. }
  294. cache = _doMigrations(cache)
  295. let { recent, stale, needUpload } = _getRecentData(cache, live)
  296. const userNew = userData.created_at > NEW_USER_DATE
  297. const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
  298. let dirty = false
  299. if (recent === null) {
  300. console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`)
  301. recent = _wrapData({
  302. flagStorage: { ...flagsTemplate },
  303. prefsStorage: { ...defaultState.prefsStorage }
  304. })
  305. }
  306. if (!needUpload && recent && stale) {
  307. console.debug('Checking if data needs merging...')
  308. // discarding timestamps and versions
  309. /* eslint-disable no-unused-vars */
  310. const { _timestamp: _0, _version: _1, ...recentData } = recent
  311. const { _timestamp: _2, _version: _3, ...staleData } = stale
  312. /* eslint-enable no-unused-vars */
  313. dirty = !isEqual(recentData, staleData)
  314. console.debug(`Data ${dirty ? 'needs' : 'doesn\'t need'} merging`)
  315. }
  316. const allFlagKeys = _getAllFlags(recent, stale)
  317. let totalFlags
  318. let totalPrefs
  319. if (dirty) {
  320. // Merge the flags
  321. console.debug('Merging the data...')
  322. totalFlags = _mergeFlags(recent, stale, allFlagKeys)
  323. _verifyPrefs(recent)
  324. _verifyPrefs(stale)
  325. totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
  326. } else {
  327. totalFlags = recent.flagStorage
  328. totalPrefs = recent.prefsStorage
  329. }
  330. totalFlags = _resetFlags(totalFlags)
  331. recent.flagStorage = { ...flagsTemplate, ...totalFlags }
  332. recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
  333. state.dirty = dirty || needUpload
  334. state.cache = recent
  335. // set local timestamp to smaller one if we don't have any changes
  336. if (stale && recent && !state.dirty) {
  337. state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
  338. }
  339. state.flagStorage = state.cache.flagStorage
  340. state.prefsStorage = state.cache.prefsStorage
  341. },
  342. setFlag (state, { flag, value }) {
  343. state.flagStorage[flag] = value
  344. state.dirty = true
  345. },
  346. setPreference (state, { path, value }) {
  347. if (path.startsWith('_')) {
  348. console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
  349. return
  350. }
  351. set(state.prefsStorage, path, value)
  352. state.prefsStorage._journal = [
  353. ...state.prefsStorage._journal,
  354. { operation: 'set', path, args: [value], timestamp: Date.now() }
  355. ]
  356. state.dirty = true
  357. },
  358. addCollectionPreference (state, { path, value }) {
  359. if (path.startsWith('_')) {
  360. console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
  361. return
  362. }
  363. const collection = new Set(get(state.prefsStorage, path))
  364. collection.add(value)
  365. set(state.prefsStorage, path, [...collection])
  366. state.prefsStorage._journal = [
  367. ...state.prefsStorage._journal,
  368. { operation: 'addToCollection', path, args: [value], timestamp: Date.now() }
  369. ]
  370. state.dirty = true
  371. },
  372. removeCollectionPreference (state, { path, value }) {
  373. if (path.startsWith('_')) {
  374. console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
  375. return
  376. }
  377. const collection = new Set(get(state.prefsStorage, path))
  378. collection.delete(value)
  379. set(state.prefsStorage, path, [...collection])
  380. state.prefsStorage._journal = [
  381. ...state.prefsStorage._journal,
  382. { operation: 'removeFromCollection', path, args: [value], timestamp: Date.now() }
  383. ]
  384. state.dirty = true
  385. },
  386. reorderCollectionPreference (state, { path, value, movement }) {
  387. if (path.startsWith('_')) {
  388. console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
  389. return
  390. }
  391. const collection = get(state.prefsStorage, path)
  392. const newCollection = _moveItemInArray(collection, value, movement)
  393. set(state.prefsStorage, path, newCollection)
  394. state.prefsStorage._journal = [
  395. ...state.prefsStorage._journal,
  396. { operation: 'arrangeCollection', path, args: [value], timestamp: Date.now() }
  397. ]
  398. state.dirty = true
  399. },
  400. updateCache (state, { username }) {
  401. state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal)
  402. state.cache = _wrapData({
  403. flagStorage: toRaw(state.flagStorage),
  404. prefsStorage: toRaw(state.prefsStorage)
  405. }, username)
  406. }
  407. }
  408. const serverSideStorage = {
  409. state: {
  410. ...cloneDeep(defaultState)
  411. },
  412. mutations,
  413. actions: {
  414. pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) {
  415. const needPush = state.dirty || force
  416. if (!needPush) return
  417. commit('updateCache', { username: rootState.users.currentUser.fqn })
  418. const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } }
  419. rootState.api.backendInteractor
  420. .updateProfile({ params })
  421. .then((user) => {
  422. commit('setServerSideStorage', user)
  423. state.dirty = false
  424. })
  425. }
  426. }
  427. }
  428. export default serverSideStorage