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


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