feat(quoter): add auto-send quote feature and update settings

feat(spotifyLyrics): remove plugin and associated files

feat(woof): remove Woof plugin and related files

fix(extensions): update User-Agent string for extension installation

fix(supportHelper): update support links to new repository

chore(betterforwardmeta): add initial plugin files and settings

chore(moreUserTags): add user tags plugin with various roles and settings

feat(forceRoleColor): implement role color customization for users
This commit is contained in:
Rayanzay 2025-05-31 14:44:00 +10:00
parent db7447a93d
commit b5fd49a41b
63 changed files with 880 additions and 5570 deletions

View file

@ -58,7 +58,7 @@ window.VencordNative = {
},
updater: {
getRepo: async () => ({ ok: true, value: "https://github.com/Equicord/Equicord" }),
getRepo: async () => ({ ok: true, value: "https://github.com/Rayanzay/ryncord" }),
getUpdates: async () => ({ ok: true, value: [] }),
update: async () => ({ ok: true, value: false }),
rebuild: async () => ({ ok: true, value: true }),

110
pnpm-lock.yaml generated
View file

@ -6,10 +6,10 @@ settings:
patchedDependencies:
'@types/less@3.0.6':
hash: krcufrsfhsuxuoj7hocqugs6zi
hash: 641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076
path: patches/@types__less@3.0.6.patch
eslint@9.20.1:
hash: xm46kqcmdgzlmm4aifkfpxaho4
hash: 4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215
path: patches/eslint@9.20.1.patch
importers:
@ -30,7 +30,7 @@ importers:
version: 0.3.5
'@types/less':
specifier: ^3.0.6
version: 3.0.6(patch_hash=krcufrsfhsuxuoj7hocqugs6zi)
version: 3.0.6(patch_hash=641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076)
'@types/stylus':
specifier: ^0.48.42
version: 0.48.42
@ -79,7 +79,7 @@ importers:
version: 3.2.10
'@stylistic/eslint-plugin':
specifier: ^4.2.0
version: 4.2.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
version: 4.2.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
'@types/chrome':
specifier: ^0.0.312
version: 0.0.312
@ -112,22 +112,22 @@ importers:
version: 0.25.1
eslint:
specifier: 9.20.1
version: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
version: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
eslint-import-resolver-alias:
specifier: ^1.1.2
version: 1.1.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)))
version: 1.1.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)))
eslint-plugin-react:
specifier: ^7.37.3
version: 7.37.4(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))
version: 7.37.4(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))
eslint-plugin-simple-header:
specifier: ^1.2.1
version: 1.2.2(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))
version: 1.2.2(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))
eslint-plugin-simple-import-sort:
specifier: ^12.1.1
version: 12.1.1(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))
version: 12.1.1(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))
eslint-plugin-unused-imports:
specifier: ^4.1.4
version: 4.1.4(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))
version: 4.1.4(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))
highlight.js:
specifier: 11.11.1
version: 11.11.1
@ -169,7 +169,7 @@ importers:
version: 5.8.2
typescript-eslint:
specifier: ^8.28.0
version: 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
version: 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
typescript-transform-paths:
specifier: ^3.5.5
version: 3.5.5(typescript@5.8.2)
@ -2665,9 +2665,9 @@ snapshots:
'@esbuild/win32-x64@0.25.1':
optional: true
'@eslint-community/eslint-utils@4.5.1(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))':
'@eslint-community/eslint-utils@4.5.1(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))':
dependencies:
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
@ -2791,10 +2791,10 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
'@stylistic/eslint-plugin@4.2.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)':
'@stylistic/eslint-plugin@4.2.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)':
dependencies:
'@typescript-eslint/utils': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
'@typescript-eslint/utils': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
eslint-visitor-keys: 4.2.0
espree: 10.3.0
estraverse: 5.3.0
@ -2839,7 +2839,7 @@ snapshots:
dependencies:
'@types/node': 22.13.13
'@types/less@3.0.6(patch_hash=krcufrsfhsuxuoj7hocqugs6zi)': {}
'@types/less@3.0.6(patch_hash=641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076)': {}
'@types/lodash@4.17.15': {}
@ -2901,15 +2901,15 @@ snapshots:
dependencies:
'@types/node': 22.13.13
'@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)':
'@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
'@typescript-eslint/parser': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
'@typescript-eslint/scope-manager': 8.28.0
'@typescript-eslint/type-utils': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
'@typescript-eslint/utils': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
'@typescript-eslint/type-utils': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
'@typescript-eslint/utils': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
'@typescript-eslint/visitor-keys': 8.28.0
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
graphemer: 1.4.0
ignore: 5.3.1
natural-compare: 1.4.0
@ -2918,14 +2918,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)':
'@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)':
dependencies:
'@typescript-eslint/scope-manager': 8.28.0
'@typescript-eslint/types': 8.28.0
'@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2)
'@typescript-eslint/visitor-keys': 8.28.0
debug: 4.4.0
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
typescript: 5.8.2
transitivePeerDependencies:
- supports-color
@ -2935,12 +2935,12 @@ snapshots:
'@typescript-eslint/types': 8.28.0
'@typescript-eslint/visitor-keys': 8.28.0
'@typescript-eslint/type-utils@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)':
'@typescript-eslint/type-utils@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)':
dependencies:
'@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2)
'@typescript-eslint/utils': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
'@typescript-eslint/utils': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
debug: 4.4.0
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
ts-api-utils: 2.1.0(typescript@5.8.2)
typescript: 5.8.2
transitivePeerDependencies:
@ -2962,13 +2962,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)':
'@typescript-eslint/utils@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)':
dependencies:
'@eslint-community/eslint-utils': 4.5.1(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))
'@eslint-community/eslint-utils': 4.5.1(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))
'@typescript-eslint/scope-manager': 8.28.0
'@typescript-eslint/types': 8.28.0
'@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2)
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
typescript: 5.8.2
transitivePeerDependencies:
- supports-color
@ -3564,9 +3564,9 @@ snapshots:
optionalDependencies:
source-map: 0.6.1
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))):
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))):
dependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))
eslint-import-resolver-node@0.3.9:
dependencies:
@ -3576,17 +3576,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
'@typescript-eslint/parser': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
eslint-import-resolver-node: 0.3.9
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@ -3595,9 +3595,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -3609,13 +3609,13 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
'@typescript-eslint/parser': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-react@7.37.4(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)):
eslint-plugin-react@7.37.4(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)):
dependencies:
array-includes: 3.1.8
array.prototype.findlast: 1.2.5
@ -3623,7 +3623,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.1
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@ -3637,19 +3637,19 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-simple-header@1.2.2(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)):
eslint-plugin-simple-header@1.2.2(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)):
dependencies:
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
eslint-plugin-simple-import-sort@12.1.1(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)):
eslint-plugin-simple-import-sort@12.1.1(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)):
dependencies:
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)):
eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)):
dependencies:
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
'@typescript-eslint/eslint-plugin': 8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
eslint-scope@8.3.0:
dependencies:
@ -3660,9 +3660,9 @@ snapshots:
eslint-visitor-keys@4.2.0: {}
eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4):
eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215):
dependencies:
'@eslint-community/eslint-utils': 4.5.1(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))
'@eslint-community/eslint-utils': 4.5.1(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.19.2
'@eslint/core': 0.11.0
@ -5028,12 +5028,12 @@ snapshots:
typed-query-selector@2.12.0: {}
typescript-eslint@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2):
typescript-eslint@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2):
dependencies:
'@typescript-eslint/eslint-plugin': 8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2))(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
'@typescript-eslint/parser': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
'@typescript-eslint/utils': 8.28.0(eslint@9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4))(typescript@5.8.2)
eslint: 9.20.1(patch_hash=xm46kqcmdgzlmm4aifkfpxaho4)
'@typescript-eslint/eslint-plugin': 8.28.0(@typescript-eslint/parser@8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2))(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
'@typescript-eslint/parser': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
'@typescript-eslint/utils': 8.28.0(eslint@9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215))(typescript@5.8.2)
eslint: 9.20.1(patch_hash=4f22e92770bf528b2448fbec0984b9c0761dd588ed0e83dcc41edfc9af711215)
typescript: 5.8.2
transitivePeerDependencies:
- supports-color

View file

@ -62,7 +62,7 @@ async function ensureBinary() {
const res = await fetch(BASE_URL + filename, {
headers: {
"User-Agent": "Equicord (https://github.com/Equicord/Equicord)",
"User-Agent": "Equicord (https://github.com/Rayanzay/ryncord)",
"If-None-Match": etag
}
});

View file

@ -60,7 +60,7 @@ function ContributorModal({ user }: { user: User; }) {
.sort((a, b) => Number(a.required ?? false) - Number(b.required ?? false));
}, [user.id, user.username]);
const ContributedHyperLink = <Link href="https://github.com/Equicord/Equicord">contributed</Link>;
const ContributedHyperLink = <Link href="https://github.com/Vendicated/Vencord">contributed</Link>;
return (
<>

View file

@ -36,8 +36,8 @@ export const settings = definePluginSettings({
});
export default definePlugin({
name: "Anammox",
description: "A microbial process that plays an important part in the nitrogen cycle",
name: "RemoveCrap",
description: "Remove Discord's bloatware from your client.",
authors: [Devs.Kyuuhachi],
settings,

View file

@ -0,0 +1,6 @@
{
"useTabs": false,
"tabWidth": 4,
"semi": true,
"singleQuote": false
}

View file

@ -0,0 +1,99 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 nin0
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { ChannelStore, DateUtils, GuildStore, IconUtils, NavigationRouter, Popout, SelectedGuildStore, SnowflakeUtils, Text, UserStore, useStateFromStores } from "@webpack/common";
import { ArrowSvg, checkForIconExistence, cl, ServerProfileComponent } from "./utils";
export default definePlugin({
name: "BetterForwardMeta",
description: "Access server profile under forwarded messages (if available) and always show time",
authors: [Devs.nin0dev],
ForwardFooter(message: any) {
const { guild_id, channel_id, message_id } = message.message.messageReference;
const guild = useStateFromStores([GuildStore], () => GuildStore.getGuild(guild_id));
const channel = useStateFromStores([ChannelStore], () => ChannelStore.getChannel(channel_id));
return <div className={cl("footer")} >
{
guild_id && <>
{
guild_id !== SelectedGuildStore.getGuildId() && <Popout
position="top"
renderPopout={() => <ServerProfileComponent guildId={guild_id} />}
>
{popoutProps => <div className={cl("footer-element")} {...popoutProps}>
{
checkForIconExistence(guild) && <img src={guild.icon && IconUtils.getGuildIconURL({
id: guild.id,
icon: guild.icon,
canAnimate: true,
size: 32
})} alt={`Server icon for ${guild.name}`} className={cl("guild-icon")} />
}
<Text variant="text-sm/medium" className={cl("footer-text")} style={{
marginLeft: checkForIconExistence(guild) ? "20px" : "0"
}}>{guild ? guild.name : "View server"} </Text>
<ArrowSvg />
</div>
}
</Popout>
}
</>
}
{
channel && <div className={cl("footer-element")} onClick={() => NavigationRouter.transitionTo(`/channels/${guild_id ?? "@me"}/${channel_id}/${message_id}`)} >
<Text variant="text-sm/medium" className={cl("footer-text")}>{(() => {
/*
- Text channel
- Voice channel
- Announcement channel
- Stage channel
- Forum channel
- Media channel
*/
if ([0, 2, 5, 13, 15, 16].includes(channel.type)) return `#${channel.name}`;
// DMs
if (channel.type === 1) return `@${(() => {
const user = UserStore.getUser(channel.recipients[0]);
// @ts-expect-error
return user.globalName || user.username;
})()}`;
// GDMs
if (channel.type === 3) return channel.name || (() => {
const users = channel.recipients.map(r => UserStore.getUser(r));
// @ts-expect-error
return users.map(u => u.globalName || u.username).join(", ");
})();
// Threads
if ([10, 11, 12].includes(channel.type)) return channel.name;
})()}</Text>
<ArrowSvg />
</div>
}
<div className={cl("footer-element")} style={{
pointerEvents: "none"
}}>
<Text variant="text-sm/medium" className={cl("footer-text")}>
{DateUtils.calendarFormat(new Date(SnowflakeUtils.extractTimestamp(message_id)))}
</Text>
</div>
</div>;
},
patches: [
{
find: "originLabel,\" • \"",
replacement: {
match: /(let{message:\i,snapshot:\i,index:\i}=(\i))(.{0,400})return .+TEXT_LOW_CONTRAST}\)]}\)/,
replace: "$1$3return $self.ForwardFooter($2)"
}
}
]
});

View file

@ -0,0 +1,47 @@
.vc-serverprofileforward-footer {
margin-top: 3px;
svg {
width: 12px !important;
}
.vc-serverprofileforward-footer-text {
font-weight: 400;
font-size: 14px;
color: var(--text-low-contrast);
}
.vc-serverprofileforward-guild-icon {
margin-top: 0;
margin-bottom: 0;
position: absolute;
border-radius: 4px;
width: 16px;
}
.vc-serverprofileforward-footer-element {
&:hover {
.vc-serverprofileforward-footer-text {
color: var(--interactive-hover);
}
svg path {
fill: var(--interactive-hover) !important;
}
background-color: var(--background-modifier-hover);
cursor: pointer;
}
&:first-of-type {
margin-left: 0;
}
align-items: center;
align-self: flex-start;
display: inline-flex;
padding: 1px 0;
border-radius: 4px;
margin-left: 4px;
}
}

View file

@ -0,0 +1,20 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { findComponentByCodeLazy } from "@webpack";
import { Guild } from "discord-types/general";
export const ServerProfileComponent = findComponentByCodeLazy("{guildProfile:", "GUILD_PROFILE");
export const cl = classNameFactory("vc-serverprofileforward-");
export const ArrowSvg = () => <svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="var(--text-low-contrast)" d="M9.3 5.3a1 1 0 0 0 0 1.4l5.29 5.3-5.3 5.3a1 1 0 1 0 1.42 1.4l6-6a1 1 0 0 0 0-1.4l-6-6a1 1 0 0 0-1.42 0Z" className=""></path></svg>;
export const checkForIconExistence = (guild: Guild) => {
if (!guild) return false;
if (!guild.icon) return false;
return true;
};

View file

@ -1,150 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Devs, EquicordDevs } from "@utils/constants";
import { closeModal, ModalCloseButton, ModalContent, ModalHeader, ModalRoot, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { Button, Forms, Switch, Text } from "@webpack/common";
// definitely not stolen from glide :P
async function injectCSS() {
var elementToRemove = document.getElementById("DemonstrationStyle");
if (elementToRemove) {
elementToRemove.remove();
}
const styleElement = document.createElement("style");
styleElement.id = "DemonstrationStyle";
const content = await fetch("https://minidiscordthemes.github.io/Demonstration/Demonstration.theme.css").then(e => e.text());
styleElement.textContent = content;
document.documentElement.appendChild(styleElement);
}
const validKeycodes = [
"Backspace", "Tab", "Enter", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Pause", "CapsLock",
"Escape", "Space", "PageUp", "PageDown", "End", "Home", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "PrintScreen", "Insert",
"Delete", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC",
"KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT",
"KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ", "MetaLeft", "MetaRight", "ContextMenu", "Numpad0", "Numpad1", "Numpad2", "Numpad3",
"Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "NumpadMultiply", "NumpadAdd", "NumpadSubtract", "NumpadDecimal",
"NumpadDivide", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "NumLock", "ScrollLock"
];
const settings = definePluginSettings({
keyBind: {
description: "The key to toggle the theme when pressed",
type: OptionType.STRING,
default: "F6",
isValid: (value: string) => {
if (validKeycodes.includes(value)) {
return true;
}
return false;
}
},
soundVolume: {
description: "How loud the toggle sound is (0 to disable)",
type: OptionType.SLIDER,
default: 0.5,
markers: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]
},
showConfirmationModal: {
description: "Show modal to remind shortcut",
type: OptionType.BOOLEAN,
default: true,
}
});
function ToggleModal() {
const value = !settings.use(["showConfirmationModal"]).showConfirmationModal;
return (
<Switch
note="You can re-enable this setting later"
value={value}
onChange={v => { settings.store.showConfirmationModal = !v; }}>
Disable modal?
</Switch>
);
}
function handleToggle() {
const style = document.getElementById("DemonstrationStyle");
if (style != null) {
style.remove();
playSound("https://github.com/Equicord/Equibored/raw/main/sounds/demonstration/wp5rpz.wav");
}
else {
if (settings.store.showConfirmationModal) {
const cl = classNameFactory("vc-demonstration-modal");
const key = openModal(props => (
<ModalRoot {...props}>
<ModalHeader className={cl("header")}>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }} >Demonstration</Text>
<ModalCloseButton onClick={() => closeModal(key)}></ModalCloseButton>
</ModalHeader>
<ModalContent className={cl("content")}>
<Forms.FormText>
This will censor all text! To disable this, remember the shortcut:
</Forms.FormText>
<Text variant="heading-xl/bold" style={{ textAlign: "center", width: "100%", paddingTop: "20px", paddingBottom: "20px" }}>
{settings.store.keyBind}
</Text>
<ToggleModal />
</ModalContent>
<Button
onClick={() => {
closeModal(key);
injectCSS();
playSound("https://github.com/Equicord/Equibored/raw/main/sounds/demonstration/ckz46t.wav");
}}
>Okay!</Button>
</ModalRoot>
));
} else {
injectCSS();
playSound("https://github.com/Equicord/Equibored/raw/main/sounds/demonstration/ckz46t.wav");
}
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.code !== settings.store.keyBind) { return; }
handleToggle();
}
async function playSound(url: string) {
const audio = new Audio(url);
audio.volume = settings.store.soundVolume;
await audio.play().catch(error => {
console.error("Error playing sound:", error);
});
audio.remove();
}
export default definePlugin({
name: "Demonstration",
description: "Plugin for taking theme screenshots - censors identifying images and text.",
authors: [Devs.Samwich, EquicordDevs.Panniku],
settings,
toolboxActions: {
"Toggle Demonstration": (() => handleToggle())
},
settingsAboutComponent: () => {
return (
<>
<Text>To change your keycode, check out <a href="https://www.toptal.com/developers/keycode" target="_blank" rel="noreferrer noopener">this tool</a>!</Text>
</>
);
},
start() {
document.addEventListener("keydown", handleKeydown);
},
stop() {
document.removeEventListener("keydown", handleKeydown);
}
});

View file

