diff --git a/psf-memo-client/src/services/memo-follow.js b/psf-memo-client/src/services/memo-follow.js index 05f2afe..0f5e18a 100644 --- a/psf-memo-client/src/services/memo-follow.js +++ b/psf-memo-client/src/services/memo-follow.js @@ -16,106 +16,44 @@ PK_HASH_LENGTH : size of the followee hash160 in bytes (20) */ -const MemoAction = require('./memo-action') -const { hexToBytes } = require('./hex') +const MemoStateAction = require('./memo-state-action') const MEMO_FOLLOW_PREFIX = '6d06' const MEMO_UNFOLLOW_PREFIX = '6d07' -const PK_HASH_LENGTH = 20 -class MemoFollow extends MemoAction { - static config = { +// Compose the static config for this Memo state action. +function followConfig () { + return { prefix: MEMO_FOLLOW_PREFIX, walletRequiredMsg: 'Memo follow requires a wallet.', lengthMessage: 'Follow address is invalid.', emptyMessage: 'Follow address is required.', lengthCode: 'follow_validation', - validationCode: 'follow_validation' + validationCode: 'follow_validation', + reflectMethod: 'setFollowState' } +} - constructor (deps = {}) { - super(deps) - this.profiles = deps.profiles - } - - // Validate a candidate cash address. Returns { ok: true } or throws a typed - // validation error. - validate (addr) { - if (typeof addr !== 'string' || addr.trim().length === 0) { - const err = new Error(this.emptyMessage) - err.code = this.validationCode - throw err - } - - try { - this._toHash160(addr) - return { ok: true } - } catch (err) { - const validationErr = new Error(`Invalid cash address: ${addr}`) - validationErr.code = this.validationCode - throw validationErr - } - } +class MemoFollow extends MemoStateAction { + static config = followConfig() // Broadcast a Memo follow for the given followee address. async follow (followeeAddr) { - return this._broadcastAction(followeeAddr, MEMO_FOLLOW_PREFIX, true) + return this._setState(followeeAddr, MEMO_FOLLOW_PREFIX, true) } // Broadcast a Memo unfollow for the given followee address. async unfollow (followeeAddr) { - return this._broadcastAction(followeeAddr, MEMO_UNFOLLOW_PREFIX, false) - } - - // Internal: validate, broadcast, and reflect a follow/unfollow action. - async _broadcastAction (followeeAddr, prefix, isFollow) { - if (!this.wallet) { - throw new Error(this.walletRequiredMsg) - } - this._ensureBchjs() - - this.validate(followeeAddr) - - await this.wallet.getUtxos() - - const hash160 = this._toHash160(followeeAddr) - const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160') - - const txid = await this.wallet.sendOpReturn(raw, prefix) - - this.reflect(txid, followeeAddr, isFollow) - - return txid - } - - // Convert a cash address to its 20-byte hash160 hex string using the wallet's - // embedded bch-js. - _toHash160 (addr) { - return this.wallet.bchjs.Address.toHash160(addr) - } - - _ensureBchjs () { - if (!this.wallet.bchjs || typeof this.wallet.bchjs.Address.toHash160 !== 'function') { - throw new Error('Wallet does not expose bch-js Address.toHash160.') - } - } - - // Record the new follow state on the injected profile store when it exposes - // the follow methods. - reflect (txid, followeeAddr, isFollow) { - if (this.profiles && typeof this.profiles.setFollowState === 'function') { - const myAddr = this.wallet?.walletInfo?.cashAddress - this.profiles.setFollowState(myAddr, followeeAddr, isFollow) - } + return this._setState(followeeAddr, MEMO_UNFOLLOW_PREFIX, false) } } MemoFollow.MEMO_FOLLOW_PREFIX = MEMO_FOLLOW_PREFIX MemoFollow.MEMO_UNFOLLOW_PREFIX = MEMO_UNFOLLOW_PREFIX -MemoFollow.PK_HASH_LENGTH = PK_HASH_LENGTH +MemoFollow.PK_HASH_LENGTH = MemoStateAction.PK_HASH_LENGTH module.exports = MemoFollow // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-27T17:51:36.253Z","module_hash":"fc0d7db2a5f7872ae0d9228a19c7314b4f46f7574c53b2a168a62b986c43a31e","functions":[{"id":"func/MemoFollow.constructor","name":"MemoFollow.constructor","line":35,"end_line":38,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoFollow.validate","name":"MemoFollow.validate","line":42,"end_line":57,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoFollow.follow","name":"MemoFollow.follow","line":60,"end_line":62,"hash":"e24f69ef420d1ef625c2d03eae6014c824fdb35438a87e379fdc41747bbd321d"},{"id":"func/MemoFollow.unfollow","name":"MemoFollow.unfollow","line":65,"end_line":67,"hash":"48e93e85645bbcd767467afb807625b4adf6d6076f02fc841c91f58147bb44c7"},{"id":"func/MemoFollow._broadcastAction","name":"MemoFollow._broadcastAction","line":70,"end_line":88,"hash":"3720e2b6d78265383728bd7c5fcafeb018fad9c17a8bede12a6d4ffc3415cd3a"},{"id":"func/MemoFollow._toHash160","name":"MemoFollow._toHash160","line":92,"end_line":94,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoFollow._ensureBchjs","name":"MemoFollow._ensureBchjs","line":96,"end_line":100,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoFollow.reflect","name":"MemoFollow.reflect","line":104,"end_line":109,"hash":"af3f0ead1a9f51724e3c252122b3bf13c5323a3b9da244dee87a34815a90c94c"}]} +// {"version":1,"tested_at":"2026-09-04T18:22:54.857Z","module_hash":"cc36dc8387a54f50e91393bcb10382515e66a0cfc74fb4f51412fac4df580136","functions":[{"id":"func/followConfig","name":"followConfig","line":25,"end_line":35,"hash":"6843e108f72a95c18267d1ecea29fdfbdb0814ce4b5e3672f22df612e6ecaccd"},{"id":"func/MemoFollow.follow","name":"MemoFollow.follow","line":41,"end_line":43,"hash":"82635824a8e221e587b74b22fea454d2f661e599d8c9b9d0d7ec7c14e214cbd7"},{"id":"func/MemoFollow.unfollow","name":"MemoFollow.unfollow","line":46,"end_line":48,"hash":"fa99fa77d68a875092f5aad8323dca42de06dff49af469c6d7db94f7033399b5"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-mute.js b/psf-memo-client/src/services/memo-mute.js index 0fda779..88ada11 100644 --- a/psf-memo-client/src/services/memo-mute.js +++ b/psf-memo-client/src/services/memo-mute.js @@ -16,106 +16,44 @@ PK_HASH_LENGTH : size of the mutee hash160 in bytes (20) */ -const MemoAction = require('./memo-action') -const { hexToBytes } = require('./hex') +const MemoStateAction = require('./memo-state-action') const MEMO_MUTE_PREFIX = '6d16' const MEMO_UNMUTE_PREFIX = '6d17' -const PK_HASH_LENGTH = 20 -class MemoMute extends MemoAction { - static config = { +// Compose the static config for this Memo state action. +function muteConfig () { + return { prefix: MEMO_MUTE_PREFIX, walletRequiredMsg: 'Memo mute requires a wallet.', lengthMessage: 'Mute address is invalid.', emptyMessage: 'Mute address is required.', lengthCode: 'mute_validation', - validationCode: 'mute_validation' + validationCode: 'mute_validation', + reflectMethod: 'setMuteState' } +} - constructor (deps = {}) { - super(deps) - this.profiles = deps.profiles - } - - // Validate a candidate cash address. Returns { ok: true } or throws a typed - // validation error. - validate (addr) { - if (typeof addr !== 'string' || addr.trim().length === 0) { - const err = new Error(this.emptyMessage) - err.code = this.validationCode - throw err - } - - try { - this._toHash160(addr) - return { ok: true } - } catch (err) { - const validationErr = new Error(`Invalid cash address: ${addr}`) - validationErr.code = this.validationCode - throw validationErr - } - } +class MemoMute extends MemoStateAction { + static config = muteConfig() // Broadcast a Memo mute for the given mutee address. async mute (muteeAddr) { - return this._broadcastAction(muteeAddr, MEMO_MUTE_PREFIX, true) + return this._setState(muteeAddr, MEMO_MUTE_PREFIX, true) } // Broadcast a Memo unmute for the given mutee address. async unmute (muteeAddr) { - return this._broadcastAction(muteeAddr, MEMO_UNMUTE_PREFIX, false) - } - - // Internal: validate, broadcast, and reflect a mute/unmute action. - async _broadcastAction (muteeAddr, prefix, isMute) { - if (!this.wallet) { - throw new Error(this.walletRequiredMsg) - } - this._ensureBchjs() - - this.validate(muteeAddr) - - await this.wallet.getUtxos() - - const hash160 = this._toHash160(muteeAddr) - const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160') - - const txid = await this.wallet.sendOpReturn(raw, prefix) - - this.reflect(txid, muteeAddr, isMute) - - return txid - } - - // Convert a cash address to its 20-byte hash160 hex string using the wallet's - // embedded bch-js. - _toHash160 (addr) { - return this.wallet.bchjs.Address.toHash160(addr) - } - - _ensureBchjs () { - if (!this.wallet.bchjs || typeof this.wallet.bchjs.Address.toHash160 !== 'function') { - throw new Error('Wallet does not expose bch-js Address.toHash160.') - } - } - - // Record the new mute state on the injected profile store when it exposes - // the mute methods. - reflect (txid, muteeAddr, isMute) { - if (this.profiles && typeof this.profiles.setMuteState === 'function') { - const myAddr = this.wallet?.walletInfo?.cashAddress - this.profiles.setMuteState(myAddr, muteeAddr, isMute) - } + return this._setState(muteeAddr, MEMO_UNMUTE_PREFIX, false) } } MemoMute.MEMO_MUTE_PREFIX = MEMO_MUTE_PREFIX MemoMute.MEMO_UNMUTE_PREFIX = MEMO_UNMUTE_PREFIX -MemoMute.PK_HASH_LENGTH = PK_HASH_LENGTH +MemoMute.PK_HASH_LENGTH = MemoStateAction.PK_HASH_LENGTH module.exports = MemoMute // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-29T03:07:20.508Z","module_hash":"33469aed44ef4e9225a9ea952c5cd9335f46a0b1c1298525b3fd51ae2f77f817","functions":[{"id":"func/MemoMute.constructor","name":"MemoMute.constructor","line":35,"end_line":38,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoMute.validate","name":"MemoMute.validate","line":42,"end_line":57,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoMute.mute","name":"MemoMute.mute","line":60,"end_line":62,"hash":"60306e96a5b6ef4bac8cf3fc516785880b2ccab835730019bbd4b9bbfa27e464"},{"id":"func/MemoMute.unmute","name":"MemoMute.unmute","line":65,"end_line":67,"hash":"f6476de3d8b62b3f5e864dc83d005452bc9842d9accfed2c64f57082e0fec7b3"},{"id":"func/MemoMute._broadcastAction","name":"MemoMute._broadcastAction","line":70,"end_line":88,"hash":"05f7860e1db59b7611027186c859d790f8d8c74b150daf3b687a8c9dc453f751"},{"id":"func/MemoMute._toHash160","name":"MemoMute._toHash160","line":92,"end_line":94,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoMute._ensureBchjs","name":"MemoMute._ensureBchjs","line":96,"end_line":100,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoMute.reflect","name":"MemoMute.reflect","line":104,"end_line":109,"hash":"6053ec62558bbc19bd5e55b19541a6fe5cde28fe6fbf7c4eede209652e835bc4"}]} +// {"version":1,"tested_at":"2026-09-04T18:22:55.701Z","module_hash":"05cb4f14173db9584533e58f931d775a2505d844f53bd29724935c3de083c40b","functions":[{"id":"func/muteConfig","name":"muteConfig","line":25,"end_line":35,"hash":"f05b5b03c441735322cdb433417ae5351626ed4c7d1db0a94cbf350f6818a605"},{"id":"func/MemoMute.mute","name":"MemoMute.mute","line":41,"end_line":43,"hash":"ad4c0ae3933e732017a59814bc6b9f6e7d263669f59440f1010209403ce0066f"},{"id":"func/MemoMute.unmute","name":"MemoMute.unmute","line":46,"end_line":48,"hash":"758b2676537ce5861f94ca9efff5b98a946bd82c023964e4d0c40f90d0a1261a"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-state-action.js b/psf-memo-client/src/services/memo-state-action.js new file mode 100644 index 0000000..e80b02e --- /dev/null +++ b/psf-memo-client/src/services/memo-state-action.js @@ -0,0 +1,100 @@ +/* + Shared base for Memo follow/unfollow and mute/unmute (state) actions. + + These actions all validate a cash address, convert it to its 20-byte + hash160, and broadcast an OP_RETURN carrying that hash160 payload with the + protocol prefix for a given state transition. On success they reflect the + new state on an injected profile store. + + Subclasses extend MemoAction's config with the state-specific messages and + codes, plus `reflectMethod` naming which profile store method records the + new state, and expose thin methods (follow/unfollow or mute/unmute) that + delegate to the shared `_setState` helper with the appropriate prefix. + + The wallet and an injected profile store are used so this module stays + testable and free of UI/network concerns; environmentally unsuitable I/O + lives behind those small adapter boundaries. +*/ + +const MemoAction = require('./memo-action') +const { hexToBytes } = require('./hex') + +const PK_HASH_LENGTH = 20 + +class MemoStateAction extends MemoAction { + constructor (deps = {}) { + super(deps) + this.profiles = deps.profiles + } + + // Validate a candidate cash address. Returns { ok: true } or throws a typed + // validation error. + validate (addr) { + if (typeof addr !== 'string' || addr.trim().length === 0) { + const err = new Error(this.emptyMessage) + err.code = this.validationCode + throw err + } + + try { + this._toHash160(addr) + return { ok: true } + } catch (err) { + const validationErr = new Error(`Invalid cash address: ${addr}`) + validationErr.code = this.validationCode + throw validationErr + } + } + + // Internal: validate, broadcast, and reflect a state transition. Subclasses + // delegate their public methods here with the transition's prefix. + async _setState (targetAddr, prefix, state) { + if (!this.wallet) { + throw new Error(this.walletRequiredMsg) + } + this._ensureBchjs() + + this.validate(targetAddr) + + await this.wallet.getUtxos() + + const hash160 = this._toHash160(targetAddr) + const raw = hexToBytes(hash160, PK_HASH_LENGTH, 'Address hash160') + + const txid = await this.wallet.sendOpReturn(raw, prefix) + + this.reflect(txid, targetAddr, state) + + return txid + } + + // Convert a cash address to its 20-byte hash160 hex string using the wallet's + // embedded bch-js. + _toHash160 (addr) { + return this.wallet.bchjs.Address.toHash160(addr) + } + + _ensureBchjs () { + if (!this.wallet.bchjs || typeof this.wallet.bchjs.Address.toHash160 !== 'function') { + throw new Error('Wallet does not expose bch-js Address.toHash160.') + } + } + + // Record the new state on the injected profile store method named by the + // subclass config. + reflect (txid, targetAddr, state) { + const reflectMethod = this.constructor.config.reflectMethod + if (this.profiles && typeof this.profiles[reflectMethod] === 'function') { + const myAddr = this.wallet?.walletInfo?.cashAddress + this.profiles[reflectMethod](myAddr, targetAddr, state) + } + } +} + +MemoStateAction.PK_HASH_LENGTH = PK_HASH_LENGTH + +module.exports = MemoStateAction + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-04T18:22:56.557Z","module_hash":"938e453562e0258b383cbc31f449fccc2e6105c805c97c52fb13bef3ee0fe6b4","functions":[{"id":"func/MemoStateAction.constructor","name":"MemoStateAction.constructor","line":25,"end_line":28,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoStateAction.validate","name":"MemoStateAction.validate","line":32,"end_line":47,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoStateAction._setState","name":"MemoStateAction._setState","line":51,"end_line":69,"hash":"86b976bcbe8532f7bfb9bdc989976f2266bce3d0e8c3a77a1f3e0b8413951684"},{"id":"func/MemoStateAction._toHash160","name":"MemoStateAction._toHash160","line":73,"end_line":75,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoStateAction._ensureBchjs","name":"MemoStateAction._ensureBchjs","line":77,"end_line":81,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoStateAction.reflect","name":"MemoStateAction.reflect","line":85,"end_line":91,"hash":"4ef0ab37e0ab08a681475805c5e53a010b35aa56560bc84f6d2189154ed7fa7d"}]} +// mutate4javascript-manifest-end