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 (15266B)


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