@ -0,0 +1,179 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { Text } from "@webpack/common";
import { Common } from "webpack";
type Author = {
nick: string;
colorString: string;
colorStrings: {
primaryColor?: string;
secondaryColor?: string;
tertiaryColor?: string;
};
colorRoleName: string;
guildId?: string;
};
const settings = definePluginSettings({
hint: {
type: OptionType.COMPONENT,
component: function () {
return <Text>
<b>Colors must be in hex (in the format of #XXXXXX) i.e. #ff0000, #123456, etc.</b><br /><br />
Primary: Controls base color<br />
Secondary & Tertiary: Set for gradient
<br /><b>Switch channels for the color to update</b>
<div style={{
margin: "2em 0",
padding: "1em",
backgroundColor: "#e7828430",
border: "1px solid #e78284",
borderRadius: "5px",
color: "var(--text-normal, white)"
}} className="markup__75297">
Gradient role colors require the experiment <code className="inline" style={{ whiteSpace: "nowrap" }}>2025-03_enhanced_role_colors</code> to be enabled!
</div>
</Text>;
}
},
primaryColor: {
type: OptionType.STRING,
description: "",
default: undefined,
placeholder: "#000000"
},
secondaryColor: {
type: OptionType.STRING,
description: "",
default: undefined,
placeholder: "#000000"
},
tertiaryColor: {
type: OptionType.STRING,
description: "",
default: undefined,
placeholder: "#000000"
},
dmsOnly: {
type: OptionType.BOOLEAN,
description: "Applies your color only in DMs",
default: false,
}
});
export default definePlugin({
name: "ForceRoleColor",
description: "Forces a specific role color on yourself globally",
authors: [Devs.surgedevs],
settings,
patches: [
{
find: ".tertiaryColor,roleStyle:\"username\",includeConvenienceGlow:!0",
replacement: [
// Override message author role color
{
match: /(?<=let{author:\i,message:)(\i)(.*?)(?<=colorStrings:\i,colorRoleName:\i}=)(\i)/,
replace: "$1$2$self.getColorsForMessages($1,$3)"
},
// Always enable gradient roles
{
match: /\(0,\i\.\i\)\(null!=\i\?\i:\i,"BaseUsername"\)/,
replace: "true"
}
]
},
{
find: ".name,roleColors:",
replacement: [
// Override member list role color
{
match: /(?<=let{colorRoleName.*?colorString:(\i).*?roleColorStrings:(\i).*?user:(\i).*?}=\i;)/,
replace: "let{colorString:_$1,roleColorStrings:_$2}=$self.getColorsForMemberList($3,$1,$2);$1=_$1;$2=_$2;"
}
]
},
// @TODO: find a better `find` here ??? not sure how stable this is lmao
{
find: ".showThreadPromptOnReply&&",
replacement: [
// Override reply role color, uses getColorsForMessage since the keys are the same
{
match: /(?<=message:(\i).*?colorString:(\i).*?,(\i)=\(0,\i\.\i\)\(\i,\i\)),/,
replace: ";let{colorString:_$2,colorStrings:_$3}=$self.getColorsForMessages($1,{colorString:$2,colorStrings:$3});$2=_$2;$3=_$3;let "
}
]
},
{
find: "memberNameText}),(0,",
replacement: [
// Override color in guild member search
{
match: /(?<=let{member:(\i),user:(\i).*(\i)=\(0,.*?colorStrings\);)/,
replace: "$1=$self.getColorsForMemberSearch($2,$1);$3=$1.colorStrings;"
}
]
}
],
getColorsForMessages(message: any, old: Author): Author {
if (
message.author.id !== Common.UserStore.getCurrentUser().id
|| (settings.store.dmsOnly && old.guildId)
) {
return old;
}
return {
...old,
colorString: settings.store.primaryColor || old?.colorString,
colorStrings: {
primaryColor: settings.store.primaryColor || old?.colorStrings?.primaryColor,
secondaryColor: settings.store.secondaryColor || old?.colorStrings?.secondaryColor,
tertiaryColor: settings.store.tertiaryColor || old?.colorStrings?.tertiaryColor,
}
};
},
getColorsForMemberList(user: any, colorString: string, old: any) {
if (user.id !== Common.UserStore.getCurrentUser().id || settings.store.dmsOnly) {
return {
colorString,
roleColorStrings: old
};
}
return {
colorString: settings.store.primaryColor || colorString,
roleColorStrings: {
primaryColor: settings.store.primaryColor || old?.primaryColor,
secondaryColor: settings.store.secondaryColor || old?.secondaryColor,
tertiaryColor: settings.store.tertiaryColor || old?.tertiaryColor,
}
};
},
getColorsForMemberSearch(user: any, old: any) {
// can just call getColorsForMessages since keys are the same
return this.getColorsForMessages({ author: user }, old);
}
});

View file

@ -1,151 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { Menu, MessageActions, MessageStore, NavigationRouter, Toasts, UserStore } from "@webpack/common";
async function findLastMessageFromUser(channelId: string, userId: string) {
try {
const messageCollection = MessageStore.getMessages(channelId);
let messages = messageCollection?.toArray() || [];
let userMessage = messages.filter(m => m?.author?.id === userId).pop();
if (userMessage) return userMessage.id;
try {
await MessageActions.fetchMessages({
channelId: channelId,
limit: 50
});
const updatedCollection = MessageStore.getMessages(channelId);
messages = updatedCollection?.toArray() || [];
userMessage = messages.filter(m => m?.author?.id === userId).pop();
if (userMessage) return userMessage.id;
} catch (fetchError) {
console.error("Error fetching messages:", fetchError);
}
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Couldn't find any recent messages from this user.",
id: Toasts.genId()
});
return null;
} catch (error) {
console.error("Error finding last message:", error);
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Failed to find messages. Check console for details.",
id: Toasts.genId()
});
return null;
}
}
async function jumpToLastActive(channel: any, targetUserId?: string) {
try {
if (!channel) {
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Channel information not available.",
id: Toasts.genId()
});
return;
}
const guildId = channel.guild_id !== null ? channel.guild_id : "@me";
const channelId = channel.id;
let userId: string;
if (targetUserId) {
userId = targetUserId;
} else {
const currentUser = UserStore.getCurrentUser();
userId = currentUser.id;
}
const messageId = await findLastMessageFromUser(channelId, userId);
if (messageId) {
const url = `/channels/${guildId}/${channelId}/${messageId}`;
NavigationRouter.transitionTo(url);
}
} catch (error) {
console.error("Error in jumpToLastActive:", error);
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Failed to jump to message. Check console for details.",
id: Toasts.genId()
});
}
}
const ChannelContextMenuPatch: NavContextMenuPatchCallback = (children, { channel }) => {
children.push(
<Menu.MenuItem
id="LastActive"
label={<span style={{ color: "#aa6746" }}>Your Last Message</span>}
icon={LastActiveIcon}
action={() => {
jumpToLastActive(channel);
}}
/>
);
};
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user, channel }) => {
if (!channel || !user?.id) return;
children.push(
<Menu.MenuItem
id="LastActive"
label={<span style={{ color: "#aa6746" }}>User's Last Message</span>}
icon={UserLastActiveIcon}
action={() => {
jumpToLastActive(channel, user.id);
}}
/>
);
};
export function UserLastActiveIcon() {
return (
<svg
viewBox="0 0 52 52"
width="20"
height="20"
fill="#aa6746"
>
<g>
<path d="M11.4,21.6L24.9,7.9c0.6-0.6,1.6-0.6,2.2,0l13.5,13.7c0.6,0.6,0.6,1.6,0,2.2L38.4,26
c-0.6,0.6-1.6,0.6-2.2,0l-9.1-9.4c-0.6-0.6-1.6-0.6-2.2,0l-9.1,9.3c-0.6,0.6-1.6,0.6-2.2,0l-2.2-2.2C10.9,23.1,10.9,22.2,11.4,21.6
z"/>
<path d="M11.4,39.7L24.9,26c0.6-0.6,1.6-0.6,2.2,0l13.5,13.7c0.6,0.6,0.6,1.6,0,2.2l-2.2,2.2
c-0.6,0.6-1.6,0.6-2.2,0l-9.1-9.4c-0.6-0.6-1.6-0.6-2.2,0L15.8,44c-0.6,0.6-1.6,0.6-2.2,0l-2.2-2.2C10.9,41.2,10.9,40.2,11.4,39.7z
"/>
</g>
</svg>
);
}
export function LastActiveIcon() {
return (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="#aa6746"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M12,2 C17.5228475,2 22,6.4771525 22,12 C22,17.5228475 17.5228475,22 12,22 C6.4771525,22 2,17.5228475 2,12 C2,6.4771525 6.4771525,2 12,2 Z M12,4 C7.581722,4 4,7.581722 4,12 C4,16.418278 7.581722,20 12,20 C16.418278,20 20,16.418278 20,12 C20,7.581722 16.418278,4 12,4 Z M12,6 C12.5128358,6 12.9355072,6.38604019 12.9932723,6.88337887 L13,7 L13,11.5857864 L14.7071068,13.2928932 C15.0976311,13.6834175 15.0976311,14.3165825 14.7071068,14.7071068 C14.3466228,15.0675907 13.7793918,15.0953203 13.3871006,14.7902954 L13.2928932,14.7071068 L11.2928932,12.7071068 C11.1366129,12.5508265 11.0374017,12.3481451 11.0086724,12.131444 L11,12 L11,7 C11,6.44771525 11.4477153,6 12,6 Z" />
</svg>
);
}
export default definePlugin({
name: "LastActive",
description: "A plugin to jump to last active message from yourself or another user in a channel/server.",
authors: [EquicordDevs.Crxa],
contextMenus: {
"channel-context": ChannelContextMenuPatch,
"user-context": UserContextMenuPatch,
"thread-context": ChannelContextMenuPatch
}
});

View file

@ -1,33 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { addChatBarButton, ChatBarButton, ChatBarButtonFactory, removeChatBarButton } from "@api/ChatButtons";
import { Devs } from "@utils/constants";
import { getCurrentChannel, sendMessage } from "@utils/discord";
import definePlugin from "@utils/types";
async function handleButtonClick() {
// @ts-expect-error typing issue
sendMessage(getCurrentChannel().id, { content: "meow" });
}
const ChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => {
if (!isMainChat) return null;
return (
<ChatBarButton tooltip="Meow" onClick={handleButtonClick}>
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 576 512"><path fill="currentColor" d="M320 192h17.1c22.1 38.3 63.5 64 110.9 64c11 0 21.8-1.4 32-4v228c0 17.7-14.3 32-32 32s-32-14.3-32-32V339.2L280 448h56c17.7 0 32 14.3 32 32s-14.3 32-32 32H192c-53 0-96-43-96-96V192.5c0-16.1-12-29.8-28-31.8l-7.9-1c-17.5-2.2-30-18.2-27.8-35.7S50.5 94 68 96.2l7.9 1c48 6 84.1 46.8 84.1 95.3v85.3c34.4-51.7 93.2-85.8 160-85.8m160 26.5c-10 3.5-20.8 5.5-32 5.5c-28.4 0-54-12.4-71.6-32c-3.7-4.1-7-8.5-9.9-13.2C357.3 164 352 146.6 352 128V10.7C352 4.8 356.7.1 362.6 0h.2c3.3 0 6.4 1.6 8.4 4.2v.1l12.8 17l27.2 36.3L416 64h64l4.8-6.4L512 21.3l12.8-17v-.1c2-2.6 5.1-4.2 8.4-4.2h.2c5.9.1 10.6 4.8 10.6 10.7V128c0 17.3-4.6 33.6-12.6 47.6c-11.3 19.8-29.6 35.2-51.4 42.9M432 128a16 16 0 1 0-32 0a16 16 0 1 0 32 0m48 16a16 16 0 1 0 0-32a16 16 0 1 0 0 32" /></svg>
</ChatBarButton>
);
};
export default definePlugin({
name: "Meow",
description: "Adds a chatbar button to meow in chat",
authors:
[Devs.Samwich],
start: () => addChatBarButton("Meow", ChatBarIcon),
stop: () => removeChatBarButton("Meow")
});

View file

@ -1,44 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Flogger, settings } from ".";
import { addMessageIDB, db, DBMessageStatus, deleteMessagesBulkIDB, getOldestMessagesIDB } from "./db";
import { LoggedMessage, LoggedMessageJSON } from "./types";
import { cleanupMessage } from "./utils";
import { cacheMessageImages } from "./utils/saveImage";
export const addMessage = async (message: LoggedMessage | LoggedMessageJSON, status: DBMessageStatus) => {
if (settings.store.saveImages && status === DBMessageStatus.DELETED)
await cacheMessageImages(message);
const finalMessage = cleanupMessage(message);
await addMessageIDB(finalMessage, status);
if (settings.store.messageLimit > 0) {
const currentMessageCount = await db.count("messages");
if (currentMessageCount > settings.store.messageLimit) {
const messagesToDelete = currentMessageCount - settings.store.messageLimit;
if (messagesToDelete <= 0 || messagesToDelete >= settings.store.messageLimit) return;
const oldestMessages = await getOldestMessagesIDB(messagesToDelete);
Flogger.info(`Deleting ${messagesToDelete} oldest messages`);
await deleteMessagesBulkIDB(oldestMessages.map(m => m.message_id));
}
}
};

View file

@ -1,95 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { classNameFactory } from "@api/Styles";
import { copyWithToast } from "@utils/misc";
import { Button, Forms, Toasts } from "@webpack/common";
import { Native, settings } from "..";
import { DEFAULT_IMAGE_CACHE_DIR } from "../utils/constants";
const cl = classNameFactory("folder-upload");
function createDirSelector(settingKey: "logsDir" | "imageCacheDir", successMessage: string) {
return function DirSelector({ option }) {
if (IS_WEB) return null;
return (
<Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle>
<SelectFolderInput
settingsKey={settingKey}
successMessage={successMessage}
/>
</Forms.FormSection>
);
};
}
export const ImageCacheDir = createDirSelector("imageCacheDir", "Successfully updated Image Cache Dir");
export const LogsDir = createDirSelector("logsDir", "Successfully updated Logs Dir");
interface Props {
settingsKey: "imageCacheDir" | "logsDir",
successMessage: string,
}
export function SelectFolderInput({ settingsKey, successMessage }: Props) {
const path = settings.store[settingsKey];
function getDirName(path: string) {
const parts = path.split("\\").length > 1 ? path.split("\\") : path.split("/");
return parts.slice(parts.length - 2, parts.length).join("\\");
}
async function onFolderSelect() {
try {
const res = await Native.chooseDir(settingsKey);
settings.store[settingsKey] = res;
return Toasts.show({
id: Toasts.genId(),
type: Toasts.Type.SUCCESS,
message: successMessage
});
} catch (err) {
Toasts.show({
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
message: "Failed to update directory"
});
}
}
return (
<div className={cl("-container")}>
<div onClick={() => copyWithToast(path)} className={cl("-input")}>
{path == null || path === DEFAULT_IMAGE_CACHE_DIR ? "Choose Folder" : getDirName(path)}
</div>
<Button
className={cl("-button")}
size={Button.Sizes.SMALL}
onClick={onFolderSelect}
>
Browse
</Button>
</div>
);
}

View file

@ -1,56 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { findComponentByCodeLazy } from "@webpack";
import { openLogModal } from "./LogsModal";
const HeaderBarIcon = findComponentByCodeLazy(".HEADER_BAR_BADGE_TOP:", '.iconBadge,"top"');
export function OpenLogsIcon() {
return (
<svg
stroke="currentColor"
fill="none"
strokeWidth="0"
viewBox="0 0 15 15"
height={24}
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
fill="currentColor"
d="M3 2.5C3 2.22386 3.22386 2 3.5 2H9.08579C9.21839 2 9.34557 2.05268 9.43934 2.14645L11.8536 4.56066C11.9473 4.65443 12 4.78161 12 4.91421V12.5C12 12.7761 11.7761 13 11.5 13H3.5C3.22386 13 3 12.7761 3 12.5V2.5ZM3.5 1C2.67157 1 2 1.67157 2 2.5V12.5C2 13.3284 2.67157 14 3.5 14H11.5C12.3284 14 13 13.3284 13 12.5V4.91421C13 4.51639 12.842 4.13486 12.5607 3.85355L10.1464 1.43934C9.86514 1.15804 9.48361 1 9.08579 1H3.5ZM4.5 4C4.22386 4 4 4.22386 4 4.5C4 4.77614 4.22386 5 4.5 5H7.5C7.77614 5 8 4.77614 8 4.5C8 4.22386 7.77614 4 7.5 4H4.5ZM4.5 7C4.22386 7 4 7.22386 4 7.5C4 7.77614 4.22386 8 4.5 8H10.5C10.7761 8 11 7.77614 11 7.5C11 7.22386 10.7761 7 10.5 7H4.5ZM4.5 10C4.22386 10 4 10.2239 4 10.5C4 10.7761 4.22386 11 4.5 11H10.5C10.7761 11 11 10.7761 11 10.5C11 10.2239 10.7761 10 10.5 10H4.5Z"
>
</path>
</svg>
);
}
export function OpenLogsButton() {
return (
<HeaderBarIcon
className="vc-log-toolbox-btn"
onClick={() => openLogModal()}
tooltip={"Open Logs"}
icon={OpenLogsIcon}
/>
);
}

View file

@ -1,446 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex";
import { InfoIcon } from "@components/Icons";
import { openUserProfile } from "@utils/discord";
import { copyWithToast } from "@utils/misc";
import { closeAllModals, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { LazyComponent } from "@utils/react";
import { find, findByCode, findByCodeLazy } from "@webpack";
import { Alerts, Button, ChannelStore, ContextMenuApi, FluxDispatcher, Menu, NavigationRouter, React, TabBar, Text, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common";
import { User } from "discord-types/general";
import { clearMessagesIDB, DBMessageRecord, deleteMessageIDB, deleteMessagesBulkIDB } from "../db";
import { settings } from "../index";
import { LoggedMessage, LoggedMessageJSON } from "../types";
import { messageJsonToMessageClass } from "../utils";
import { importLogs } from "../utils/settingsUtils";
import { useMessages } from "./hooks";
export interface MessagePreviewProps {
className: string;
author: User;
message: LoggedMessage;
compact: boolean;
isGroupStart: boolean;
hideSimpleEmbedContent: boolean;
childrenAccessories: any;
}
export interface ChildrenAccProops {
channelMessageProps: {
compact: boolean;
channel: any;
message: LoggedMessage;
groupId: string;
id: string;
isLastItem: boolean;
isHighlight: boolean;
renderContentOnly: boolean;
};
hasSpoilerEmbeds: boolean;
isInteracting: boolean;
isAutomodBlockedMessage: boolean;
showClydeAiEmbeds: boolean;
}
const PrivateChannelRecord = findByCodeLazy(".is_message_request_timestamp,");
const MessagePreview = LazyComponent<MessagePreviewProps>(() => find(m => m?.type?.toString().includes("previewLinkTarget:") && !m?.type?.toString().includes("HAS_THREAD")));
const ChildrenAccessories = LazyComponent<ChildrenAccProops>(() => findByCode("channelMessageProps:{message:"));
const cl = classNameFactory("msg-logger-modal-");
export enum LogTabs {
DELETED = "Deleted",
EDITED = "Edited",
GHOST_PING = "Ghost Pinged"
}
interface Props {
modalProps: ModalProps;
initalQuery?: string;
}
export function LogsModal({ modalProps, initalQuery }: Props) {
const [currentTab, setCurrentTab] = useState(LogTabs.DELETED);
const [queryEh, setQuery] = useState(initalQuery ?? "");
const [sortNewest, setSortNewest] = useState(settings.store.sortNewest);
const [numDisplayedMessages, setNumDisplayedMessages] = useState(settings.store.messagesToDisplayAtOnceInLogs);
const contentRef = useRef<HTMLDivElement | null>(null);
const { messages, total, statusTotal, pending, reset } = useMessages(queryEh, currentTab, sortNewest, numDisplayedMessages);
return (
<ModalRoot className={cl("root")} {...modalProps} size={ModalSize.LARGE}>
<ModalHeader className={cl("header")}>
<TextInput value={queryEh} onChange={e => setQuery(e)} style={{ width: "100%" }} placeholder="Filter Messages" />
<TabBar
type="top"
look="brand"
className={cl("tab-bar")}
selectedItem={currentTab}
onItemSelect={e => {
setCurrentTab(e);
setNumDisplayedMessages(settings.store.messagesToDisplayAtOnceInLogs);
contentRef.current?.firstElementChild?.scrollTo(0, 0);
// forceUpdate();
}}
>
<TabBar.Item
className={cl("tab-bar-item")}
id={LogTabs.DELETED}
>
Deleted
</TabBar.Item>
<TabBar.Item
className={cl("tab-bar-item")}
id={LogTabs.EDITED}
>
Edited
</TabBar.Item>
<TabBar.Item
className={cl("tab-bar-item")}
id={LogTabs.GHOST_PING}
>
Ghost Pinged
</TabBar.Item>
</TabBar>
</ModalHeader>
<div style={{ opacity: modalProps.transitionState === 1 ? "1" : "0" }} className={cl("content-container")} ref={contentRef}>
{
modalProps.transitionState === 1 &&
<ModalContent
className={cl("content")}
>
{messages != null && total === 0 && (
<EmptyLogs
hasQuery={queryEh.length !== 0}
reset={reset}
/>
)}
{!pending && messages != null && (
<LogsContentMemo
visibleMessages={messages}
canLoadMore={messages.length < statusTotal && messages.length >= settings.store.messagesToDisplayAtOnceInLogs}
tab={currentTab}
sortNewest={sortNewest}
reset={reset}
handleLoadMore={() => setNumDisplayedMessages(e => e + settings.store.messagesToDisplayAtOnceInLogs)}
/>
)}
</ModalContent>
}
</div>
<ModalFooter>
<Button
color={Button.Colors.RED}
onClick={() => Alerts.show({
title: "Clear Logs",
body: "Are you sure you want to clear all the logs",
confirmText: "Clear",
confirmColor: Button.Colors.RED,
cancelText: "Cancel",
onConfirm: async () => {
await clearMessagesIDB();
reset();
}
})}
>
Clear All Logs
</Button>
<Button
style={{ marginRight: "16px" }}
color={Button.Colors.YELLOW}
disabled={messages?.length === 0}
onClick={() => Alerts.show({
title: "Clear Logs",
body: `Are you sure you want to clear ${messages.length} logs`,
confirmText: "Clear",
confirmColor: Button.Colors.RED,
cancelText: "Cancel",
onConfirm: async () => {
await deleteMessagesBulkIDB(messages.map(e => e.message_id));
reset();
}
})}
>
Clear Visible Logs
</Button>
<Button
look={Button.Looks.LINK}
color={Button.Colors.PRIMARY}
onClick={() => {
setSortNewest(e => {
const val = !e;
settings.store.sortNewest = val;
return val;
});
contentRef.current?.firstElementChild?.scrollTo(0, 0);
}}
>
Sort {sortNewest ? "Oldest First" : "Newest First"}
</Button>
</ModalFooter>
</ModalRoot>
);
}
interface LogContentProps {
sortNewest: boolean;
tab: LogTabs;
visibleMessages: DBMessageRecord[];
canLoadMore: boolean;
reset: () => void;
handleLoadMore: () => void;
}
function LogsContent({ visibleMessages, canLoadMore, sortNewest, tab, reset, handleLoadMore }: LogContentProps) {
if (visibleMessages.length === 0)
return <NoResults tab={tab} />;
return (
<div className={cl("content-inner")}>
{visibleMessages
.map(({ message }, i) => (
<LMessage
key={message.id}
log={{ message }}
reset={reset}
isGroupStart={isGroupStart(message, visibleMessages[i - 1]?.message, sortNewest)}
/>
))}
{
canLoadMore &&
<Button
style={{ marginTop: "1rem", width: "100%" }}
size={Button.Sizes.SMALL} onClick={() => handleLoadMore()}
>
Load More
</Button>
}
</div>
);
}
const LogsContentMemo = LazyComponent(() => React.memo(LogsContent));
function NoResults({ tab }: { tab: LogTabs; }) {
const generateSuggestedTabs = (tab: LogTabs) => {
switch (tab) {
case LogTabs.DELETED:
return { nextTab: LogTabs.EDITED, lastTab: LogTabs.GHOST_PING };
case LogTabs.EDITED:
return { nextTab: LogTabs.GHOST_PING, lastTab: LogTabs.DELETED };
case LogTabs.GHOST_PING:
return { nextTab: LogTabs.DELETED, lastTab: LogTabs.EDITED };
default:
return { nextTab: "", lastTab: "" };
}
};
const { nextTab, lastTab } = generateSuggestedTabs(tab);
return (
<div className={cl("empty-logs", "content-inner")} style={{ textAlign: "center" }}>
<Text variant="text-lg/normal">
No results in <b>{tab}</b>.
</Text>
<Text variant="text-lg/normal" style={{ marginTop: "0.2rem" }}>
Maybe try <b>{nextTab}</b> or <b>{lastTab}</b>
</Text>
</div>
);
}
function EmptyLogs({ hasQuery, reset: forceUpdate }: { hasQuery: boolean; reset: () => void; }) {
return (
<div className={cl("empty-logs", "content-inner")} style={{ textAlign: "center" }}>
<Flex flexDirection="column" style={{ position: "relative" }}>
<Text variant="text-lg/normal">
Empty eh
</Text>
{!hasQuery && (
<>
<Tooltip text="ML Enhanced now stores logs in indexeddb. You need to import your old logs from the logs directory. Importing wont overwrite existing logs">
{({ onMouseEnter, onMouseLeave }) => (
<div
className={cl("info-icon")}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<InfoIcon />
</div>
)}
</Tooltip>
<Button onClick={() => importLogs().then(() => forceUpdate())}>
Import Logs
</Button>
</>
)}
</Flex>
</div>
);
}
interface LMessageProps {
log: { message: LoggedMessageJSON; };
isGroupStart: boolean,
reset: () => void;
}
function LMessage({ log, isGroupStart, reset, }: LMessageProps) {
const message = useMemo(() => messageJsonToMessageClass(log), [log]);
// console.log(message);
if (!message) return null;
return (
<div
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () =>
<Menu.Menu
navId="message-logger"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Message Logger"
>
<Menu.MenuItem
key="jump-to-message"
id="jump-to-message"
label="Jump To Message"
action={() => {
NavigationRouter.transitionTo(`/channels/${ChannelStore.getChannel(message.channel_id)?.guild_id ?? "@me"}/${message.channel_id}${message.id ? "/" + message.id : ""}`);
closeAllModals();
}}
/>
<Menu.MenuItem
key="open-user-profile"
id="open-user-profile"
label="Open user profile"
action={() => {
closeAllModals();
openUserProfile(message.author.id);
}}
/>
<Menu.MenuItem
key="copy-content"
id="copy-content"
label="Copy Content"
action={() => copyWithToast(message.content)}
/>
<Menu.MenuItem
key="copy-user-id"
id="copy-user-id"
label="Copy User ID"
action={() => copyWithToast(message.author.id)}
/>
<Menu.MenuItem
key="copy-message-id"
id="copy-message-id"
label="Copy Message ID"
action={() => copyWithToast(message.id)}
/>
<Menu.MenuItem
key="copy-channel-id"
id="copy-channel-id"
label="Copy Channel ID"
action={() => copyWithToast(message.channel_id)}
/>
{
log.message.guildId != null
&& (
<Menu.MenuItem
key="copy-server-id"
id="copy-server-id"
label="Copy Server ID"
action={() => copyWithToast(log.message.guildId!)}
/>
)
}
<Menu.MenuItem
key="delete-log"
id="delete-log"
label="Delete Log"
color="danger"
action={() =>
deleteMessageIDB(log.message.id).then(() => reset())
}
/>
</Menu.Menu>
);
}}>
<MessagePreview
className={`${cl("msg-preview")} ${message.deleted ? "messagelogger-deleted" : ""}`}
author={message.author}
message={message}
compact={false}
isGroupStart={isGroupStart}
hideSimpleEmbedContent={false}
childrenAccessories={
<ChildrenAccessories
channelMessageProps={{
channel: ChannelStore.getChannel(message.channel_id) || new PrivateChannelRecord({ id: "" }),
message,
compact: false,
groupId: "1",
id: message.id,
isLastItem: false,
isHighlight: false,
renderContentOnly: false,
}}
hasSpoilerEmbeds={false}
isInteracting={false}
showClydeAiEmbeds={true}
isAutomodBlockedMessage={false}
/>
}
/>
</div>
);
}
export const openLogModal = (initalQuery?: string) => openModal(modalProps => <LogsModal modalProps={modalProps} initalQuery={initalQuery} />);
function isGroupStart(
currentMessage: LoggedMessageJSON | undefined,
previousMessage: LoggedMessageJSON | undefined,
sortNewest: boolean
) {
if (!currentMessage || !previousMessage) return true;
if (currentMessage.id === previousMessage.id) return true;
const [newestMessage, oldestMessage] = sortNewest
? [previousMessage, currentMessage]
: [currentMessage, previousMessage];
if (newestMessage.author.id !== oldestMessage.author.id) return true;
const timeDifferenceInMinutes = Math.abs(
(new Date(newestMessage.timestamp)?.getTime() - new Date(oldestMessage.timestamp)?.getTime()) / (1000 * 60)
);
return timeDifferenceInMinutes >= 5;
}

View file

@ -1,109 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { useEffect, useState } from "@webpack/common";
import { countMessagesByStatusIDB, countMessagesIDB, DBMessageRecord, DBMessageStatus, getDateStortedMessagesByStatusIDB } from "../db";
import { doesMatch, tokenizeQuery } from "../utils/parseQuery";
import { LogTabs } from "./LogsModal";
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// this is so shit
export function useMessages(query: string, currentTab: LogTabs, sortNewest: boolean, numDisplayedMessages: number) {
// only for initial load
const [pending, setPending] = useState(true);
const [messages, setMessages] = useState<DBMessageRecord[]>([]);
const [statusTotal, setStatusTotal] = useState<number>(0);
const [total, setTotal] = useState<number>(0);
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
countMessagesIDB().then(x => setTotal(x));
}, [pending]);
useEffect(() => {
let isMounted = true;
const loadMessages = async () => {
const status = getStatus(currentTab);
if (debouncedQuery === "") {
const [messages, statusTotal] = await Promise.all([
getDateStortedMessagesByStatusIDB(sortNewest, numDisplayedMessages, status),
countMessagesByStatusIDB(status),
]);
if (isMounted) {
setMessages(messages);
setStatusTotal(statusTotal);
}
setPending(false);
} else {
const allMessages = await getDateStortedMessagesByStatusIDB(sortNewest, Number.MAX_SAFE_INTEGER, status);
const { queries, rest } = tokenizeQuery(debouncedQuery);
const filteredMessages = allMessages.filter(record => {
for (const query of queries) {
const matching = doesMatch(query.key, query.value, record.message);
if (query.negate ? matching : !matching) {
return false;
}
}
return rest.every(r =>
record.message.content.toLowerCase().includes(r.toLowerCase())
);
});
if (isMounted) {
setMessages(filteredMessages);
setStatusTotal(Number.MAX_SAFE_INTEGER);
}
setPending(false);
}
};
loadMessages();
return () => {
isMounted = false;
};
}, [debouncedQuery, sortNewest, numDisplayedMessages, currentTab, pending]);
return { messages, statusTotal, total, pending, reset: () => setPending(true) };
}
function getStatus(currentTab: LogTabs) {
switch (currentTab) {
case LogTabs.DELETED:
return DBMessageStatus.DELETED;
case LogTabs.EDITED:
return DBMessageStatus.EDITED;
default:
return DBMessageStatus.GHOST_PINGED;
}
}

View file

@ -1,200 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DBSchema, IDBPDatabase, openDB } from "idb";
import { LoggedMessageJSON } from "./types";
import { getMessageStatus } from "./utils";
import { DB_NAME, DB_VERSION } from "./utils/constants";
import { getAttachmentBlobUrl } from "./utils/saveImage";
export enum DBMessageStatus {
DELETED = "DELETED",
EDITED = "EDITED",
GHOST_PINGED = "GHOST_PINGED",
}
export interface DBMessageRecord {
message_id: string;
channel_id: string;
status: DBMessageStatus;
message: LoggedMessageJSON;
}
export interface MLIDB extends DBSchema {
messages: {
key: string;
value: DBMessageRecord;
indexes: {
by_channel_id: string;
by_status: DBMessageStatus;
by_timestamp: string;
by_timestamp_and_message_id: [string, string];
};
};
}
export let db: IDBPDatabase<MLIDB>;
export const cachedMessages = new Map<string, LoggedMessageJSON>();
// this is probably not the best way to do this
async function cacheRecords(records: DBMessageRecord[]) {
for (const r of records) {
cacheRecord(r);
for (const att of r.message.attachments) {
const blobUrl = await getAttachmentBlobUrl(att);
if (blobUrl) {
att.url = blobUrl + "#";
att.proxy_url = blobUrl + "#";
}
}
}
return records;
}
async function cacheRecord(record?: DBMessageRecord | null) {
if (!record) return record;
cachedMessages.set(record.message_id, record.message);
return record;
}
export async function initIDB() {
db = await openDB<MLIDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
const messageStore = db.createObjectStore("messages", { keyPath: "message_id" });
messageStore.createIndex("by_channel_id", "channel_id");
messageStore.createIndex("by_status", "status");
messageStore.createIndex("by_timestamp", "message.timestamp");
messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]);
}
});
}
initIDB();
export async function hasMessageIDB(message_id: string) {
return cachedMessages.has(message_id) || (await db.count("messages", message_id)) > 0;
}
export async function countMessagesIDB() {
return db.count("messages");
}
export async function countMessagesByStatusIDB(status: DBMessageStatus) {
return db.countFromIndex("messages", "by_status", status);
}
export async function getAllMessagesIDB() {
return cacheRecords(await db.getAll("messages"));
}
export async function getMessagesForChannelIDB(channel_id: string) {
return cacheRecords(await db.getAllFromIndex("messages", "by_channel_id", channel_id));
}
export async function getMessageIDB(message_id: string) {
return cacheRecord(await db.get("messages", message_id));
}
export async function getMessagesByStatusIDB(status: DBMessageStatus) {
return cacheRecords(await db.getAllFromIndex("messages", "by_status", status));
}
export async function getOldestMessagesIDB(limit: number) {
return cacheRecords(await db.getAllFromIndex("messages", "by_timestamp", undefined, limit));
}
export async function getDateStortedMessagesByStatusIDB(newest: boolean, limit: number, status: DBMessageStatus) {
const tx = db.transaction("messages", "readonly");
const { store } = tx;
const index = store.index("by_status");
const direction = newest ? "prev" : "next";
const cursor = await index.openCursor(IDBKeyRange.only(status), direction);
if (!cursor) {
console.log("No messages found");
return [];
}
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
if (messages.length >= limit) break;
}
return cacheRecords(messages);
}
export async function getMessagesByChannelAndAfterTimestampIDB(channel_id: string, start: string) {
const tx = db.transaction("messages", "readonly");
const { store } = tx;
const index = store.index("by_timestamp_and_message_id");
const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"]));
if (!cursor) {
console.log("No messages found in range");
return [];
}
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
return cacheRecords(messages);
}
export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessageStatus) {
await db.put("messages", {
channel_id: message.channel_id,
message_id: message.id,
status,
message,
});
cachedMessages.set(message.id, message);
}
export async function addMessagesBulkIDB(messages: LoggedMessageJSON[], status?: DBMessageStatus) {
const tx = db.transaction("messages", "readwrite");
const { store } = tx;
await Promise.all([
...messages.map(message => store.add({
channel_id: message.channel_id,
message_id: message.id,
status: status ?? getMessageStatus(message),
message,
})),
tx.done
]);
messages.forEach(message => cachedMessages.set(message.id, message));
}
export async function deleteMessageIDB(message_id: string) {
await db.delete("messages", message_id);
cachedMessages.delete(message_id);
}
export async function deleteMessagesBulkIDB(message_ids: string[]) {
const tx = db.transaction("messages", "readwrite");
const { store } = tx;
await Promise.all([...message_ids.map(id => store.delete(id)), tx.done]);
message_ids.forEach(id => cachedMessages.delete(id));
}
export async function clearMessagesIDB() {
await db.clear("messages");
cachedMessages.clear();
}

View file

@ -1,403 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export const VERSION = "4.0.0";
export const Native = getNative();
import "./styles.css";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher, MessageStore, React, UserStore } from "@webpack/common";
import { OpenLogsButton } from "./components/LogsButton";
import { openLogModal } from "./components/LogsModal";
import * as idb from "./db";
import { addMessage } from "./LoggedMessageManager";
import * as LoggedMessageManager from "./LoggedMessageManager";
import { settings } from "./settings";
import { FetchMessagesResponse, LoadMessagePayload, LoggedMessage, LoggedMessageJSON, MessageCreatePayload, MessageDeleteBulkPayload, MessageDeletePayload, MessageUpdatePayload } from "./types";
import { cleanUpCachedMessage, cleanupUserObject, getNative, isGhostPinged, mapTimestamp, messageJsonToMessageClass, reAddDeletedMessages } from "./utils";
import { removeContextMenuBindings, setupContextMenuPatches } from "./utils/contextMenu";
import { shouldIgnore } from "./utils/index";
import { LimitedMap } from "./utils/LimitedMap";
import { doesMatch } from "./utils/parseQuery";
import * as imageUtils from "./utils/saveImage";
import * as ImageManager from "./utils/saveImage/ImageManager";
export { settings };
export const Flogger = new Logger("MessageLoggerEnhanced", "#f26c6c");
export const cacheSentMessages = new LimitedMap<string, LoggedMessageJSON>();
const cacheThing = findByPropsLazy("commit", "getOrCreate");
let oldGetMessage: typeof MessageStore.getMessage;
const handledMessageIds = new Set();
async function messageDeleteHandler(payload: MessageDeletePayload & { isBulk: boolean; }) {
if (payload.mlDeleted) {
if (settings.store.permanentlyRemoveLogByDefault)
await idb.deleteMessageIDB(payload.id);
return;
}
if (handledMessageIds.has(payload.id)) {
// Flogger.warn("skipping duplicate message", payload.id);
return;
}
try {
handledMessageIds.add(payload.id);
let message: LoggedMessage | LoggedMessageJSON | null =
oldGetMessage?.(payload.channelId, payload.id);
if (message == null) {
// most likely an edited message
const cachedMessage = cacheSentMessages.get(`${payload.channelId},${payload.id}`);
if (!cachedMessage) return; // Flogger.log("no message to save");
message = { ...cacheSentMessages.get(`${payload.channelId},${payload.id}`), deleted: true } as LoggedMessageJSON;
}
const ghostPinged = isGhostPinged(message as any);
if (
shouldIgnore({
channelId: message?.channel_id ?? payload.channelId,
guildId: payload.guildId ?? (message as any).guildId ?? (message as any).guild_id,
authorId: message?.author?.id,
bot: message?.bot || message?.author?.bot,
flags: message?.flags,
ghostPinged,
isCachedByUs: (message as LoggedMessageJSON).ourCache,
webhookId: message?.webhookId
})
) {
// Flogger.log("IGNORING", message, payload);
return FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
channelId: payload.channelId,
id: payload.id,
mlDeleted: true
});
}
if (message == null || message.channel_id == null || !message.deleted) return;
// Flogger.log("ADDING MESSAGE (DELETED)", message);
if (payload.isBulk)
return message;
await addMessage(message, ghostPinged ? idb.DBMessageStatus.GHOST_PINGED : idb.DBMessageStatus.DELETED);
}
finally {
handledMessageIds.delete(payload.id);
}
}
async function messageDeleteBulkHandler({ channelId, guildId, ids }: MessageDeleteBulkPayload) {
// is this bad? idk man
const messages = [] as LoggedMessageJSON[];
for (const id of ids) {
const msg = await messageDeleteHandler({ type: "MESSAGE_DELETE", channelId, guildId, id, isBulk: true });
if (msg) messages.push(msg as LoggedMessageJSON);
}
await idb.addMessagesBulkIDB(messages);
}
async function messageUpdateHandler(payload: MessageUpdatePayload) {
const cachedMessage = cacheSentMessages.get(`${payload.message.channel_id},${payload.message.id}`);
if (
shouldIgnore({
channelId: payload.message?.channel_id,
guildId: payload.guildId ?? (payload as any).guild_id,
authorId: payload.message?.author?.id,
bot: (payload.message?.author as any)?.bot,
flags: payload.message?.flags,
ghostPinged: isGhostPinged(payload.message as any),
isCachedByUs: cachedMessage?.ourCache ?? false
})
) {
const cache = cacheThing.getOrCreate(payload.message.channel_id);
const message = cache.get(payload.message.id);
if (message) {
message.editHistory = [];
cacheThing.commit(cache);
}
return;// Flogger.log("this message has been ignored", payload);
}
let message = oldGetMessage?.(payload.message.channel_id, payload.message.id) as LoggedMessage | LoggedMessageJSON | null;
if (message == null) {
// MESSAGE_UPDATE gets dispatched when emebeds change too and content becomes null
if (cachedMessage != null && payload.message.content != null && cachedMessage.content !== payload.message.content) {
message = {
...cachedMessage,
content: payload.message.content,
editHistory: [
...(cachedMessage.editHistory ?? []),
{
content: cachedMessage.content,
timestamp: (new Date()).toISOString()
}
]
};
cacheSentMessages.set(`${payload.message.channel_id},${payload.message.id}`, message);
}
}
if (message == null || message.channel_id == null || message.editHistory == null || message.editHistory.length === 0) return;
// Flogger.log("ADDING MESSAGE (EDITED)", message, payload);
await addMessage(message, idb.DBMessageStatus.EDITED);
}
function messageCreateHandler(payload: MessageCreatePayload) {
// we do this here because cache is limited and to save memory
if (!settings.store.cacheMessagesFromServers && payload.guildId != null) {
const ids = [payload.channelId, payload.message?.author?.id, payload.guildId];
const isWhitelisted =
settings.store.whitelistedIds
.split(",")
.some(e => ids.includes(e));
if (!isWhitelisted) {
return; // dont cache messages from servers when cacheMessagesFromServers is disabled and not whitelisted.
}
}
cacheSentMessages.set(`${payload.message.channel_id},${payload.message.id}`, cleanUpCachedMessage(payload.message));
// Flogger.log(`cached\nkey:${payload.message.channel_id},${payload.message.id}\nvalue:`, payload.message);
}
async function processMessageFetch(response: FetchMessagesResponse) {
try {
if (!response.ok || response.body.length === 0) {
Flogger.error("Failed to fetch messages", response);
return;
}
const firstMessage = response.body[response.body.length - 1];
// console.time("fetching messages from idb");
const messages = await idb.getMessagesByChannelAndAfterTimestampIDB(firstMessage.channel_id, firstMessage.timestamp);
// console.timeEnd("fetching messages from idb");
if (!messages.length) return;
const deletedMessages = messages.filter(m => m.status === idb.DBMessageStatus.DELETED);
for (const recivedMessage of response.body) {
const record = messages.find(m => m.message_id === recivedMessage.id);
if (record == null) continue;
if (record.message.editHistory && record.message.editHistory.length > 0) {
recivedMessage.editHistory = record.message.editHistory;
}
}
const fetchUser = (id: string) => UserStore.getUser(id) || response.body.find(e => e.author.id === id);
for (let i = 0, len = messages.length; i < len; i++) {
const record = messages[i];
if (!record) continue;
const { message } = record;
for (let j = 0, len2 = message.mentions.length; j < len2; j++) {
const user = message.mentions[j];
const cachedUser = fetchUser((user as any).id || user);
if (cachedUser) (message.mentions[j] as any) = cleanupUserObject(cachedUser);
}
const author = fetchUser(message.author.id);
if (!author) continue;
(message.author as any) = cleanupUserObject(author);
}
response.body.extra = deletedMessages.map(m => m.message);
} catch (e) {
Flogger.error("Failed to fetch messages", e);
}
}
export default definePlugin({
name: "MessageLoggerEnhanced",
authors: [Devs.Aria],
description: "G'day",
dependencies: ["MessageLogger"],
patches: [
{
find: "_tryFetchMessagesCached",
replacement: [
{
match: /(?<=\.get\({url.+?then\()(\i)=>\(/,
replace: "async $1=>(await $self.processMessageFetch($1),"
},
{
match: /(?<=type:"LOAD_MESSAGES_SUCCESS",.{1,100})messages:(\i)/,
replace: "get messages() {return $self.coolReAddDeletedMessages($1, this);}"
}
]
},
{
find: "THREAD_STARTER_MESSAGE?null==",
replacement: {
match: /deleted:\i\.deleted, editHistory:\i\.editHistory,/,
replace: "deleted:$self.getDeleted(...arguments), editHistory:$self.getEdited(...arguments),"
}
},
{
find: "toolbar:function",
predicate: () => settings.store.ShowLogsButton,
replacement: {
match: /(function \i\(\i\){)(.{1,200}toolbar.{1,100}mobileToolbar)/,
replace: "$1$self.addIconToToolBar(arguments[0]);$2"
}
},
{
find: "childrenMessageContent:null",
replacement: {
match: /(cozyMessage.{1,50},)childrenHeader:/,
replace: "$1childrenAccessories:arguments[0].childrenAccessories || null,childrenHeader:"
}
},
// https://regex101.com/r/S3IVGm/1
// fix vidoes failing because there are no thumbnails
{
find: ".handleImageLoad)",
replacement: {
match: /(componentDidMount\(\){)(.{1,150}===(.+?)\.LOADING)/,
replace:
"$1if(this.props?.src?.startsWith('blob:') && this.props?.item?.type === 'VIDEO')" +
"return this.setState({readyState: $3.READY});$2"
}
},
// dont fetch messages for channels in modal
{
find: "Using PollReferenceMessageContext without",
replacement: {
match: /(?:\i\.)?\i\.(?:default\.)?focusMessage\(/,
replace: "!(arguments[0]?.message?.deleted || arguments[0]?.message?.editHistory?.length > 0) && $&"
}
},
// only check for expired attachments if the message is not deleted
{
find: "\"/ephemeral-attachments/\"",
replacement: {
match: /\i\.attachments\.some\(\i\)\|\|\i\.embeds\.some/,
replace: "!arguments[0].deleted && $&"
}
}
],
settings,
toolboxActions: {
"Message Logger"() {
openLogModal();
}
},
addIconToToolBar(e: { toolbar: React.ReactNode[] | React.ReactNode; }) {
if (Array.isArray(e.toolbar))
return e.toolbar.push(
<ErrorBoundary noop={true}>
<OpenLogsButton />
</ErrorBoundary>
);
e.toolbar = [
<ErrorBoundary noop={true} key={"MessageLoggerEnhanced"} >
<OpenLogsButton />
</ErrorBoundary>,
e.toolbar,
];
},
processMessageFetch,
openLogModal,
doesMatch,
reAddDeletedMessages,
LoggedMessageManager,
ImageManager,
imageUtils,
idb,
coolReAddDeletedMessages: (messages: LoggedMessageJSON[] & { extra: LoggedMessageJSON[]; }, payload: LoadMessagePayload) => {
try {
if (messages.extra)
reAddDeletedMessages(messages, messages.extra, !payload.hasMoreAfter && !payload.isBefore, !payload.hasMoreBefore && !payload.isAfter);
}
catch (e) {
Flogger.error("Failed to re-add deleted messages", e);
}
finally {
return messages;
}
},
isDeletedMessage: (id: string) => cacheSentMessages.get(id)?.deleted ?? false,
getDeleted(m1, m2) {
const deleted = m2?.deleted;
if (deleted == null && m1?.deleted != null) return m1.deleted;
return deleted;
},
getEdited(m1, m2) {
const editHistory = m2?.editHistory;
if (editHistory == null && m1?.editHistory != null && m1.editHistory.length > 0)
return m1.editHistory.map(mapTimestamp);
return editHistory;
},
flux: {
"MESSAGE_DELETE": messageDeleteHandler as any,
"MESSAGE_DELETE_BULK": messageDeleteBulkHandler,
"MESSAGE_UPDATE": messageUpdateHandler,
"MESSAGE_CREATE": messageCreateHandler
},
async start() {
this.oldGetMessage = oldGetMessage = MessageStore.getMessage;
// we have to do this because the original message logger fetches the message from the store now
MessageStore.getMessage = (channelId: string, messageId: string) => {
const MLMessage = idb.cachedMessages.get(messageId);
if (MLMessage) return messageJsonToMessageClass({ message: MLMessage });
return this.oldGetMessage(channelId, messageId);
};
Native.init();
const { imageCacheDir, logsDir } = await Native.getSettings();
settings.store.imageCacheDir = imageCacheDir;
settings.store.logsDir = logsDir;
setupContextMenuPatches();
},
stop() {
removeContextMenuBindings();
MessageStore.getMessage = this.oldGetMessage;
}
});

View file

@ -1,200 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { DATA_DIR } from "@main/utils/constants";
import { dialog, IpcMainInvokeEvent, shell } from "electron";
import { getSettings, saveSettings } from "./settings";
export * from "./updater";
import { LoggedAttachment } from "../types";
import { LOGS_DATA_FILENAME } from "../utils/constants";
import { ensureDirectoryExists, getAttachmentIdFromFilename, sleep } from "./utils";
export { getSettings };
// so we can filter the native helpers by this key
export function messageLoggerEnhancedUniqueIdThingyIdkMan() { }
// Map<attachmetId, path>()
const nativeSavedImages = new Map<string, string>();
export const getNativeSavedImages = () => nativeSavedImages;
let logsDir: string;
let imageCacheDir: string;
const getImageCacheDir = async () => imageCacheDir ?? await getDefaultNativeImageDir();
const getLogsDir = async () => logsDir ?? await getDefaultNativeDataDir();
export async function initDirs() {
const { logsDir: ld, imageCacheDir: icd } = await getSettings();
logsDir = ld || await getDefaultNativeDataDir();
imageCacheDir = icd || await getDefaultNativeImageDir();
}
initDirs();
export async function init(_event: IpcMainInvokeEvent) {
const imageDir = await getImageCacheDir();
await ensureDirectoryExists(imageDir);
const files = await readdir(imageDir);
for (const filename of files) {
const attachmentId = getAttachmentIdFromFilename(filename);
nativeSavedImages.set(attachmentId, path.join(imageDir, filename));
}
}
export async function getImageNative(_event: IpcMainInvokeEvent, attachmentId: string): Promise<Uint8Array | Buffer | null> {
const imagePath = nativeSavedImages.get(attachmentId);
if (!imagePath) return null;
try {
return await readFile(imagePath);
} catch (error: any) {
console.error(error);
return null;
}
}
export async function writeImageNative(_event: IpcMainInvokeEvent, filename: string, content: Uint8Array) {
if (!filename || !content) return;
const imageDir = await getImageCacheDir();
// returns the file name
// ../../someMalicousPath.png -> someMalicousPath
const attachmentId = getAttachmentIdFromFilename(filename);
const existingImage = nativeSavedImages.get(attachmentId);
if (existingImage) return;
const imagePath = path.join(imageDir, filename);
await ensureDirectoryExists(imageDir);
await writeFile(imagePath, content);
nativeSavedImages.set(attachmentId, imagePath);
}
export async function deleteFileNative(_event: IpcMainInvokeEvent, attachmentId: string) {
const imagePath = nativeSavedImages.get(attachmentId);
if (!imagePath) return;
await unlink(imagePath);
}
export async function writeLogs(_event: IpcMainInvokeEvent, contents: string) {
const logsDir = await getLogsDir();
writeFile(path.join(logsDir, LOGS_DATA_FILENAME), contents);
}
export async function getDefaultNativeImageDir(): Promise<string> {
return path.join(await getDefaultNativeDataDir(), "savedImages");
}
export async function getDefaultNativeDataDir(): Promise<string> {
return path.join(DATA_DIR, "MessageLoggerData");
}
export async function chooseDir(event: IpcMainInvokeEvent, logKey: "logsDir" | "imageCacheDir") {
const settings = await getSettings();
const defaultPath = settings[logKey] || await getDefaultNativeDataDir();
const res = await dialog.showOpenDialog({ properties: ["openDirectory"], defaultPath: defaultPath });
const dir = res.filePaths[0];
if (!dir) throw Error("Invalid Directory");
settings[logKey] = dir;
await saveSettings(settings);
switch (logKey) {
case "logsDir": logsDir = dir; break;
case "imageCacheDir": imageCacheDir = dir; break;
}
if (logKey === "imageCacheDir")
await init(event);
return dir;
}
export async function showItemInFolder(_event: IpcMainInvokeEvent, filePath: string) {
shell.showItemInFolder(filePath);
}
export async function chooseFile(_event: IpcMainInvokeEvent, title: string, filters: Electron.FileFilter[], defaultPath?: string) {
const res = await dialog.showOpenDialog({ title, filters, properties: ["openFile"], defaultPath });
const [path] = res.filePaths;
if (!path) throw Error("Invalid file");
return await readFile(path, "utf-8");
}
// doing it in native because you can only fetch images from the renderer
// other types of files will cause cors issues
export async function downloadAttachment(_event: IpcMainInvokeEvent, attachemnt: LoggedAttachment, attempts = 0, useOldUrl = false): Promise<{ error: string | null; path: string | null; }> {
try {
if (!attachemnt?.url || !attachemnt.oldUrl || !attachemnt?.id || !attachemnt?.fileExtension)
return { error: "Invalid Attachment", path: null };
if (attachemnt.id.match(/[\\/.]/)) {
return { error: "Invalid Attachment ID", path: null };
}
const existingImage = nativeSavedImages.get(attachemnt.id);
if (existingImage)
return {
error: null,
path: existingImage
};
const res = await fetch(useOldUrl ? attachemnt.oldUrl : attachemnt.url);
if (res.status !== 200) {
if (res.status === 404 || res.status === 403 || res.status === 415)
useOldUrl = true;
attempts++;
if (attempts > 3) {
return {
error: `Failed to get attachment ${attachemnt.id} for caching. too many attempts, error code ${res.status}`,
path: null,
};
}
await sleep(1000);
return downloadAttachment(_event, attachemnt, attempts, useOldUrl);
}
const ab = await res.arrayBuffer();
const imageCacheDir = await getImageCacheDir();
await ensureDirectoryExists(imageCacheDir);
const finalPath = path.join(imageCacheDir, `${attachemnt.id}${attachemnt.fileExtension}`);
await writeFile(finalPath, Buffer.from(ab));
nativeSavedImages.set(attachemnt.id, finalPath);
return {
error: null,
path: finalPath
};
} catch (error: any) {
console.error(error);
return { error: error.message, path: null };
}
}

View file

@ -1,49 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import fs from "fs/promises";
import path from "path";
import { getDefaultNativeDataDir, getDefaultNativeImageDir } from ".";
import { ensureDirectoryExists } from "./utils";
interface MLSettings {
logsDir: string;
imageCacheDir: string;
}
export async function getSettings(): Promise<MLSettings> {
try {
const settings = await fs.readFile(await getSettingsFilePath(), "utf8");
return JSON.parse(settings);
} catch (err) {
// probably doesnt exist
// time to create it
const settings = {
logsDir: await getDefaultNativeDataDir(),
imageCacheDir: await getDefaultNativeImageDir(),
};
try {
await saveSettings(settings);
} catch (err) { }
return settings;
}
}
// dont expose this to renderer future me
export async function saveSettings(settings: MLSettings) {
if (!settings) return;
await fs.writeFile(await getSettingsFilePath(), JSON.stringify(settings, null, 4), "utf8");
}
async function getSettingsFilePath() {
// mlSettings.json will always in that folder
const MlDataDir = await getDefaultNativeDataDir();
await ensureDirectoryExists(MlDataDir);
const mlSettingsDir = path.join(MlDataDir, "mlSettings.json");
return mlSettingsDir;
}

View file

@ -1,134 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { execFile as cpExecFile, ExecFileOptions } from "node:child_process";
import { readdir } from "fs/promises";
import { join } from "path";
import { promisify } from "util";
import type { GitResult } from "../types";
import { memoize } from "../utils/memoize";
const execFile = promisify(cpExecFile);
const isFlatpak = process.platform === "linux" && Boolean(process.env.FLATPAK_ID?.includes("discordapp") || process.env.FLATPAK_ID?.includes("Discord"));
if (process.platform === "darwin") process.env.PATH = `/usr/local/bin:${process.env.PATH}`;
const VENCORD_USER_PLUGIN_DIR = join(__dirname, "..", "src", "userplugins");
const getCwd = memoize(async () => {
const dirs = await readdir(VENCORD_USER_PLUGIN_DIR, { withFileTypes: true });
for (const dir of dirs) {
if (!dir.isDirectory()) continue;
const pluginDir = join(VENCORD_USER_PLUGIN_DIR, dir.name);
const files = await readdir(pluginDir);
if (files.includes("LoggedMessageManager.ts")) return join(VENCORD_USER_PLUGIN_DIR, dir.name);
}
return;
});
async function git(...args: string[]): Promise<GitResult> {
const opts: ExecFileOptions = { cwd: await getCwd(), shell: true };
try {
let result;
if (isFlatpak) {
result = await execFile("flatpak-spawn", ["--host", "git", ...args], opts);
} else {
result = await execFile("git", args, opts);
}
return { value: result.stdout.trim(), stderr: result.stderr, ok: true };
} catch (error: any) {
return {
ok: false,
cmd: error.cmd as string,
message: error.stderr as string,
error
};
}
}
export async function update() {
return await git("pull");
}
export async function getCommitHash() {
return await git("rev-parse", "HEAD");
}
export interface GitInfo {
repo: string;
gitHash: string;
}
export async function getRepoInfo(): Promise<GitResult> {
const res = await git("remote", "get-url", "origin");
if (!res.ok) {
return res;
}
const gitHash = await getCommitHash();
if (!gitHash.ok) {
return gitHash;
}
return {
ok: true,
value: {
repo: res.value
.replace(/git@(.+):/, "https://$1/")
.replace(/\.git$/, ""),
gitHash: gitHash.value
}
};
}
export interface Commit {
hash: string;
longHash: string;
message: string;
author: string;
}
export async function getNewCommits(): Promise<GitResult> {
const branch = await git("branch", "--show-current");
if (!branch.ok) {
return branch;
}
const logFormat = "%H;%an;%s";
const branchRange = `HEAD..origin/${branch.value}`;
try {
await git("fetch");
const logOutput = await git("log", `--format="${logFormat}"`, branchRange);
if (!logOutput.ok) {
return logOutput;
}
if (logOutput.value.trim() === "") {
return { ok: true, value: [] };
}
const commitLines = logOutput.value.trim().split("\n");
const commits: Commit[] = commitLines.map(line => {
const [hash, author, ...rest] = line.split(";");
return { longHash: hash, hash: hash.slice(0, 7), author, message: rest.join(";") } satisfies Commit;
});
return { ok: true, value: commits };
} catch (error: any) {
return { ok: false, cmd: error.cmd, message: error.message, error };
}
}

View file

@ -1,28 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { access, mkdir } from "fs/promises";
import path from "path";
export async function exists(filename: string) {
try {
await access(filename);
return true;
} catch (error) {
return false;
}
}
export async function ensureDirectoryExists(cacheDir: string) {
if (!await exists(cacheDir))
await mkdir(cacheDir);
}
export function getAttachmentIdFromFilename(filename: string) {
return path.parse(filename).name;
}
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

View file

@ -1,242 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { OptionType } from "@utils/types";
import { Alerts, Button } from "@webpack/common";
import { Settings } from "Vencord";
import { Native } from ".";
import { ImageCacheDir, LogsDir } from "./components/FolderSelectInput";
import { openLogModal } from "./components/LogsModal";
import { clearMessagesIDB } from "./db";
import { DEFAULT_IMAGE_CACHE_DIR } from "./utils/constants";
import { exportLogs, importLogs } from "./utils/settingsUtils";
export const settings = definePluginSettings({
saveMessages: {
default: true,
type: OptionType.BOOLEAN,
description: "Wether to save the deleted and edited messages.",
},
saveImages: {
type: OptionType.BOOLEAN,
description: "Save deleted attachments.",
default: false
},
sortNewest: {
default: true,
type: OptionType.BOOLEAN,
description: "Sort logs by newest.",
},
cacheMessagesFromServers: {
default: false,
type: OptionType.BOOLEAN,
description: "Usually message logger only logs from whitelisted ids and dms, enabling this would mean it would log messages from all servers as well. Note that this may cause the cache to exceed its limit, resulting in some messages being missed. If you are in a lot of servers, this may significantly increase the chances of messages being logged, which can result in a large message record and the inclusion of irrelevant messages.",
},
ignoreBots: {
type: OptionType.BOOLEAN,
description: "Whether to ignore messages by bots",
default: false,
onChange() {
// we will be handling the ignoreBots now (enabled or not) so the original messageLogger shouldnt
Settings.plugins.MessageLogger.ignoreBots = false;
}
},
ignoreWebhooks: {
type: OptionType.BOOLEAN,
description: "Whether to ignore messages by webhooks",
default: false,
},
ignoreSelf: {
type: OptionType.BOOLEAN,
description: "Whether to ignore messages by yourself",
default: false,
onChange() {
Settings.plugins.MessageLogger.ignoreSelf = false;
}
},
ignoreMutedGuilds: {
default: false,
type: OptionType.BOOLEAN,
description: "Messages in muted guilds will not be logged. Whitelisted users/channels in muted guilds will still be logged."
},
ignoreMutedCategories: {
default: false,
type: OptionType.BOOLEAN,
description: "Messages in channels belonging to muted categories will not be logged. Whitelisted users/channels in muted guilds will still be logged."
},
ignoreMutedChannels: {
default: false,
type: OptionType.BOOLEAN,
description: "Messages in muted channels will not be logged. Whitelisted users/channels in muted guilds will still be logged."
},
alwaysLogDirectMessages: {
default: true,
type: OptionType.BOOLEAN,
description: "Always log DMs",
},
alwaysLogCurrentChannel: {
default: true,
type: OptionType.BOOLEAN,
description: "Always log current selected channel. Blacklisted channels/users will still be ignored.",
},
permanentlyRemoveLogByDefault: {
default: false,
type: OptionType.BOOLEAN,
description: "Vencord's base MessageLogger remove log button wiil delete logs permanently",
},
hideMessageFromMessageLoggers: {
default: false,
type: OptionType.BOOLEAN,
description: "When enabled, a context menu button will be added to messages to allow you to delete messages without them being logged by other loggers. Might not be safe, use at your own risk."
},
ShowLogsButton: {
default: true,
type: OptionType.BOOLEAN,
description: "Toggle to whenever show the toolbox or not",
restartNeeded: true,
},
messagesToDisplayAtOnceInLogs: {
default: 100,
type: OptionType.NUMBER,
description: "Number of messages to display at once in logs & number of messages to load when loading more messages in logs.",
},
hideMessageFromMessageLoggersDeletedMessage: {
default: "redacted eh",
type: OptionType.STRING,
description: "The message content to replace the message with when using the hide message from message loggers feature.",
},
messageLimit: {
default: 200,
type: OptionType.NUMBER,
description: "Maximum number of messages to save. Older messages are deleted when the limit is reached. 0 means there is no limit"
},
attachmentSizeLimitInMegabytes: {
default: 12,
type: OptionType.NUMBER,
description: "Maximum size of an attachment in megabytes to save. Attachments larger than this size will not be saved."
},
attachmentFileExtensions: {
default: "png,jpg,jpeg,gif,webp,mp4,webm,mp3,ogg,wav",
type: OptionType.STRING,
description: "Comma separated list of file extensions to save. Attachments with file extensions not in this list will not be saved. Leave empty to save all attachments."
},
cacheLimit: {
default: 1000,
type: OptionType.NUMBER,
description: "Maximum number of messages to store in the cache. Older messages are deleted when the limit is reached. This helps reduce memory usage and improve performance. 0 means there is no limit",
},
whitelistedIds: {
default: "",
type: OptionType.STRING,
description: "Whitelisted server, channel, or user IDs."
},
blacklistedIds: {
default: "",
type: OptionType.STRING,
description: "Blacklisted server, channel, or user IDs."
},
imageCacheDir: {
type: OptionType.COMPONENT,
description: "Select saved images directory",
component: ErrorBoundary.wrap(ImageCacheDir) as any
},
logsDir: {
type: OptionType.COMPONENT,
description: "Select logs directory",
component: ErrorBoundary.wrap(LogsDir) as any
},
importLogs: {
type: OptionType.COMPONENT,
description: "Import Logs From File",
component: () =>
<Button onClick={importLogs}>
Import Logs
</Button>
},
exportLogs: {
type: OptionType.COMPONENT,
description: "Export Logs From IndexedDB",
component: () =>
<Button onClick={exportLogs}>
Export Logs
</Button>
},
openLogs: {
type: OptionType.COMPONENT,
description: "Open Logs",
component: () =>
<Button onClick={() => openLogModal()}>
Open Logs
</Button>
},
openImageCacheFolder: {
type: OptionType.COMPONENT,
description: "Opens the image cache directory",
component: () =>
<Button
disabled={
IS_WEB
|| settings.store.imageCacheDir == null
|| settings.store.imageCacheDir === DEFAULT_IMAGE_CACHE_DIR
}
onClick={() => Native.showItemInFolder(settings.store.imageCacheDir)}
>
Open Image Cache Folder
</Button>
},
clearLogs: {
type: OptionType.COMPONENT,
description: "Clear Logs",
component: () =>
<Button
color={Button.Colors.RED}
onClick={() => Alerts.show({
title: "Clear Logs",
body: "Are you sure you want to clear all logs?",
confirmColor: Button.Colors.RED,
confirmText: "Clear",
cancelText: "Cancel",
onConfirm: () => {
clearMessagesIDB();
},
})}
>
Clear Logs
</Button>
},
});

View file

@ -1,85 +0,0 @@
.msg-logger-modal-root {
max-height: 80vh;
min-height: 80vh;
min-width: 85vw;
max-width: 85vw;
}
.msg-logger-modal-empty-logs {
display: grid;
place-content: center;
height: 100%;
}
.msg-logger-modal-info-icon {
position: absolute;
top: -24px;
right: -24px;
color: var(--interactive-normal);
cursor: pointer;
}
.msg-logger-modal-header {
flex-direction: column;
/* width: 100%; */
}
.msg-logger-modal-content-container {
/* max-height: 80vh; */
overflow: hidden;
height: 100%;
transition: opacity 100ms ease-in;
}
.msg-logger-modal-content {
padding-bottom: 20px;
height: 100%;
}
.msg-logger-modal-content-inner {
padding-top: 1rem;
padding-bottom: 1rem;
height: 100%;
}
.msg-logger-modal-header > div:has(input) {
width: 100%;
}
.msg-logger-modal-tab-bar-item {
padding-bottom: 16px;
margin-bottom: -2px;
}
.msg-logger-modal-tab-bar {
margin-top: 20px;
width: 100%;
justify-content: space-around;
}
.vc-log-toolbox-btn svg {
color: var(--interactive-normal);
}
:is(.vc-log-toolbox-btn:hover, .vc-log-toolbox-btn[class*="selected"]) svg {
color: var(--interactive-active);
}
.folder-upload-container {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--background-secondary);
}
.folder-upload-input {
cursor: pointer;
padding: 10px 0 10px 10px;
color: var(--header-secondary);
}
.folder-upload-button {
margin: 6px;
padding: 4px 8px;
}

View file

@ -1,146 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Message, MessageAttachment, MessageJSON } from "discord-types/general";
export interface LoggedAttachment extends MessageAttachment {
fileExtension?: string | null;
path?: string | null;
blobUrl?: string;
nativefileSystem?: boolean;
oldUrl?: string;
oldProxyUrl?: string;
}
export type RefrencedMessage = LoggedMessageJSON & { message_id: string; };
export interface LoggedMessageJSON extends Omit<LoggedMessage, "timestamp"> {
mention_everyone?: string;
guildId?: string;
guild_id?: string;
ghostPinged?: boolean;
timestamp: string;
ourCache?: boolean;
referenced_message: RefrencedMessage;
message_reference: RefrencedMessage;
}
export interface LoggedMessage extends Message {
attachments: LoggedAttachment[];
deleted?: boolean;
deletedTimestamp?: string;
editHistory?: {
timestamp: string;
content: string;
}[];
}
export interface MessageDeletePayload {
type: string;
guildId: string;
id: string;
channelId: string;
mlDeleted?: boolean;
}
export interface MessageDeleteBulkPayload {
type: string;
guildId: string;
ids: string[];
channelId: string;
}
export interface MessageUpdatePayload {
type: string;
guildId: string;
message: MessageJSON;
}
export interface MessageCreatePayload {
type: string;
guildId: string;
channelId: string;
message: MessageJSON;
optimistic: boolean;
isPushNotification: boolean;
}
export interface LoadMessagePayload {
type: string;
channelId: string;
messages: LoggedMessageJSON[];
isBefore: boolean;
isAfter: boolean;
hasMoreBefore: boolean;
hasMoreAfter: boolean;
limit: number;
isStale: boolean;
}
export interface FetchMessagesResponse {
ok: boolean;
headers: Headers;
body: LoggedMessageJSON[] & {
extra?: LoggedMessageJSON[];
};
text: string;
status: number;
}
export interface PatchAttachmentItem {
uniqueId: string;
originalItem: LoggedAttachment;
type: string;
downloadUrl: string;
height: number;
width: number;
spoiler: boolean;
contentType: string;
}
export interface AttachmentData {
messageId: string;
attachmentId: string;
}
export type SavedImages = Record<string, AttachmentData>;
export type LoggedMessageIds = {
// [channel_id: string]: message_id
deletedMessages: Record<string, string[]>;
editedMessages: Record<string, string[]>;
};
export type MessageRecord = { message: LoggedMessageJSON; };
export type LoggedMessages = LoggedMessageIds & { [message_id: string]: { message?: LoggedMessageJSON; }; };
export type GitValue = {
value: any;
stderr?: string;
ok: true;
};
export type GitError = {
ok: false;
cmd: string;
message: string;
error: any;
};
export type GitResult = GitValue | GitError;

View file

@ -1,38 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { settings } from "../index";
export class LimitedMap<K, V> {
public map: Map<K, V> = new Map();
constructor() { }
set(key: K, value: V) {
if (settings.store.cacheLimit > 0 && this.map.size >= settings.store.cacheLimit) {
const firstKey = this.map.keys().next().value;
if (firstKey !== undefined) {
this.map.delete(firstKey);
}
}
this.map.set(key, value);
}
get(key: K) {
return this.map.get(key);
}
}

View file

@ -1,116 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { MessageStore } from "@webpack/common";
import { User } from "discord-types/general";
import { LoggedMessageJSON, RefrencedMessage } from "../types";
import { getGuildIdByChannel, isGhostPinged } from "./index";
export function cleanupMessage(message: any, removeDetails: boolean = true): LoggedMessageJSON {
const ret: LoggedMessageJSON = typeof message.toJS === "function" ? JSON.parse(JSON.stringify(message.toJS())) : { ...message };
if (removeDetails) {
ret.author.phone = undefined;
ret.author.email = undefined;
}
ret.ghostPinged = ret.mentioned ?? isGhostPinged(message);
ret.guildId = ret.guild_id ?? getGuildIdByChannel(ret.channel_id);
ret.embeds = (ret.embeds ?? []).map(cleanupEmbed);
ret.deleted = ret.deleted ?? false;
ret.deletedTimestamp = ret.deleted ? (new Date()).toISOString() : undefined;
ret.editHistory = ret.editHistory ?? [];
if (ret.type === 19) {
ret.message_reference = message.message_reference || message.messageReference;
if (ret.message_reference) {
if (message.referenced_message) {
ret.referenced_message = cleanupMessage(message.referenced_message) as RefrencedMessage;
} else if (MessageStore.getMessage(ret.message_reference.channel_id, ret.message_reference.message_id)) {
ret.referenced_message = cleanupMessage(MessageStore.getMessage(ret.message_reference.channel_id, ret.message_reference.message_id)) as RefrencedMessage;
}
}
}
return ret;
}
export function cleanUpCachedMessage(message: any) {
const ret = cleanupMessage(message, false);
ret.ourCache = true;
return ret;
}
// stolen from mlv2
export function cleanupEmbed(embed) {
/* backported code from MLV2 rewrite */
if (!embed.id) return embed; /* already cleaned */
const retEmbed: any = {};
if (typeof embed.rawTitle === "string") retEmbed.title = embed.rawTitle;
if (typeof embed.rawDescription === "string") retEmbed.description = embed.rawDescription;
if (typeof embed.referenceId !== "undefined") retEmbed.reference_id = embed.referenceId;
// if (typeof embed.color === "string") retEmbed.color = ZeresPluginLibrary.ColorConverter.hex2int(embed.color);
if (typeof embed.type !== "undefined") retEmbed.type = embed.type;
if (typeof embed.url !== "undefined") retEmbed.url = embed.url;
if (typeof embed.provider === "object") retEmbed.provider = { name: embed.provider.name, url: embed.provider.url };
if (typeof embed.footer === "object") retEmbed.footer = { text: embed.footer.text, icon_url: embed.footer.iconURL, proxy_icon_url: embed.footer.iconProxyURL };
if (typeof embed.author === "object") retEmbed.author = { name: embed.author.name, url: embed.author.url, icon_url: embed.author.iconURL, proxy_icon_url: embed.author.iconProxyURL };
if (typeof embed.timestamp === "object" && embed.timestamp._isAMomentObject) retEmbed.timestamp = embed.timestamp.milliseconds();
if (typeof embed.thumbnail === "object") {
if (typeof embed.thumbnail.proxyURL === "string" || (typeof embed.thumbnail.url === "string" && !embed.thumbnail.url.endsWith("?format=jpeg"))) {
retEmbed.thumbnail = {
url: embed.thumbnail.url,
proxy_url: typeof embed.thumbnail.proxyURL === "string" ? embed.thumbnail.proxyURL.split("?format")[0] : undefined,
width: embed.thumbnail.width,
height: embed.thumbnail.height
};
}
}
if (typeof embed.image === "object") {
retEmbed.image = {
url: embed.image.url,
proxy_url: embed.image.proxyURL,
width: embed.image.width,
height: embed.image.height
};
}
if (typeof embed.video === "object") {
retEmbed.video = {
url: embed.video.url,
proxy_url: embed.video.proxyURL,
width: embed.video.width,
height: embed.video.height
};
}
if (Array.isArray(embed.fields) && embed.fields.length) {
retEmbed.fields = embed.fields.map(e => ({ name: e.rawName, value: e.rawValue, inline: e.inline }));
}
return retEmbed;
}
// stolen from mlv2
export function cleanupUserObject(user: User) {
/* backported from MLV2 rewrite */
return {
discriminator: user.discriminator,
username: user.username,
avatar: user.avatar,
id: user.id,
bot: user.bot,
public_flags: typeof user.publicFlags !== "undefined" ? user.publicFlags : (user as any).public_flags
};
}

View file

@ -1,23 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const DEFAULT_IMAGE_CACHE_DIR = "savedImages";
export const DB_NAME = "MessageLoggerIDB";
export const DB_VERSION = 1;
export const LOGS_DATA_FILENAME = "message-logger-logs.json";

View file

@ -1,171 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
import { FluxDispatcher, Menu, MessageActions, React, Toasts, UserStore } from "@webpack/common";
import { openLogModal } from "../components/LogsModal";
import { deleteMessageIDB } from "../db";
import { settings } from "../index";
import { addToXAndRemoveFromOpposite, ListType, removeFromX } from ".";
const idFunctions = {
Server: props => props?.guild?.id,
User: props => props?.message?.author?.id || props?.user?.id,
Channel: props => props.message?.channel_id || props.channel?.id
} as const;
type idKeys = keyof typeof idFunctions;
function renderListOption(listType: ListType, IdType: idKeys, props: any) {
const id = idFunctions[IdType](props);
if (!id) return null;
const isBlocked = settings.store[listType].includes(id);
const oppositeListType = listType === "blacklistedIds" ? "whitelistedIds" : "blacklistedIds";
const isOppositeBlocked = settings.store[oppositeListType].includes(id);
const list = listType === "blacklistedIds" ? "Blacklist" : "Whitelist";
const addToList = () => addToXAndRemoveFromOpposite(listType, id);
const removeFromList = () => removeFromX(listType, id);
return (
<Menu.MenuItem
id={`${listType}-${IdType}-${id}`}
label={
isOppositeBlocked
? `Move ${IdType} to ${list}`
: isBlocked ? `Remove ${IdType} From ${list}` : `${list} ${IdType}`
}
action={isBlocked ? removeFromList : addToList}
/>
);
}
function renderOpenLogs(idType: idKeys, props: any) {
const id = idFunctions[idType](props);
if (!id) return null;
return (
<Menu.MenuItem
id={`open-logs-for-${idType.toLowerCase()}`}
label={`Open Logs For ${idType}`}
action={() => openLogModal(`${idType.toLowerCase()}:${id}`)}
/>
);
}
export const contextMenuPath: NavContextMenuPatchCallback = (children, props) => {
if (!props) return;
if (!children.some(child => child?.props?.id === "message-logger")) {
children.push(
<Menu.MenuSeparator />,
<Menu.MenuItem
id="message-logger"
label="Message Logger"
>
<Menu.MenuItem
id="open-logs"
label="Open Logs"
action={() => openLogModal()}
/>
{Object.keys(idFunctions).map(IdType => renderOpenLogs(IdType as idKeys, props))}
<Menu.MenuSeparator />
{Object.keys(idFunctions).map(IdType => (
<React.Fragment key={IdType}>
{renderListOption("blacklistedIds", IdType as idKeys, props)}
{renderListOption("whitelistedIds", IdType as idKeys, props)}
</React.Fragment>
))}
{
props.navId === "message"
&& (props.message?.deleted || props.message?.editHistory?.length > 0)
&& (
<>
<Menu.MenuSeparator />
<Menu.MenuItem
id="remove-message"
label={props.message?.deleted ? "Remove Message (Permanent)" : "Remove Message History (Permanent)"}
color="danger"
action={() =>
deleteMessageIDB(props.message.id)
.then(() => {
if (props.message.deleted) {
FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
channelId: props.message.channel_id,
id: props.message.id,
mlDeleted: true
});
} else {
props.message.editHistory = [];
}
}).catch(() => Toasts.show({
type: Toasts.Type.FAILURE,
message: "Failed to remove message",
id: Toasts.genId()
}))
}
/>
</>
)
}
{
settings.store.hideMessageFromMessageLoggers
&& props.navId === "message"
&& props.message?.author?.id === UserStore.getCurrentUser().id
&& props.message?.deleted === false
&& (
<>
<Menu.MenuSeparator />
<Menu.MenuItem
id="hide-from-message-loggers"
label="Delete Message (Hide From Message Loggers)"
color="danger"
action={async () => {
await MessageActions.deleteMessage(props.message.channel_id, props.message.id);
MessageActions._sendMessage(props.message.channel_id, {
"content": settings.store.hideMessageFromMessageLoggersDeletedMessage,
"tts": false,
"invalidEmojis": [],
"validNonShortcutEmojis": []
}, { nonce: props.message.id });
}}
/>
</>
)
}
</Menu.MenuItem>
);
}
};
export const setupContextMenuPatches = () => {
addContextMenuPatch("message", contextMenuPath);
addContextMenuPatch("channel-context", contextMenuPath);
addContextMenuPatch("user-context", contextMenuPath);
addContextMenuPatch("guild-context", contextMenuPath);
addContextMenuPatch("gdm-context", contextMenuPath);
};
export const removeContextMenuBindings = () => {
removeContextMenuPatch("message", contextMenuPath);
removeContextMenuPatch("channel-context", contextMenuPath);
removeContextMenuPatch("user-context", contextMenuPath);
removeContextMenuPatch("guild-context", contextMenuPath);
removeContextMenuPatch("gdm-context", contextMenuPath);
};

View file

@ -1,185 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Settings } from "@api/Settings";
import { findStoreLazy } from "@webpack";
import { ChannelStore, SelectedChannelStore, UserStore } from "@webpack/common";
import { settings } from "../index";
import { LoggedMessageJSON } from "../types";
import { findLastIndex, getGuildIdByChannel } from "./misc";
export * from "./cleanUp";
export * from "./misc";
// stolen from mlv2
// https://github.com/1Lighty/BetterDiscordPlugins/blob/master/Plugins/MessageLoggerV2/MessageLoggerV2.plugin.js#L2367
interface Id { id: string, time: number; message?: LoggedMessageJSON; }
export const DISCORD_EPOCH = 14200704e5;
export function reAddDeletedMessages(messages: LoggedMessageJSON[], deletedMessages: LoggedMessageJSON[], channelStart: boolean, channelEnd: boolean) {
if (!messages.length || !deletedMessages?.length) return;
const IDs: Id[] = [];
const savedIDs: Id[] = [];
for (let i = 0, len = messages.length; i < len; i++) {
const { id } = messages[i];
IDs.push({ id: id, time: (parseInt(id) / 4194304) + DISCORD_EPOCH });
}
for (let i = 0, len = deletedMessages.length; i < len; i++) {
const record = deletedMessages[i];
if (!record) continue;
savedIDs.push({ id: record.id, time: (parseInt(record.id) / 4194304) + DISCORD_EPOCH, message: record });
}
savedIDs.sort((a, b) => a.time - b.time);
if (!savedIDs.length) return;
const { time: lowestTime } = IDs[IDs.length - 1];
const [{ time: highestTime }] = IDs;
const lowestIDX = channelEnd ? 0 : savedIDs.findIndex(e => e.time > lowestTime);
if (lowestIDX === -1) return;
const highestIDX = channelStart ? savedIDs.length - 1 : findLastIndex(savedIDs, e => e.time < highestTime);
if (highestIDX === -1) return;
const reAddIDs = savedIDs.slice(lowestIDX, highestIDX + 1);
reAddIDs.push(...IDs);
reAddIDs.sort((a, b) => b.time - a.time);
for (let i = 0, len = reAddIDs.length; i < len; i++) {
const { id, message } = reAddIDs[i];
if (messages.findIndex(e => e.id === id) !== -1) continue;
if (!message) continue;
messages.splice(i, 0, message);
}
}
interface ShouldIgnoreArguments {
channelId?: string,
authorId?: string,
guildId?: string;
flags?: number,
bot?: boolean;
ghostPinged?: boolean;
isCachedByUs?: boolean;
webhookId?: string;
}
const EPHEMERAL = 64;
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
/**
* the function `shouldIgnore` evaluates whether a message should be ignored or kept, following a priority hierarchy: User > Channel > Server.
* In this hierarchy, whitelisting takes priority; if any element (User, Channel, or Server) is whitelisted, the message is kept.
* However, if a higher-priority element, like a User, is blacklisted, it will override the whitelisting status of a lower-priority element, such as a Server, causing the message to be ignored.
* @param {ShouldIgnoreArguments} args - An object containing the message details.
* @returns {boolean} - True if the message should be ignored, false if it should be kept.
*/
export function shouldIgnore({ channelId, authorId, guildId, flags, bot, ghostPinged, isCachedByUs, webhookId }: ShouldIgnoreArguments): boolean {
const isEphemeral = ((flags ?? 0) & EPHEMERAL) === EPHEMERAL;
if (isEphemeral) return true; // ignore
if (channelId && guildId == null)
guildId = getGuildIdByChannel(channelId);
const myId = UserStore.getCurrentUser().id;
const { ignoreUsers, ignoreChannels, ignoreGuilds } = Settings.plugins.MessageLogger;
const { ignoreBots, ignoreSelf, ignoreWebhooks } = settings.store;
if (ignoreSelf && authorId === myId)
return true; // ignore
if (settings.store.alwaysLogDirectMessages && ChannelStore.getChannel(channelId ?? "-1")?.isDM?.())
return false; // keep
const shouldLogCurrentChannel = settings.store.alwaysLogCurrentChannel && SelectedChannelStore.getChannelId() === channelId;
const ids = [authorId, channelId, guildId];
const whitelistedIds = settings.store.whitelistedIds.split(",");
const isWhitelisted = settings.store.whitelistedIds.split(",").some(e => ids.includes(e));
const isAuthorWhitelisted = whitelistedIds.includes(authorId!);
const isChannelWhitelisted = whitelistedIds.includes(channelId!);
const isGuildWhitelisted = whitelistedIds.includes(guildId!);
const blacklistedIds = [
...settings.store.blacklistedIds.split(","),
...(ignoreUsers ?? []).split(","),
...(ignoreChannels ?? []).split(","),
...(ignoreGuilds ?? []).split(",")
];
const isBlacklisted = blacklistedIds.some(e => ids.includes(e));
const isAuthorBlacklisted = blacklistedIds.includes(authorId);
const isChannelBlacklisted = blacklistedIds.includes(channelId);
const shouldIgnoreMutedGuilds = settings.store.ignoreMutedGuilds;
const shouldIgnoreMutedCategories = settings.store.ignoreMutedCategories;
const shouldIgnoreMutedChannels = settings.store.ignoreMutedChannels;
if ((ignoreBots && bot) && !isAuthorWhitelisted) return true; // ignore
if ((ignoreWebhooks && webhookId) && !isAuthorWhitelisted) return true;
if (ghostPinged) return false; // keep
// author has highest priority
if (isAuthorWhitelisted) return false; // keep
if (isAuthorBlacklisted) return true; // ignore
if (isChannelWhitelisted) return false; // keep
if (isChannelBlacklisted) return true; // ignore
if (shouldLogCurrentChannel) return false; // keep
if (isWhitelisted) return false; // keep
if (isCachedByUs && (!settings.store.cacheMessagesFromServers && guildId != null && !isGuildWhitelisted)) return true; // ignore
if (isBlacklisted && (!isAuthorWhitelisted || !isChannelWhitelisted)) return true; // ignore
if (guildId != null && shouldIgnoreMutedGuilds && UserGuildSettingsStore.isMuted(guildId)) return true; // ignore
if (channelId != null && shouldIgnoreMutedCategories && UserGuildSettingsStore.isCategoryMuted(guildId, channelId)) return true; // ignore
if (channelId != null && shouldIgnoreMutedChannels && UserGuildSettingsStore.isChannelMuted(guildId, channelId)) return true; // ignore
return false; // keep;
}
export type ListType = "blacklistedIds" | "whitelistedIds";
export function addToXAndRemoveFromOpposite(list: ListType, id: string) {
const oppositeListType = list === "blacklistedIds" ? "whitelistedIds" : "blacklistedIds";
removeFromX(oppositeListType, id);
addToX(list, id);
}
export function addToX(list: ListType, id: string) {
const items = settings.store[list] ? settings.store[list].split(",") : [];
items.push(id);
settings.store[list] = items.join(",");
}
export function removeFromX(list: ListType, id: string) {
const items = settings.store[list] ? settings.store[list].split(",") : [];
const index = items.indexOf(id);
if (index !== -1) {
items.splice(index, 1);
}
settings.store[list] = items.join(",");
}

View file

@ -1,41 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
type MemoizedFunction<T extends (...args: any[]) => any> = {
(...args: Parameters<T>): ReturnType<T>;
clear(): void;
};
export function memoize<T extends (...args: any[]) => any>(func: T): MemoizedFunction<T> {
const cache = new Map<string, ReturnType<T>>();
const memoizedFunc = (...args: Parameters<T>): ReturnType<T> => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = func(...args);
cache.set(key, result);
return result;
};
memoizedFunc.clear = () => cache.clear();
return memoizedFunc;
}

View file

@ -1,176 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { PluginNative } from "@utils/types";
import { findByCodeLazy, findLazy } from "@webpack";
import { ChannelStore, moment, UserStore } from "@webpack/common";
import { DBMessageStatus } from "../db";
import { LoggedMessageJSON } from "../types";
import { DEFAULT_IMAGE_CACHE_DIR } from "./constants";
import { DISCORD_EPOCH } from "./index";
import { memoize } from "./memoize";
const MessageClass: any = findLazy(m => m?.prototype?.isEdited);
const AuthorClass = findLazy(m => m?.prototype?.getAvatarURL);
const sanitizeEmbed = findByCodeLazy('"embed_"),');
export function getGuildIdByChannel(channel_id: string) {
return ChannelStore.getChannel(channel_id)?.guild_id;
}
export const isGhostPinged = (message?: LoggedMessageJSON) => {
return message?.ghostPinged || message?.deleted && hasPingged(message);
};
export const hasPingged = (message?: LoggedMessageJSON | { mention_everyone: boolean, mentions: any[]; }) => {
return message && !!(
message.mention_everyone ||
message.mentions?.find(m => (typeof m === "string" ? m : m.id) === UserStore.getCurrentUser().id)
);
};
export const getMessageStatus = (message: LoggedMessageJSON) => {
if (isGhostPinged(message)) return DBMessageStatus.GHOST_PINGED;
if (message.deleted) return DBMessageStatus.DELETED;
if (message.editHistory?.length) return DBMessageStatus.EDITED;
throw new Error("Unknown message status");
};
export const discordIdToDate = (id: string) => new Date((parseInt(id) / 4194304) + DISCORD_EPOCH);
export const sortMessagesByDate = (timestampA: string, timestampB: string) => {
// very expensive
// const timestampA = discordIdToDate(a).getTime();
// const timestampB = discordIdToDate(b).getTime();
// return timestampB - timestampA;
// newest first
if (timestampA < timestampB) {
return 1;
} else if (timestampA > timestampB) {
return -1;
} else {
return 0;
}
};
// stolen from mlv2
export function findLastIndex<T>(array: T[], predicate: (e: T, t: number, n: T[]) => boolean) {
let l = array.length;
while (l--) {
if (predicate(array[l], l, array))
return l;
}
return -1;
}
const getTimestamp = (timestamp: any): Date => {
return new Date(timestamp);
};
export const mapTimestamp = (m: any) => {
if (m.timestamp) m.timestamp = getTimestamp(m.timestamp);
if (m.editedTimestamp) m.editedTimestamp = getTimestamp(m.editedTimestamp);
if (m.embeds) m.embeds = m.embeds.map(e => sanitizeEmbed(m.channel_id, m.id, e));
return m;
};
export const messageJsonToMessageClass = memoize((log: { message: LoggedMessageJSON; }) => {
// console.time("message populate");
if (!log?.message) return null;
const message: any = new MessageClass(log.message);
message.timestamp = getTimestamp(message.timestamp);
const editHistory = message.editHistory?.map(mapTimestamp);
if (editHistory && editHistory.length > 0) {
message.editHistory = editHistory;
}
if (message.editedTimestamp)
message.editedTimestamp = getTimestamp(message.editedTimestamp);
if (message.firstEditTimestamp)
message.firstEditTimestamp = getTimestamp(message.firstEditTimestamp);
message.author = UserStore.getUser(message.author.id) ?? new AuthorClass(message.author);
message.author.nick = message.author.globalName ?? message.author.username;
message.embeds = message.embeds.map(e => sanitizeEmbed(message.channel_id, message.id, e));
if (message.poll)
message.poll.expiry = moment(message.poll.expiry);
if (message.messageSnapshots)
message.messageSnapshots.map(m => mapTimestamp(m.message));
// console.timeEnd("message populate");
return message;
});
export function parseJSON(json?: string | null) {
try {
return JSON.parse(json!);
} finally {
return null;
}
}
export async function doesBlobUrlExist(url: string) {
const res = await fetch(url);
return res.ok;
}
export function getNative(): PluginNative<typeof import("../native")> {
if (IS_WEB) {
const Native = {
writeLogs: async () => { },
getDefaultNativeImageDir: async () => DEFAULT_IMAGE_CACHE_DIR,
getDefaultNativeDataDir: async () => "",
deleteFileNative: async () => { },
chooseDir: async (x: string) => "",
getSettings: async () => ({ imageCacheDir: DEFAULT_IMAGE_CACHE_DIR, logsDir: "" }),
init: async () => { },
initDirs: async () => { },
getImageNative: async (x: string) => new Uint8Array(0),
getNativeSavedImages: async () => new Map(),
messageLoggerEnhancedUniqueIdThingyIdkMan: async () => { },
showItemInFolder: async () => { },
writeImageNative: async () => { },
getCommitHash: async () => ({ ok: true, value: "" }),
getRepoInfo: async () => ({ ok: true, value: { repo: "", gitHash: "" } }),
getNewCommits: async () => ({ ok: true, value: [] }),
update: async () => ({ ok: true, value: "" }),
chooseFile: async () => "",
downloadAttachment: async () => ({ error: "web", path: null }),
} satisfies PluginNative<typeof import("../native")>;
return Native;
}
return Object.values(VencordNative.pluginHelpers)
.find(m => m.messageLoggerEnhancedUniqueIdThingyIdkMan) as PluginNative<typeof import("../native")>;
}

View file

@ -1,132 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ChannelStore, GuildStore } from "@webpack/common";
import { LoggedMessageJSON } from "../types";
import { getGuildIdByChannel } from "./index";
import { memoize } from "./memoize";
const validIdSearchTypes = ["server", "guild", "channel", "in", "user", "from", "message", "has", "before", "after", "around", "near", "during"] as const;
type ValidIdSearchTypesUnion = typeof validIdSearchTypes[number];
interface QueryResult {
key: ValidIdSearchTypesUnion;
value: string;
negate: boolean;
}
export const parseQuery = memoize((query: string = ""): QueryResult | string => {
let trimmedQuery = query.trim();
if (!trimmedQuery) {
return query;
}
let negate = false;
if (trimmedQuery.startsWith("!")) {
negate = true;
trimmedQuery = trimmedQuery.substring(trimmedQuery.length, 1);
}
const [filter, rest] = trimmedQuery.split(" ", 2);
if (!filter) {
return query;
}
const [type, id] = filter.split(":") as [ValidIdSearchTypesUnion, string];
if (!type || !id || !validIdSearchTypes.includes(type)) {
return query;
}
return {
key: type,
value: id,
negate,
};
});
export const tokenizeQuery = (query: string) => {
const parts = query.split(" ").map(parseQuery);
const queries = parts.filter(p => typeof p !== "string") as QueryResult[];
const rest = parts.filter(p => typeof p === "string") as string[];
return { queries, rest };
};
const linkRegex = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
export const doesMatch = (type: typeof validIdSearchTypes[number], value: string, message: LoggedMessageJSON) => {
switch (type) {
case "in":
case "channel":
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel)
return message.channel_id === value;
const { name, id } = channel;
return id === value
|| name.toLowerCase().includes(value.toLowerCase());
case "message":
return message.id === value;
case "from":
case "user":
return message.author.id === value
|| message.author?.username?.toLowerCase().includes(value.toLowerCase())
|| (message.author as any)?.globalName?.toLowerCase()?.includes(value.toLowerCase());
case "guild":
case "server": {
const guildId = message.guildId ?? getGuildIdByChannel(message.channel_id);
if (!guildId) return false;
const guild = GuildStore.getGuild(guildId);
if (!guild)
return guildId === value;
return guild.id === value
|| guild.name.toLowerCase().includes(value.toLowerCase());
}
case "before":
return new Date(message.timestamp) < new Date(value);
case "after":
return new Date(message.timestamp) > new Date(value);
case "around":
case "near":
case "during":
return Math.abs(new Date(message.timestamp)?.getTime() - new Date(value)?.getTime()) < 1000 * 60 * 60 * 24;
case "has": {
switch (value) {
case "attachment":
return message.attachments.length > 0;
case "image":
return message.attachments.some(a => a?.content_type?.startsWith("image")) ||
message.embeds.some(e => e.image || e.thumbnail);
case "video":
return message.attachments.some(a => a?.content_type?.startsWith("video")) ||
message.embeds.some(e => e.video);
case "embed":
return message.embeds.length > 0;
case "link":
return message.content.match(linkRegex);
default:
return false;
}
}
default:
return false;
}
};

View file

@ -1,117 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createStore,
del,
get,
keys,
set,
} from "@api/DataStore";
import { sleep } from "@utils/misc";
import { Flogger, Native } from "../..";
import { LoggedAttachment } from "../../types";
import { DEFAULT_IMAGE_CACHE_DIR } from "../constants";
const ImageStore = createStore("MessageLoggerImageData", "MessageLoggerImageStore");
interface IDBSavedImage { attachmentId: string, path: string; }
const idbSavedImages = new Map<string, IDBSavedImage>();
(async () => {
try {
const paths = await keys(ImageStore);
paths.forEach(path => {
const str = path.toString();
if (!str.startsWith(DEFAULT_IMAGE_CACHE_DIR)) return;
idbSavedImages.set(str.split("/")?.[1]?.split(".")?.[0], { attachmentId: str.split("/")?.[1]?.split(".")?.[0], path: str });
});
} catch (err) {
Flogger.error("Failed to get idb images", err);
}
})();
export async function getImage(attachmentId: string, fileExt?: string | null): Promise<any> {
// for people who have access to native api but some images are still in idb
// also for people who dont have native api
const idbPath = idbSavedImages.get(attachmentId)?.path;
if (idbPath)
return get(idbPath, ImageStore);
if (IS_WEB) return null;
return await Native.getImageNative(attachmentId);
}
export async function downloadAttachment(attachemnt: LoggedAttachment): Promise<string | undefined> {
if (IS_WEB) {
return await downloadAttachmentWeb(attachemnt);
}
const { path, error } = await Native.downloadAttachment(attachemnt);
if (error || !path) {
Flogger.error("Failed to download attachment", error, path);
return;
}
return path;
}
export async function deleteImage(attachmentId: string): Promise<void> {
const idbPath = idbSavedImages.get(attachmentId)?.path;
if (idbPath)
return await del(idbPath, ImageStore);
if (IS_WEB) return;
await Native.deleteFileNative(attachmentId);
}
async function downloadAttachmentWeb(attachemnt: LoggedAttachment, attempts = 0) {
if (!attachemnt?.url || !attachemnt?.id || !attachemnt?.fileExtension) {
Flogger.error("Invalid attachment", attachemnt);
return;
}
const res = await fetch(attachemnt.url);
if (res.status !== 200) {
if (res.status === 404 || res.status === 403) return;
attempts++;
if (attempts > 3) {
Flogger.warn(`Failed to get attachment ${attachemnt.id} for caching, error code ${res.status}`);
return;
}
await sleep(1000);
return downloadAttachmentWeb(attachemnt, attempts);
}
const ab = await res.arrayBuffer();
const path = `${DEFAULT_IMAGE_CACHE_DIR}/${attachemnt.id}${attachemnt.fileExtension}`;
// await writeImage(imageCacheDir, `${attachmentId}${fileExtension}`, new Uint8Array(ab));
await set(path, new Uint8Array(ab), ImageStore);
idbSavedImages.set(attachemnt.id, { attachmentId: attachemnt.id, path });
return path;
}

View file

@ -1,111 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { MessageAttachment } from "discord-types/general";
import { Flogger, settings } from "../..";
import { LoggedAttachment, LoggedMessage, LoggedMessageJSON } from "../../types";
import { memoize } from "../memoize";
import { deleteImage, downloadAttachment, getImage, } from "./ImageManager";
export function getFileExtension(str: string) {
const matches = str.match(/(\.[a-zA-Z0-9]+)(?:\?.*)?$/);
if (!matches) return null;
return matches[1];
}
export function isAttachmentGoodToCache(attachment: MessageAttachment, fileExtension: string) {
if (attachment.size > settings.store.attachmentSizeLimitInMegabytes * 1024 * 1024) {
Flogger.log("Attachment too large to cache", attachment.filename);
return false;
}
const attachmentFileExtensionsStr = settings.store.attachmentFileExtensions.trim();
if (attachmentFileExtensionsStr === "")
return true;
const allowedFileExtensions = attachmentFileExtensionsStr.split(",");
if (fileExtension.startsWith(".")) {
fileExtension = fileExtension.slice(1);
}
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
Flogger.log("Attachment not in allowed file extensions", attachment.filename);
return false;
}
return true;
}
export async function cacheMessageImages(message: LoggedMessage | LoggedMessageJSON) {
try {
for (const attachment of message.attachments) {
const fileExtension = getFileExtension(attachment.filename ?? attachment.url) ?? attachment?.content_type?.split("/")?.[1] ?? ".png";
if (!isAttachmentGoodToCache(attachment, fileExtension)) {
Flogger.log("skipping", attachment.filename);
continue;
}
attachment.oldUrl = attachment.url;
attachment.oldProxyUrl = attachment.proxy_url;
// only normal urls work if theres a charset in the content type /shrug
if (attachment?.content_type?.includes(";")) {
attachment.proxy_url = attachment.url;
} else {
// apparently proxy urls last longer
attachment.url = attachment.proxy_url;
attachment.proxy_url = attachment.url;
}
attachment.fileExtension = fileExtension;
const path = await downloadAttachment(attachment);
if (!path) {
Flogger.error("Failed to cache attachment", attachment);
continue;
}
attachment.path = path;
}
} catch (error) {
Flogger.error("Error caching message images:", error);
}
}
export async function deleteMessageImages(message: LoggedMessage | LoggedMessageJSON) {
for (let i = 0; i < message.attachments.length; i++) {
const attachment = message.attachments[i];
await deleteImage(attachment.id);
}
}
export const getAttachmentBlobUrl = memoize(async (attachment: LoggedAttachment) => {
const imageData = await getImage(attachment.id, attachment.fileExtension);
if (!imageData) return null;
const blob = new Blob([imageData]);
const resUrl = URL.createObjectURL(blob);
return resUrl;
});

View file

@ -1,108 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { chooseFile as chooseFileWeb } from "@utils/web";
import { Toasts } from "@webpack/common";
import { Native } from "..";
import { addMessagesBulkIDB, DBMessageRecord, getAllMessagesIDB } from "../db";
import { LoggedMessage, LoggedMessageJSON } from "../types";
async function getLogContents(): Promise<string> {
if (IS_WEB) {
const file = await chooseFileWeb(".json");
return new Promise((resolve, reject) => {
if (!file) return reject("No file selected");
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsText(file);
});
}
const settings = await Native.getSettings();
return Native.chooseFile("Logs", [{ extensions: ["json"], name: "logs" }], settings.logsDir);
}
export async function importLogs() {
try {
const content = await getLogContents();
const data = JSON.parse(content) as { messages: DBMessageRecord[]; };
let messages: LoggedMessageJSON[] = [];
if ((data as any).deletedMessages || (data as any).editedMessages) {
messages = Object.values((data as unknown as LoggedMessage)).filter(m => m.message).map(m => m.message) as LoggedMessageJSON[];
} else
messages = data.messages.map(m => m.message);
if (!Array.isArray(messages)) {
throw new Error("Invalid log file format");
}
if (!messages.length) {
throw new Error("No messages found in log file");
}
if (!messages.every(m => m.id && m.channel_id && m.timestamp)) {
throw new Error("Invalid message format");
}
await addMessagesBulkIDB(messages);
Toasts.show({
id: Toasts.genId(),
message: "Successfully imported logs",
type: Toasts.Type.SUCCESS
});
} catch (e) {
console.error(e);
Toasts.show({
id: Toasts.genId(),
message: "Error importing logs. Check the console for more information",
type: Toasts.Type.FAILURE
});
}
}
export async function exportLogs() {
const filename = "message-logger-logs-idb.json";
const messages = await getAllMessagesIDB();
const data = JSON.stringify({ messages }, null, 2);
if (IS_WEB || IS_VESKTOP || IS_EQUIBOP || !DiscordNative) {
const file = new File([data], filename, { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = filename;
document.body.appendChild(a);
a.click();
setImmediate(() => {
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
});
} else {
DiscordNative.fileManager.saveWithDialog(data, filename);
}
}

View file

@ -0,0 +1,74 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findByCodeLazy, findLazy } from "@webpack";
import { GuildStore } from "@webpack/common";
import { RC } from "@webpack/types";
import { Channel, Guild, Message, User } from "discord-types/general";
import { settings } from "./settings";
import type { ITag } from "./types";
export const isWebhook = (message: Message, user: User) => !!message?.webhookId && user.isNonUserBot();
export const tags = [
{
name: "WEBHOOK",
displayName: "Webhook",
description: "Messages sent by webhooks",
condition: isWebhook
}, {
name: "OWNER",
displayName: "Owner",
description: "Owns the server",
condition: (_, user, channel) => GuildStore.getGuild(channel?.guild_id)?.ownerId === user.id
}, {
name: "ADMINISTRATOR",
displayName: "Admin",
description: "Has the administrator permission",
permissions: ["ADMINISTRATOR"]
}, {
name: "MODERATOR_STAFF",
displayName: "Staff",
description: "Can manage the server, channels or roles",
permissions: ["MANAGE_GUILD", "MANAGE_CHANNELS", "MANAGE_ROLES"]
}, {
name: "MODERATOR",
displayName: "Mod",
description: "Can manage messages or kick/ban people",
permissions: ["MANAGE_MESSAGES", "KICK_MEMBERS", "BAN_MEMBERS"]
}, {
name: "VOICE_MODERATOR",
displayName: "VC Mod",
description: "Can manage voice chats",
permissions: ["MOVE_MEMBERS", "MUTE_MEMBERS", "DEAFEN_MEMBERS"]
}, {
name: "CHAT_MODERATOR",
displayName: "Chat Mod",
description: "Can timeout people",
permissions: ["MODERATE_MEMBERS"]
}, {
name: "SPECIAL_USER",
displayName: "Special",
description: "Special custom tag for specific users",
condition: (_message, user) => {
const { specialUsers } = settings.store;
const userIds = specialUsers.split(",").map(id => id.trim());
return userIds.includes(user.id);
},
verified: true
}
] as const satisfies ITag[];
export const Tag = findLazy(m => m.Types?.[0] === "BOT") as RC<{ type?: number | null, className?: string, useRemSizes?: boolean; }> & { Types: Record<string, number>; };
// PermissionStore.computePermissions will not work here since it only gets permissions for the current user
export const computePermissions: (options: {
user?: { id: string; } | string | null;
context?: Guild | Channel | null;
overwrites?: Channel["permissionOverwrites"] | null;
checkElevated?: boolean /* = true */;
excludeGuildPermissions?: boolean /* = false */;
}) => bigint = findByCodeLazy(".getCurrentUser()", ".computeLurkerPermissionsAllowList()");

View file

@ -0,0 +1,185 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { classNameFactory } from "@api/Styles";
import { Devs, EquicordDevs } from "@utils/constants";
import { getCurrentChannel, getIntlMessage } from "@utils/discord";
import definePlugin from "@utils/types";
import { ChannelStore, GuildStore, PermissionsBits, SelectedChannelStore, UserStore } from "@webpack/common";
import { Channel, Message, User } from "discord-types/general";
import { computePermissions, Tag, tags } from "./consts";
import { settings } from "./settings";
import { TagSettings } from "./types";
const cl = classNameFactory("vc-mut-");
const genTagTypes = () => {
let i = 100;
const obj = {};
for (const { name } of tags) {
obj[name] = ++i;
obj[i] = name;
}
return obj;
};
export default definePlugin({
name: "MoreUserTags",
description: "Adds tags for webhooks and moderative roles (owner, admin, etc.)",
authors: [Devs.Cyn, Devs.TheSun, Devs.RyanCaoDev, Devs.LordElias, Devs.AutumnVN, EquicordDevs.Hen],
dependencies: ["MemberListDecoratorsAPI", "NicknameIconsAPI", "MessageDecorationsAPI"],
settings,
patches: [
// Make discord actually use our tags
{
find: ".STAFF_ONLY_DM:",
replacement: [
{
match: /(?<=type:(\i).{10,1000}.REMIX.{10,100})default:(\i)=/,
replace: "default:$2=$self.getTagText($self.localTags[$1]);",
},
{
match: /(?<=type:(\i).{10,1000}.REMIX.{10,100})\.BOT:(?=default:)/,
replace: "$&return null;",
predicate: () => settings.store.dontShowBotTag
},
],
}
],
start() {
const tagSettings = settings.store.tagSettings || {} as TagSettings;
for (const tag of Object.values(tags)) {
tagSettings[tag.name] ??= {
showInChat: true,
showInNotChat: true,
text: tag.displayName
};
}
settings.store.tagSettings = tagSettings;
},
localTags: genTagTypes(),
getChannelId() {
return SelectedChannelStore.getChannelId();
},
renderNicknameIcon(props) {
const tagId = this.getTag({
user: UserStore.getUser(props.userId),
channel: ChannelStore.getChannel(this.getChannelId()),
channelId: this.getChannelId(),
isChat: false
});
return tagId && <Tag
type={tagId}
verified={false}>
</Tag>;
},
renderMessageDecoration(props) {
const tagId = this.getTag({
message: props.message,
user: UserStore.getUser(props.message.author.id),
channelId: props.message.channel_id,
isChat: false
});
return tagId && <Tag
useRemSizes={true}
className={cl("message-tag", props.message.author.isVerifiedBot() && "message-verified")}
type={tagId}
verified={false}>
</Tag>;
},
renderMemberListDecorator(props) {
const tagId = this.getTag({
user: props.user,
channel: getCurrentChannel(),
channelId: this.getChannelId(),
isChat: false
});
return tagId && <Tag
type={tagId}
verified={false}>
</Tag>;
},
getTagText(tagName: string) {
if (!tagName) return getIntlMessage("APP_TAG");
const tag = tags.find(({ name }) => tagName === name);
if (!tag) return tagName || getIntlMessage("APP_TAG");
return settings.store.tagSettings?.[tag.name]?.text || tag.displayName;
},
getTag({
message, user, channelId, isChat, channel
}: {
message?: Message,
user?: User & { isClyde(): boolean; },
channel?: Channel & { isForumPost(): boolean; isMediaPost(): boolean; },
channelId?: string;
isChat?: boolean;
}): number | null {
const settings = this.settings.store;
if (!user) return null;
if (isChat && user.id === "1") return null;
if (user.isClyde()) return null;
if (user.bot && settings.dontShowForBots) return null;
channel ??= ChannelStore.getChannel(channelId!) as any;
if (!channel) return null;
const perms = this.getPermissions(user, channel);
for (const tag of tags) {
if (isChat && !settings.tagSettings[tag.name].showInChat)
continue;
if (!isChat && !settings.tagSettings[tag.name].showInNotChat)
continue;
// If the owner tag is disabled, and the user is the owner of the guild,
// avoid adding other tags because the owner will always match the condition for them
if (
(tag.name !== "OWNER" &&
GuildStore.getGuild(channel?.guild_id)?.ownerId ===
user.id &&
isChat &&
!settings.tagSettings.OWNER.showInChat) ||
(!isChat &&
!settings.tagSettings.OWNER.showInNotChat)
)
continue;
if ("permissions" in tag ?
tag.permissions.some(perm => perms.includes(perm)) :
tag.condition(message!, user, channel)) {
return this.localTags[tag.name];
}
}
return null;
},
getPermissions(user: User, channel: Channel): string[] {
const guild = GuildStore.getGuild(channel?.guild_id);
if (!guild) return [];
const permissions = computePermissions({ user, context: guild, overwrites: channel.permissionOverwrites });
return Object.entries(PermissionsBits)
.map(([perm, permInt]) =>
permissions & permInt ? perm : ""
)
.filter(Boolean);
},
});

View file

@ -0,0 +1,21 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const settings = definePluginSettings({
specialUsers: {
type: OptionType.STRING,
description: "List of special user IDs (comma separated)",
default: "878151241769820173",
},
specialTag: {
type: OptionType.STRING,
description: "Text to display for special users",
default: "Special",
}
});

View file

@ -0,0 +1,114 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Margins } from "@utils/margins";
import { OptionType } from "@utils/types";
import { Card, Flex, Forms, Switch, TextInput, Tooltip } from "@webpack/common";
import { Tag, tags } from "./consts";
import { TagSettings } from "./types";
function SettingsComponent() {
const tagSettings = settings.store.tagSettings as TagSettings;
const { localTags } = Vencord.Plugins.plugins.MoreUserTags as any;
return (
<Flex flexDirection="column">
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "16px",
}}
>
{tags.map(t => (
<Card
key={t.name}
style={{
padding: "1em 1em 0",
width: "calc(33.333% - 11px)",
boxSizing: "border-box",
}}
>
<Forms.FormTitle style={{ width: "fit-content" }}>
<Tooltip text={t.description}>
{({ onMouseEnter, onMouseLeave }) => (
<div
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{t.displayName} Tag
</div>
)}
</Tooltip>
</Forms.FormTitle>
<div style={{ marginBottom: "10px" }}>
<Forms.FormText style={{ fontSize: "13px" }}>
Example:
</Forms.FormText>
<Tag type={localTags[t.name]} />
</div>
<TextInput
type="text"
value={tagSettings[t.name]?.text ?? t.displayName}
placeholder={`Text on tag (default: ${t.displayName})`}
onChange={v => tagSettings[t.name].text = v}
className={Margins.bottom16}
/>
<Switch
value={tagSettings[t.name]?.showInChat ?? true}
onChange={v => tagSettings[t.name].showInChat = v}
hideBorder
>
Show in messages
</Switch>
<Switch
value={tagSettings[t.name]?.showInNotChat ?? true}
onChange={v => tagSettings[t.name].showInNotChat = v}
hideBorder
>
Show in member list and profiles
</Switch>
</Card>
))}
</div>
</Flex>
);
}
export const settings = definePluginSettings({
dontShowForBots: {
description: "Don't show extra tags for bots (excluding webhooks)",
type: OptionType.BOOLEAN,
default: false
},
dontShowBotTag: {
description: "Only show extra tags for bots / Hide [APP] text",
type: OptionType.BOOLEAN,
default: false,
restartNeeded: true
},
tagSettings: {
type: OptionType.COMPONENT,
component: SettingsComponent,
description: "fill me"
},
specialUsers: {
type: OptionType.STRING,
description: "List of special user IDs (comma separated)",
default: "878151241769820173",
},
specialTag: {
type: OptionType.STRING,
description: "Text to display for special users",
default: "Special",
}
});

View file

@ -0,0 +1,27 @@
.vc-message-decorations-wrapper .vc-mut-message-tag {
margin-bottom: 1px;
}
/* stylelint-disable-next-line no-descending-specificity */
.vc-mut-message-tag {
/* Remove default margin from tags in messages */
margin-top: unset !important;
/* Align with Discord default tags in messages */
/* stylelint-disable-next-line length-zero-no-unit */
bottom: 0px;
top: -2px;
margin-right: 3px;
}
.vc-mut-message-verified {
height: 1rem !important;
}
span[class*="botTagCozy"][data-moreTags-darkFg="true"]>svg>path {
fill: #000;
}
span[class*="botTagCozy"][data-moreTags-darkFg="false"]>svg>path {
fill: #fff;
}

View file

@ -0,0 +1,33 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Permissions } from "@webpack/types";
import type { Channel, Message, User } from "discord-types/general";
import { tags } from "./consts";
export type ITag = {
// name used for identifying, must be alphanumeric + underscores
name: string;
// name shown on the tag itself, can be anything probably; automatically uppercase'd
displayName: string;
description: string;
verified?: boolean;
} & ({
permissions: Permissions[];
} | {
condition?(message: Message | null, user: User, channel: Channel): boolean;
});
export interface TagSetting {
text: string;
showInChat: boolean;
showInNotChat: boolean;
}
export type TagSettings = {
[k in typeof tags[number]["name"]]: TagSetting;
};

View file

@ -30,7 +30,11 @@ const messagePatch: NavContextMenuPatchCallback = (children, { message }) => {
label="Quote"
icon={QuoteIcon}
action={async () => {
openModal(props => <QuoteModal {...props} />);
if (settings.store.autoSendQuote) {
await SendInChat(() => { });
} else {
openModal(props => <QuoteModal {...props} />);
}
}}
/>;
@ -65,6 +69,11 @@ const settings = definePluginSettings({
{ label: "Username", value: userIDOptions.userName },
{ label: "User ID", value: userIDOptions.userId }
]
},
autoSendQuote: {
type: OptionType.BOOLEAN,
description: "Automatically send the quote when clicking the quote button",
default: false
}
});
@ -187,7 +196,7 @@ function registerStyleChange(style) {
}
function QuoteModal(props: ModalProps) {
const [gray, setGray] = useState(true);
const [gray, setGray] = useState(false); // Default to false (disabled)
useEffect(() => {
grayscale = gray;
GeneratePreview();

View file

@ -1,132 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { Track } from "plugins/spotifyControls/SpotifyStore";
import { getLyricsLrclib } from "./providers/lrclibAPI";
import { getLyricsSpotify } from "./providers/SpotifyAPI";
import { LyricsData, Provider, SyncedLyric } from "./providers/types";
import settings from "./settings";
const LyricsCacheKey = "SpotifyLyricsCacheNew";
interface NullLyricCacheEntry {
[Provider.Lrclib]?: boolean;
[Provider.Spotify]?: boolean;
}
const nullLyricCache = new Map<string, NullLyricCacheEntry>();
export const lyricFetchers = {
[Provider.Spotify]: async (track: Track) => await getLyricsSpotify(track.id),
[Provider.Lrclib]: getLyricsLrclib,
};
export const providers = Object.keys(lyricFetchers) as Provider[];
export async function getLyrics(track: Track | null): Promise<LyricsData | null> {
if (!track) return null;
const cacheKey = track.id;
const cached = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
if (cached?.[cacheKey]) {
return cached[cacheKey];
}
const nullCacheEntry = nullLyricCache.get(cacheKey);
if (nullCacheEntry) {
const provider = settings.store.LyricsProvider;
if (!settings.store.FallbackProvider && nullCacheEntry[provider]) {
return null;
}
if (nullCacheEntry[Provider.Spotify] && nullCacheEntry[Provider.Lrclib]) {
return null;
}
}
const providersToTry = [settings.store.LyricsProvider, ...providers.filter(p => p !== settings.store.LyricsProvider)];
for (const provider of providersToTry) {
const lyricsInfo = await lyricFetchers[provider](track);
if (lyricsInfo) {
await DataStore.set(LyricsCacheKey, { ...cached, [cacheKey]: lyricsInfo });
return lyricsInfo;
}
const updatedNullCacheEntry = nullLyricCache.get(cacheKey) || {};
nullLyricCache.set(cacheKey, { ...updatedNullCacheEntry, [provider]: true });
}
return null;
}
export async function clearLyricsCache() {
nullLyricCache.clear();
await DataStore.set(LyricsCacheKey, {});
}
export async function getLyricsCount(): Promise<number> {
const cache = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
return Object.keys(cache).length;
}
export async function updateLyrics(trackId: string, newLyrics: SyncedLyric[], provider: Provider) {
const cache = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
const current = cache[trackId];
await DataStore.set(LyricsCacheKey,
{
...cache, [trackId]: {
...current,
useLyric: provider,
lyricsVersions: {
...current?.lyricsVersions,
[provider]: newLyrics
}
}
}
);
}
export async function removeTranslations() {
const cache = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
const newCache = {} as Record<string, LyricsData | null>;
for (const [trackId, trackData] of Object.entries(cache)) {
const { Translated, ...lyricsVersions } = trackData?.lyricsVersions || {};
const newUseLyric = !!lyricsVersions[Provider.Spotify] ? Provider.Spotify : Provider.Lrclib;
newCache[trackId] = { lyricsVersions, useLyric: newUseLyric };
}
await DataStore.set(LyricsCacheKey, newCache);
}
export async function migrateOldLyrics() {
const oldCache = await DataStore.get("SpotifyLyricsCache");
if (!oldCache || !Object.entries(oldCache).length) return;
const filteredCache = Object.entries(oldCache).filter(lrc => lrc[1]);
const result = {};
filteredCache.forEach(([trackId, lyrics]) => {
result[trackId] = {
lyricsVersions: {
// @ts-ignore
LRCLIB: lyrics.map(({ time, text }) => ({ time, text }))
},
useLyric: "LRCLIB"
};
});
await DataStore.set(LyricsCacheKey, result);
await DataStore.set("SpotifyLyricsCache", {});
}

View file

@ -1,76 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { copyWithToast } from "@utils/misc";
import { findComponentByCodeLazy } from "@webpack";
import { FluxDispatcher, Menu } from "@webpack/common";
import { Provider } from "../providers/types";
import { useLyrics } from "./util";
const CopyIcon = findComponentByCodeLazy(" 1-.5.5H10a6");
const lyricsActualProviders = [Provider.Lrclib, Provider.Spotify];
const lyricsAlternative = [Provider.Translated, Provider.Romanized];
function ProviderMenuItem(toProvider: Provider, currentProvider?: Provider) {
return (
(!currentProvider || currentProvider !== toProvider) && (
<Menu.MenuItem
key={`switch-provider-${toProvider.toLowerCase()}`}
id={`switch-provider-${toProvider.toLowerCase()}`}
label={`Switch to ${toProvider}`}
action={() => {
FluxDispatcher.dispatch({
// @ts-ignore
type: "SPOTIFY_LYRICS_PROVIDER_CHANGE",
provider: toProvider,
});
}}
/>
)
);
}
export function LyricsContextMenu() {
const { lyricsInfo, currLrcIndex } = useLyrics();
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric];
const hasAShowingLyric = currLrcIndex !== null && currLrcIndex >= 0;
const hasLyrics = !!(lyricsInfo?.lyricsVersions[Provider.Lrclib] || lyricsInfo?.lyricsVersions[Provider.Spotify]);
return (
<Menu.Menu
navId="spotify-lyrics-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Spotify Lyrics Menu"
>
{hasAShowingLyric && (
<Menu.MenuItem
key="copy-lyric"
id="copy-lyric"
label="Copy lyric"
action={() => {
copyWithToast(currentLyrics![currLrcIndex].text!, "Lyric copied!");
}}
icon={CopyIcon}
/>
)}
<Menu.MenuItem
navId="spotify-lyrics-provider"
id="spotify-lyrics-provider"
label="Lyrics Provider"
>
{lyricsActualProviders.map(provider =>
ProviderMenuItem(provider, lyricsInfo?.useLyric)
)}
{hasLyrics && lyricsAlternative.map(provider =>
ProviderMenuItem(provider, lyricsInfo?.useLyric)
)}
</Menu.MenuItem>
</Menu.Menu>
);
}

View file

@ -1,94 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { openModal } from "@utils/modal";
import { ContextMenuApi, React, Text, TooltipContainer, useEffect, useState, useStateFromStores } from "@webpack/common";
import { SpotifyLrcStore } from "../providers/store";
import settings from "../settings";
import { LyricsContextMenu } from "./ctxMenu";
import { LyricsModal } from "./modal";
import { cl, NoteSvg, useLyrics } from "./util";
function LyricsDisplay() {
const { ShowMusicNoteOnNoLyrics } = settings.use(["ShowMusicNoteOnNoLyrics"]);
const { lyricsInfo, lyricRefs, currLrcIndex } = useLyrics();
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
const NoteElement = NoteSvg(cl("music-note"));
const makeClassName = (index: number): string => {
if (currLrcIndex === null) return "";
const diff = index - currLrcIndex;
if (diff === 0) return cl("current");
return cl(diff > 0 ? "next" : "prev");
};
if (!lyricsInfo) {
return ShowMusicNoteOnNoLyrics && (
<div className="vc-spotify-lyrics"
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <LyricsContextMenu />)}
>
<TooltipContainer text="No synced lyrics found">
{NoteElement}
</TooltipContainer>
</div>
);
}
return (
<div
className="vc-spotify-lyrics"
onClick={() => openModal(props => <LyricsModal rootProps={props} />)}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <LyricsContextMenu />)}
>
{currentLyrics?.map((line, i) => (
<div ref={lyricRefs[i]} key={i}>
<Text
variant={currLrcIndex === i ? "text-sm/normal" : "text-xs/normal"}
className={makeClassName(i)}
>
{line.text || NoteElement}
</Text>
</div>
))}
</div>
);
}
export function Lyrics() {
const track = useStateFromStores(
[SpotifyLrcStore],
() => SpotifyLrcStore.track,
null,
(prev, next) => (prev?.id ? prev.id === next?.id : prev?.name === next?.name)
);
const device = useStateFromStores(
[SpotifyLrcStore],
() => SpotifyLrcStore.device,
null,
(prev, next) => prev?.id === next?.id
);
const isPlaying = useStateFromStores([SpotifyLrcStore], () => SpotifyLrcStore.isPlaying);
const [shouldHide, setShouldHide] = useState(false);
useEffect(() => {
setShouldHide(false);
if (!isPlaying) {
const timeout = setTimeout(() => setShouldHide(true), 1000 * 60 * 5);
return () => clearTimeout(timeout);
}
}, [isPlaying]);
if (!track || !device?.is_active || shouldHide) return null;
return <LyricsDisplay />;
}

View file

@ -1,78 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { openImageModal } from "@utils/discord";
import { ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { React, Text } from "@webpack/common";
import { SpotifyStore, Track } from "plugins/spotifyControls/SpotifyStore";
import { cl, NoteSvg, scrollClasses, useLyrics } from "./util";
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
function ModalHeaderContent({ track }: { track: Track; }) {
return (
<ModalHeader>
<div className={cl("header-content")}>
{track?.album?.image?.url && (
<img
src={track.album.image.url}
alt={track.album.name}
className={cl("album-image")}
onClick={() => openImageModal({
url: track.album.image.url,
width: track.album.image.width,
height: track.album.image.height,
})}
/>
)}
<div>
<Text selectable variant="text-sm/semibold">{track.name}</Text>
<Text selectable variant="text-sm/normal">by {track.artists.map(a => a.name).join(", ")}</Text>
<Text selectable variant="text-sm/normal">on {track.album.name}</Text>
</div>
</div>
</ModalHeader>
);
}
export function LyricsModal({ rootProps }: { rootProps: ModalProps; }) {
const { track, lyricsInfo, currLrcIndex } = useLyrics();
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
return (
<ModalRoot {...rootProps}>
<ModalHeaderContent track={track} />
<ModalContent>
<div className={cl("lyrics-modal-container") + ` ${scrollClasses.auto}`}>
{currentLyrics ? (
currentLyrics.map((line, i) => (
<Text
key={i}
variant={currLrcIndex === i ? "text-md/semibold" : "text-sm/normal"}
selectable
className={currLrcIndex === i ? cl("modal-line-current") : cl("modal-line")}
>
<span className={cl("modal-timestamp")} onClick={() => SpotifyStore.seek(line.time * 1000)}>
{formatTime(line.time)}
</span>
{line.text || NoteSvg(cl("modal-note"))}
</Text>
))
) : (
<Text variant="text-sm/normal" className={cl("modal-no-lyrics")}>
No lyrics available :(
</Text>
)}
</div>
</ModalContent>
</ModalRoot>
);
}

View file

@ -1,92 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { findByPropsLazy } from "@webpack";
import { React, useEffect, useState, useStateFromStores } from "@webpack/common";
import { SpotifyLrcStore } from "../providers/store";
import { SyncedLyric } from "../providers/types";
import settings from "../settings";
export const scrollClasses = findByPropsLazy("auto", "customTheme");
export const cl = classNameFactory("vc-spotify-lyrics-");
export function NoteSvg(className: string) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 480 720" fill="currentColor" className={className} >
<path d="m160,-240 q -66,0 -113,-47 -47,-47 -47,-113 0,-66 47,-113 47,-47 113,-47 23,0 42.5,5.5 19.5,5.5 37.5,16.5 v -422 h 240 v 160 H 320 v 400 q 0,66 -47,113 -47,47 -113,47 z" />
</svg>
);
}
const calculateIndexes = (lyrics: SyncedLyric[], position: number, delay: number) => {
const posInSec = position / 1000;
const currentIndex = lyrics.findIndex(l => l.time - (delay / 1000) > posInSec && l.time < posInSec + 8) - 1;
const nextLyric = lyrics.findIndex(l => l.time >= posInSec);
return [currentIndex, nextLyric];
};
export function useLyrics() {
const [track, storePosition, isPlaying, lyricsInfo] = useStateFromStores(
[SpotifyLrcStore],
() => [
SpotifyLrcStore.track!,
SpotifyLrcStore.mPosition,
SpotifyLrcStore.isPlaying,
SpotifyLrcStore.lyricsInfo
]
);
const { LyricDelay } = settings.use(["LyricDelay"]);
const [currLrcIndex, setCurrLrcIndex] = useState<number | null>(null);
const [nextLyric, setNextLyric] = useState<number | null>(null);
const [position, setPosition] = useState(storePosition);
const [lyricRefs, setLyricRefs] = useState<React.RefObject<HTMLDivElement | null>[]>([]);
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
useEffect(() => {
if (currentLyrics) {
setLyricRefs(currentLyrics.map(() => React.createRef()));
}
}, [currentLyrics]);
useEffect(() => {
if (currentLyrics && position) {
const [currentIndex, nextLyric] = calculateIndexes(currentLyrics, position, LyricDelay);
setCurrLrcIndex(currentIndex);
setNextLyric(nextLyric);
}
}, [currentLyrics, position]);
useEffect(() => {
if (currLrcIndex !== null) {
if (currLrcIndex >= 0) {
lyricRefs[currLrcIndex].current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
if (currLrcIndex < 0 && nextLyric !== null) {
lyricRefs[nextLyric]?.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, [currLrcIndex, nextLyric]);
useEffect(() => {
if (isPlaying) {
setPosition(SpotifyLrcStore.position);
const interval = setInterval(() => {
setPosition(p => p + 1000);
}, 1000);
return () => clearInterval(interval);
}
}, [storePosition, isPlaying]);
return { track, lyricsInfo, lyricRefs, currLrcIndex, nextLyric };
}

View file

@ -1,61 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { Settings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { Player } from "plugins/spotifyControls/PlayerComponent";
import { migrateOldLyrics } from "./api";
import { Lyrics } from "./components/lyrics";
import settings from "./settings";
export default definePlugin({
name: "SpotifyLyrics",
authors: [Devs.Joona],
description: "Adds lyrics to SpotifyControls",
dependencies: ["SpotifyControls"],
patches: [
{
find: "this.isCopiedStreakGodlike",
replacement: {
match: /Vencord.Plugins.plugins\["SpotifyControls"\].PanelWrapper/,
replace: "$self.FakePanelWrapper",
},
predicate: () => Settings.plugins.SpotifyControls.enabled,
noWarn: true,
},
],
FakePanelWrapper({ VencordOriginal, ...props }) {
const { LyricsPosition } = settings.use(["LyricsPosition"]);
return (
<>
<ErrorBoundary
fallback={() => (
<div className="vc-spotify-fallback">
<p>Failed to render Spotify Lyrics Modal :(</p>
<p>Check the console for errors</p>
</div>
)}
>
{LyricsPosition === "above" && <Lyrics />}
<Player />
{LyricsPosition === "below" && <Lyrics />}
</ErrorBoundary>
<VencordOriginal {...props} />
</>
);
},
settings,
async start() {
await migrateOldLyrics();
},
});

View file

@ -1,49 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { LyricsData, Provider } from "../types";
interface LyricsAPIResp {
error: boolean;
syncType: string;
lines: Line[];
}
interface Line {
startTimeMs: string;
words: string;
syllables: any[];
endTimeMs: string;
}
export async function getLyricsSpotify(trackId: string): Promise<LyricsData | null> {
const resp = await fetch("https://spotify-lyrics-api-pi.vercel.app/?trackid=" + trackId);
if (!resp.ok) return null;
let data: LyricsAPIResp;
try {
data = await resp.json() as LyricsAPIResp;
} catch (e) {
return null;
}
const lyrics = data.lines;
if (lyrics[0].startTimeMs === "0" && lyrics[lyrics.length - 1].startTimeMs === "0") return null;
return {
useLyric: Provider.Spotify,
lyricsVersions: {
Spotify: lyrics.map(line => {
const trimmedText = line.words.trim();
return {
time: Number(line.startTimeMs) / 1000,
text: (trimmedText === "" || trimmedText === "♪") ? null : trimmedText
};
})
}
};
}

View file

@ -1,67 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Track } from "plugins/spotifyControls/SpotifyStore";
import { LyricsData, Provider } from "../types";
const baseUrlLrclib = "https://lrclib.net/api/get";
interface LrcLibResponse {
id: number;
name: string;
trackName: string;
artistName: string;
albumName: string;
duration: number;
instrumental: boolean;
plainLyrics: string | null;
syncedLyrics: string | null;
}
function lyricTimeToSeconds(time: string) {
const [minutes, seconds] = time.slice(1, -1).split(":").map(Number);
return minutes * 60 + seconds;
}
export async function getLyricsLrclib(track: Track): Promise<LyricsData | null> {
const info = {
track_name: track.name,
artist_name: track.artists[0].name,
album_name: track.album.name,
duration: track.duration / 1000
};
const params = new URLSearchParams(info as any);
const url = `${baseUrlLrclib}?${params.toString()}`;
const response = await fetch(url, {
headers: {
"User-Agent": "https://github.com/Masterjoona/vc-spotifylyrics"
}
});
if (!response.ok) return null;
const data = await response.json() as LrcLibResponse;
if (!data.syncedLyrics) return null;
const lyrics = data.syncedLyrics;
const lines = lyrics.split("\n").filter(line => line.trim() !== "");
return {
useLyric: Provider.Lrclib,
lyricsVersions: {
LRCLIB: lines.map(line => {
const [lrcTime, text] = line.split("]");
const trimmedText = text.trim();
return {
time: lyricTimeToSeconds(lrcTime),
text: (trimmedText === "" || trimmedText === "♪") ? null : trimmedText
};
})
}
};
}

View file

@ -1,151 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { showNotification } from "@api/Notifications";
import { proxyLazyWebpack } from "@webpack";
import { Flux, FluxDispatcher } from "@webpack/common";
import { Track } from "plugins/spotifyControls/SpotifyStore";
import { getLyrics, lyricFetchers, updateLyrics } from "../api";
import settings from "../settings";
import { romanizeLyrics, translateLyrics } from "./translator";
import { LyricsData, Provider } from "./types";
interface PlayerStateMin {
track: Track | null;
device?: Device;
isPlaying: boolean,
position: number,
}
interface Device {
id: string;
is_active: boolean;
}
function showNotif(title: string, body: string) {
if (settings.store.ShowFailedToasts) {
showNotification({
color: "#ee2902",
title,
body,
noPersist: true
});
}
}
// steal from spotifycontrols
export const SpotifyLrcStore = proxyLazyWebpack(() => {
class SpotifyLrcStore extends Flux.Store {
public mPosition = 0;
private start = 0;
public track: Track | null = null;
public device: Device | null = null;
public isPlaying = false;
public lyricsInfo: LyricsData | null = null;
public fetchingsTracks: string[] = [];
public get position(): number {
let pos = this.mPosition;
if (this.isPlaying) {
pos += Date.now() - this.start;
}
return pos;
}
public set position(p: number) {
this.mPosition = p;
this.start = Date.now();
}
}
const store = new SpotifyLrcStore(FluxDispatcher, {
async SPOTIFY_PLAYER_STATE(e: PlayerStateMin) {
if (store.fetchingsTracks.includes(e.track?.id ?? "")) return;
store.fetchingsTracks.push(e.track?.id ?? "");
store.track = e.track;
store.isPlaying = e.isPlaying ?? false;
store.position = e.position ?? 0;
store.device = e.device ?? null;
store.lyricsInfo = await getLyrics(e.track);
const { LyricsConversion } = settings.store;
if (LyricsConversion !== Provider.None) {
// @ts-ignore
FluxDispatcher.dispatch({ type: "SPOTIFY_LYRICS_PROVIDER_CHANGE", provider: LyricsConversion });
}
store.fetchingsTracks = store.fetchingsTracks.filter(id => id !== e.track?.id);
store.emitChange();
},
SPOTIFY_SET_DEVICES({ devices }: { devices: Device[]; }) {
store.device = devices.find(d => d.is_active) ?? devices[0] ?? null;
store.emitChange();
},
// @ts-ignore
async SPOTIFY_LYRICS_PROVIDER_CHANGE(e: { provider: Provider; }) {
const currentInfo = await getLyrics(store.track);
const { provider } = e;
if (currentInfo?.useLyric === provider) return;
if (currentInfo?.lyricsVersions[provider]) {
store.lyricsInfo = { ...currentInfo, useLyric: provider };
await updateLyrics(store.track!.id, currentInfo.lyricsVersions[provider]!, provider);
store.emitChange();
return;
}
if (provider === Provider.Translated || provider === Provider.Romanized) {
if (!currentInfo?.lyricsVersions[Provider.Spotify] && !currentInfo?.lyricsVersions[Provider.Lrclib]) {
showNotif("No lyrics", `No lyrics to ${provider === Provider.Translated ? "translate" : "romanize"}`);
return;
}
const fetcher = provider === Provider.Translated ? translateLyrics : romanizeLyrics;
const fetchResult = await fetcher(currentInfo.lyricsVersions[currentInfo.useLyric]);
if (!fetchResult) {
showNotif("Lyrics fetch failed", `Failed to fetch ${provider === Provider.Translated ? "translation" : "romanization"}`);
return;
}
store.lyricsInfo = {
...currentInfo,
useLyric: provider,
lyricsVersions: {
...currentInfo.lyricsVersions,
[provider]: fetchResult
}
};
await updateLyrics(store.track!.id, fetchResult, provider);
store.emitChange();
return;
}
const newLyricsInfo = await lyricFetchers[e.provider](store.track!);
if (!newLyricsInfo) {
showNotif("Lyrics fetch failed", `Failed to fetch ${e.provider} lyrics`);
return;
}
store.lyricsInfo = newLyricsInfo;
updateLyrics(store.track!.id, newLyricsInfo.lyricsVersions[e.provider], e.provider);
store.emitChange();
}
});
return store;
});

View file

@ -1,90 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import settings from "../../settings";
import { LyricsData, Provider, SyncedLyric } from "../types";
// stolen from src\plugins\translate\utils.ts
interface GoogleData {
src: string;
sentences: {
// 🏳️‍⚧️
trans: string;
orig: string;
src_translit?: string;
}[];
}
async function googleTranslate(text: string, targetLang: string, romanize: boolean): Promise<GoogleData | null> {
const url = "https://translate.googleapis.com/translate_a/single?" + new URLSearchParams({
// see https://stackoverflow.com/a/29537590 for more params
// holy shidd nvidia
client: "gtx",
// source language
sl: "auto",
// target language
tl: targetLang,
// what to return, t = translation probably
dt: romanize ? "rm" : "t",
// Send json object response instead of weird array
dj: "1",
source: "input",
// query, duh
q: text
});
const res = await fetch(url);
if (!res.ok)
return null;
return await res.json();
}
async function processLyrics(
lyrics: LyricsData["lyricsVersions"][Provider],
targetLang: string,
romanize: boolean
): Promise<SyncedLyric[] | null> {
if (!lyrics) return null;
const nonDuplicatedLyrics = lyrics.filter((lyric, index, self) =>
self.findIndex(l => l.text === lyric.text) === index
);
const processedLyricsResp = await Promise.all(
nonDuplicatedLyrics.map(async lyric => {
if (!lyric.text) return [lyric.text, null];
const translation = await googleTranslate(lyric.text, targetLang, romanize);
if (!translation || !translation.sentences || translation.sentences.length === 0) return [lyric.text, null];
return [lyric.text, romanize ? translation.sentences[0].src_translit : translation.sentences[0].trans];
})
);
if (processedLyricsResp[0][1] === null) return null;
return lyrics.map(lyric => ({
...lyric,
text: processedLyricsResp.find(mapping => mapping[0] === lyric.text)?.[1] ?? lyric.text
}));
}
export async function translateLyrics(lyrics: LyricsData["lyricsVersions"][Provider]): Promise<SyncedLyric[] | null> {
const language = settings.store.TranslateTo;
// Why not make only one request to translate?
// because occasionally it will add a new line
// and i dont have a good way to handle that
return processLyrics(lyrics, language, false);
}
export async function romanizeLyrics(lyrics: LyricsData["lyricsVersions"][Provider]): Promise<SyncedLyric[] | null> {
// Why not make only one request to romanize?
// it will romanize it as one string, and how would i know where to split it?
return processLyrics(lyrics, "", true);
}

View file

@ -1,546 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// hate having this twice but oh well
export default [
{
value: "auto",
label: "Detect language"
},
{
value: "af",
label: "Afrikaans"
},
{
value: "sq",
label: "Albanian"
},
{
value: "am",
label: "Amharic"
},
{
value: "ar",
label: "Arabic"
},
{
value: "hy",
label: "Armenian"
},
{
value: "as",
label: "Assamese"
},
{
value: "ay",
label: "Aymara"
},
{
value: "az",
label: "Azerbaijani"
},
{
value: "bm",
label: "Bambara"
},
{
value: "eu",
label: "Basque"
},
{
value: "be",
label: "Belarusian"
},
{
value: "bn",
label: "Bengali"
},
{
value: "bho",
label: "Bhojpuri"
},
{
value: "bs",
label: "Bosnian"
},
{
value: "bg",
label: "Bulgarian"
},
{
value: "ca",
label: "Catalan"
},
{
value: "ceb",
label: "Cebuano"
},
{
value: "ny",
label: "Chichewa"
},
{
value: "zh-CN",
label: "Chinese (Simplified)"
},
{
value: "zh-TW",
label: "Chinese (Traditional)"
},
{
value: "co",
label: "Corsican"
},
{
value: "hr",
label: "Croatian"
},
{
value: "cs",
label: "Czech"
},
{
value: "da",
label: "Danish"
},
{
value: "dv",
label: "Dhivehi"
},
{
value: "doi",
label: "Dogri"
},
{
value: "nl",
label: "Dutch"
},
{
value: "en",
label: "English",
default: true
},
{
value: "eo",
label: "Esperanto"
},
{
value: "et",
label: "Estonian"
},
{
value: "ee",
label: "Ewe"
},
{
value: "tl",
label: "Filipino"
},
{
value: "fi",
label: "Finnish"
},
{
value: "fr",
label: "French"
},
{
value: "fy",
label: "Frisian"
},
{
value: "gl",
label: "Galician"
},
{
value: "ka",
label: "Georgian"
},
{
value: "de",
label: "German"
},
{
value: "el",
label: "Greek"
},
{
value: "gn",
label: "Guarani"
},
{
value: "gu",
label: "Gujarati"
},
{
value: "ht",
label: "Haitian Creole"
},
{
value: "ha",
label: "Hausa"
},
{
value: "haw",
label: "Hawaiian"
},
{
value: "iw",
label: "Hebrew"
},
{
value: "hi",
label: "Hindi"
},
{
value: "hmn",
label: "Hmong"
},
{
value: "hu",
label: "Hungarian"
},
{
value: "is",
label: "Icelandic"
},
{
value: "ig",
label: "Igbo"
},
{
value: "ilo",
label: "Ilocano"
},
{
value: "id",
label: "Indonesian"
},
{
value: "ga",
label: "Irish"
},
{
value: "it",
label: "Italian"
},
{
value: "ja",
label: "Japanese"
},
{
value: "jw",
label: "Javanese"
},
{
value: "kn",
label: "Kannada"
},
{
value: "kk",
label: "Kazakh"
},
{
value: "km",
label: "Khmer"
},
{
value: "rw",
label: "Kinyarwanda"
},
{
value: "gom",
label: "Konkani"
},
{
value: "ko",
label: "Korean"
},
{
value: "kri",
label: "Krio"
},
{
value: "ku",
label: "Kurdish (Kurmanji)"
},
{
value: "ckb",
label: "Kurdish (Sorani)"
},
{
value: "ky",
label: "Kyrgyz"
},
{
value: "lo",
label: "Lao"
},
{
value: "la",
label: "Latin"
},
{
value: "lv",
label: "Latvian"
},
{
value: "ln",
label: "Lingala"
},
{
value: "lt",
label: "Lithuanian"
},
{
value: "lg",
label: "Luganda"
},
{
value: "lb",
label: "Luxembourgish"
},
{
value: "mk",
label: "Macedonian"
},
{
value: "mai",
label: "Maithili"
},
{
value: "mg",
label: "Malagasy"
},
{
value: "ms",
label: "Malay"
},
{
value: "ml",
label: "Malayalam"
},
{
value: "mt",
label: "Maltese"
},
{
value: "mi",
label: "Maori"
},
{
value: "mr",
label: "Marathi"
},
{
value: "mni-Mtei",
label: "Meiteilon (Manipuri)"
},
{
value: "lus",
label: "Mizo"
},
{
value: "mn",
label: "Mongolian"
},
{
value: "my",
label: "Myanmar (Burmese)"
},
{
value: "ne",
label: "Nepali"
},
{
value: "no",
label: "Norwegian"
},
{
value: "or",
label: "Odia (Oriya)"
},
{
value: "om",
label: "Oromo"
},
{
value: "ps",
label: "Pashto"
},
{
value: "fa",
label: "Persian"
},
{
value: "pl",
label: "Polish"
},
{
value: "pt",
label: "Portuguese"
},
{
value: "pa",
label: "Punjabi"
},
{
value: "qu",
label: "Quechua"
},
{
value: "ro",
label: "Romanian"
},
{
value: "ru",
label: "Russian"
},
{
value: "sm",
label: "Samoan"
},
{
value: "sa",
label: "Sanskrit"
},
{
value: "gd",
label: "Scots Gaelic"
},
{
value: "nso",
label: "Sepedi"
},
{
value: "sr",
label: "Serbian"
},
{
value: "st",
label: "Sesotho"
},
{
value: "sn",
label: "Shona"
},
{
value: "sd",
label: "Sindhi"
},
{
value: "si",
label: "Sinhala"
},
{
value: "sk",
label: "Slovak"
},
{
value: "sl",
label: "Slovenian"
},
{
value: "so",
label: "Somali"
},
{
value: "es",
label: "Spanish"
},
{
value: "su",
label: "Sundanese"
},
{
value: "sw",
label: "Swahili"
},
{
value: "sv",
label: "Swedish"
},
{
value: "tg",
label: "Tajik"
},
{
value: "ta",
label: "Tamil"
},
{
value: "tt",
label: "Tatar"
},
{
value: "te",
label: "Telugu"
},
{
value: "th",
label: "Thai"
},
{
value: "ti",
label: "Tigrinya"
},
{
value: "ts",
label: "Tsonga"
},
{
value: "tr",
label: "Turkish"
},
{
value: "tk",
label: "Turkmen"
},
{
value: "ak",
label: "Twi"
},
{
value: "uk",
label: "Ukrainian"
},
{
value: "ur",
label: "Urdu"
},
{
value: "ug",
label: "Uyghur"
},
{
value: "uz",
label: "Uzbek"
},
{
value: "vi",
label: "Vietnamese"
},
{
value: "cy",
label: "Welsh"
},
{
value: "xh",
label: "Xhosa"
},
{
value: "yi",
label: "Yiddish"
},
{
value: "yo",
label: "Yoruba"
},
{
value: "zu",
label: "Zulu"
}
];

View file

@ -1,23 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface SyncedLyric {
time: number;
text: string | null;
}
export enum Provider {
Lrclib = "LRCLIB",
Spotify = "Spotify",
Translated = "Translated",
Romanized = "Romanized",
None = "None",
}
export interface LyricsData {
lyricsVersions: Partial<Record<Provider, SyncedLyric[] | null>>;
useLyric: Provider;
}

View file

@ -1,146 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { makeRange, SettingSliderComponent } from "@components/PluginSettings/components";
import { useAwaiter } from "@utils/react";
import { OptionType } from "@utils/types";
import { Button, showToast, Text, Toasts, useMemo } from "@webpack/common";
import { clearLyricsCache, getLyricsCount, removeTranslations } from "./api";
import { Lyrics } from "./components/lyrics";
import { useLyrics } from "./components/util";
import languages from "./providers/translator/languages";
import { Provider } from "./providers/types";
const sliderOptions = {
markers: makeRange(-2500, 2500, 250),
stickToMarkers: true,
};
function Details() {
const { lyricsInfo } = useLyrics();
const [count, error, loading] = useAwaiter(
useMemo(() => getLyricsCount, []),
{
onError: () => console.error("Failed to get lyrics count"),
fallbackValue: null,
}
);
return (
<>
<Text>Current lyrics provider: {lyricsInfo?.useLyric || "None"}</Text>
{loading ? <Text>Loading lyrics count...</Text> : error ? <Text>Failed to get lyrics count</Text> : <Text>Lyrics count: {count}</Text>}
</>
);
}
const settings = definePluginSettings({
ShowMusicNoteOnNoLyrics: {
description: "Show a music note icon when no lyrics are found",
type: OptionType.BOOLEAN,
default: true,
},
LyricsPosition: {
description: "Position of the lyrics",
type: OptionType.SELECT,
options: [
{ value: "above", label: "Above SpotifyControls" },
{ value: "below", label: "Below SpotifyControls", default: true },
],
},
LyricsProvider: {
description: "Where lyrics are fetched from",
type: OptionType.SELECT,
options: [
{ value: Provider.Spotify, label: "Spotify (Musixmatch)", default: true },
{ value: Provider.Lrclib, label: "LRCLIB" },
],
},
FallbackProvider: {
description: "When a lyrics provider fails, try other providers",
type: OptionType.BOOLEAN,
default: true,
},
TranslateTo: {
description: "Translate lyrics to - Changing this will clear existing translations",
type: OptionType.SELECT,
options: languages,
onChange: async () => {
await removeTranslations();
showToast("Translations cleared", Toasts.Type.SUCCESS);
}
},
LyricsConversion: {
description: "Automatically translate or romanize lyrics",
type: OptionType.SELECT,
options: [
{ value: Provider.None, label: "None", default: true },
{ value: Provider.Translated, label: "Translate" },
{ value: Provider.Romanized, label: "Romanize" },
]
},
ShowFailedToasts: {
description: "Hide toasts when lyrics fail to fetch",
type: OptionType.BOOLEAN,
default: true,
},
LyricDelay: {
description: "",
type: OptionType.SLIDER,
default: 0,
hidden: true,
...sliderOptions
},
Display: {
description: "",
type: OptionType.COMPONENT,
component: () => (
<>
<SettingSliderComponent
option={{ ...sliderOptions } as any}
onChange={v => {
settings.store.LyricDelay = v;
}}
pluginSettings={Vencord.Settings.plugins.SpotifyLyrics}
id={"LyricDelay"}
onError={() => { }}
/>
<Lyrics />
</>
)
},
Details: {
description: "",
type: OptionType.COMPONENT,
component: () => <Details />,
},
PurgeLyricsCache: {
description: "Purge the lyrics cache",
type: OptionType.COMPONENT,
component: () => (
<Button
color={Button.Colors.RED}
onClick={() => {
clearLyricsCache();
showToast("Lyrics cache purged", Toasts.Type.SUCCESS);
}}
>
Purge Cache
</Button>
),
},
TestingCache: {
description: "Save songs to a testing cache instead",
type: OptionType.BOOLEAN,
default: false,
hidden: true,
}
});
export default settings;

View file

@ -1,78 +0,0 @@
.vc-spotify-lyrics {
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--background-modifier-accent);
text-align: center;
overflow: hidden;
max-height: 60px;
cursor: pointer;
}
.vc-spotify-lyrics-music-note {
font-size: 16px;
text-align: center;
animation: side-to-side 2s ease-in-out infinite;
color: var(--text-muted);
height: 18px;
margin: 3px;
}
.vc-spotify-lyrics-next,
.vc-spotify-lyrics-prev {
opacity: 0.6;
margin-bottom: 1%;
}
@keyframes side-to-side {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(-10px);
}
}
.vc-spotify-lyrics-header-content {
display: flex;
align-items: center;
width: 100%;
}
.vc-spotify-lyrics-album-image {
width: 100px;
height: 100px;
margin-right: 5%;
border-radius: 5px;
cursor: pointer;
}
.vc-spotify-lyrics-modal-line-current,
.vc-spotify-lyrics-modal-line {
margin: 4px 0;
padding: 4px 8px;
position: relative;
}
.vc-spotify-lyrics-modal-line-current {
font-weight: bold;
}
.vc-spotify-lyrics-modal-note {
height: 1lh;
}
.vc-spotify-lyrics-modal-timestamp {
color: var(--text-muted);
margin-right: 0.5em;
cursor: pointer;
}
.vc-spotify-lyrics-modal-timestamp:hover {
text-decoration: underline;
}
.vc-spotify-lyrics-modal-no-lyrics {
text-align: center;
padding: 1rem;
}

View file

@ -1,32 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { addChatBarButton, ChatBarButton, ChatBarButtonFactory, removeChatBarButton } from "@api/ChatButtons";
import { Devs } from "@utils/constants";
import { getCurrentChannel, sendMessage } from "@utils/discord";
import definePlugin from "@utils/types";
async function handleButtonClick() {
// @ts-expect-error typing issue
sendMessage(getCurrentChannel().id, { content: "woof" });
}
const ChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => {
if (!isMainChat) return null;
return (
<ChatBarButton tooltip="Woof" onClick={handleButtonClick}>
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 576 512"><path fill="currentColor" d="m309.6 158.5l23.1-138.7C334.6 8.4 344.5 0 356.1 0c7.5 0 14.5 3.5 19 9.5L392 32h52.1c12.7 0 24.9 5.1 33.9 14.1L496 64h56c13.3 0 24 10.7 24 24v24c0 44.2-35.8 80-80 80h-69.3l-5.1 30.5zM416 256.1V480c0 17.7-14.3 32-32 32h-32c-17.7 0-32-14.3-32-32V364.8c-24 12.3-51.2 19.2-80 19.2s-56-6.9-80-19.2V480c0 17.7-14.3 32-32 32H96c-17.7 0-32-14.3-32-32V249.8c-28.8-10.9-51.4-35.3-59.2-66.5L1 167.8c-4.3-17.1 6.1-34.5 23.3-38.8s34.5 6.1 38.8 23.3l3.9 15.5C70.5 182 83.3 192 98 192h205.8zM464 80a16 16 0 1 0-32 0a16 16 0 1 0 32 0" /></svg>
</ChatBarButton>
);
};
export default definePlugin({
name: "Woof",
description: "Adds a chatbar button to woof in chat",
authors: [Devs.Samwich],
start: () => addChatBarButton("Woof", ChatBarIcon),
stop: () => removeChatBarButton("Woof")
});

View file

@ -71,7 +71,7 @@ export async function installExt(id: string) {
const buf = await get(url, {
headers: {
"User-Agent": `Electron ${process.versions.electron} ~ Equicord (https://github.com/Equicord/Equicord)`
"User-Agent": `Electron ${process.versions.electron} ~ Equicord (https://github.com/Rayanzay/ryncord)`
}
});

View file

@ -83,7 +83,7 @@ async function generateDebugInfoMessage() {
const info = {
Equicord:
`v${VERSION} • [${gitHash}](<https://github.com/Equicord/Equicord/commit/${gitHash}>)` +
`v${VERSION} • [${gitHash}](<https://github.com/Rayanzay/ryncord/commit/${gitHash}>)` +
`${SettingsPlugin.additionalInfo} - ${Intl.DateTimeFormat("en-GB", { dateStyle: "medium" }).format(BUILD_TIMESTAMP)}`,
Client: `${RELEASE_CHANNEL} ~ ${client}`,
Platform: typeof DiscordNative !== "undefined" ?
@ -265,8 +265,8 @@ export default definePlugin({
<Forms.FormText>You are using a custom build of Equicord, which we do not provide support for!</Forms.FormText>
<Forms.FormText className={Margins.top8}>
We only provide support for <Link href="https://github.com/Equicord/Equicord">official builds</Link>.
Either <Link href="https://github.com/Equicord/Equilotl">switch to an official build</Link> or figure your issue out yourself.
We only provide support for <Link href="https://github.com/Rayanzay/ryncord">official builds</Link>.
Either <Link href="https://github.com/Rayanzay/rynstaller">switch to an official build</Link> or figure your issue out yourself.
</Forms.FormText>
<Text variant="text-md/bold" className={Margins.top8}>You will be banned from receiving support if you ignore this rule.</Text>