First commit
This commit is contained in:
50
.eslintrc.json
Normal file
50
.eslintrc.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2021,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"arrow-spacing": ["warn", { "before": true, "after": true }],
|
||||||
|
"brace-style": ["error", "stroustrup", { "allowSingleLine": true }],
|
||||||
|
"comma-dangle": ["error", "always-multiline"],
|
||||||
|
"comma-spacing": "error",
|
||||||
|
"comma-style": "error",
|
||||||
|
"curly": ["error", "multi-line", "consistent"],
|
||||||
|
"dot-location": ["error", "property"],
|
||||||
|
"handle-callback-err": "off",
|
||||||
|
"indent": ["error", "tab"],
|
||||||
|
"keyword-spacing": "error",
|
||||||
|
"max-nested-callbacks": ["error", { "max": 4 }],
|
||||||
|
"max-statements-per-line": ["error", { "max": 2 }],
|
||||||
|
"no-console": "off",
|
||||||
|
"no-empty-function": "error",
|
||||||
|
"no-floating-decimal": "error",
|
||||||
|
"no-inline-comments": "error",
|
||||||
|
"no-lonely-if": "error",
|
||||||
|
"no-multi-spaces": "error",
|
||||||
|
"no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }],
|
||||||
|
"no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }],
|
||||||
|
"no-trailing-spaces": ["error"],
|
||||||
|
"no-var": "error",
|
||||||
|
"object-curly-spacing": ["error", "always"],
|
||||||
|
"prefer-const": "error",
|
||||||
|
"quotes": ["error", "single"],
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"space-before-blocks": "error",
|
||||||
|
"space-before-function-paren": ["error", {
|
||||||
|
"anonymous": "never",
|
||||||
|
"named": "never",
|
||||||
|
"asyncArrow": "always"
|
||||||
|
}],
|
||||||
|
"space-in-parens": "error",
|
||||||
|
"space-infix-ops": "error",
|
||||||
|
"space-unary-ops": "error",
|
||||||
|
"spaced-comment": "error",
|
||||||
|
"yoda": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
177
.gitignore
vendored
Normal file
177
.gitignore
vendored
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
|
||||||
|
pids
|
||||||
|
_.pid
|
||||||
|
_.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
config.json
|
||||||
48
Modules/cronManager.js
Normal file
48
Modules/cronManager.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
|
||||||
|
const daysOfWeek = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||||||
|
const hoursOfDay = [...Array(24).keys()].map(hour => hour.toString());
|
||||||
|
const minutesOfHour = [...Array(60).keys()].map(minute => minute.toString());
|
||||||
|
|
||||||
|
function toCronExpression(str) {
|
||||||
|
switch (str.toLowerCase()) {
|
||||||
|
case 'everyfiveseconds': {
|
||||||
|
return '*/5 * * * * *';
|
||||||
|
}
|
||||||
|
case 'everythirtyseconds': {
|
||||||
|
return '*/30 * * * * *';
|
||||||
|
}
|
||||||
|
case 'everyminute': {
|
||||||
|
return '* * * * *';
|
||||||
|
}
|
||||||
|
case 'everyfiveminutes': {
|
||||||
|
return '*/5 * * * *';
|
||||||
|
}
|
||||||
|
case 'everyfifteenminutes': {
|
||||||
|
return '*/15 * * * *';
|
||||||
|
}
|
||||||
|
case 'everythirtyminutes': {
|
||||||
|
return '*/30 * * * *';
|
||||||
|
}
|
||||||
|
case 'everyday': {
|
||||||
|
return '0 0 * * *';
|
||||||
|
}
|
||||||
|
case 'weekdays': {
|
||||||
|
return '0 0 * * 1-5';
|
||||||
|
}
|
||||||
|
case 'weekends': {
|
||||||
|
return '0 0 * * 6,7';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const [day, hour, minute] = str.toLowerCase().split(' ');
|
||||||
|
const dayIndex = daysOfWeek.indexOf(day);
|
||||||
|
const hourIndex = hoursOfDay.indexOf(hour);
|
||||||
|
const minuteIndex = minutesOfHour.indexOf(minute);
|
||||||
|
return `${minuteIndex} ${hourIndex} * * ${dayIndex}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCron(rate, exec) {
|
||||||
|
cron.schedule(toCronExpression(rate), exec);
|
||||||
|
}
|
||||||
71
Modules/fetchManager.js
Normal file
71
Modules/fetchManager.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { error } from './logManager';
|
||||||
|
|
||||||
|
async function get(url, token) {
|
||||||
|
const options = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', authorization: `${token}` },
|
||||||
|
};
|
||||||
|
|
||||||
|
return await fetch(url, options)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(json => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch(err => error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(url, body, token) {
|
||||||
|
const options = {
|
||||||
|
method: 'POST',
|
||||||
|
mode: 'cors',
|
||||||
|
headers: { 'Content-Type': 'application/json', authorization: `${token}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await fetch(url, options)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(json => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch(err => error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(url, body, token) {
|
||||||
|
const options = {
|
||||||
|
method: 'PATCH',
|
||||||
|
mode: 'cors',
|
||||||
|
headers: { 'Content-Type': 'application/json', authorization: `${token}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await fetch(url, options)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(json => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch(err => error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function put(url, body, token) {
|
||||||
|
const options = {
|
||||||
|
method: 'PUT',
|
||||||
|
mode: 'cors',
|
||||||
|
headers: { 'Content-Type': 'application/json', authorization: `${token}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await fetch(url, options)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(json => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch(err => error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
get,
|
||||||
|
post,
|
||||||
|
patch,
|
||||||
|
put,
|
||||||
|
};
|
||||||
86
Modules/htmlManager.js
Normal file
86
Modules/htmlManager.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import { log } from './logManager';
|
||||||
|
|
||||||
|
function stylizeHTML(html) {
|
||||||
|
return `<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #333;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
background-color: #444;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${html}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePostHtml(posts) {
|
||||||
|
return posts.map(post => `
|
||||||
|
<div>
|
||||||
|
<h2>${post.title}</h2>
|
||||||
|
<p>Date: ${post.date}</p>
|
||||||
|
<p>Flair: ${post.flair}</p>
|
||||||
|
<p>Linked: <a href="${post.linked}">${post.linked}</a></p>
|
||||||
|
<p>${post.description}</p>
|
||||||
|
${post.tldr ? `<p>TLDR: ${post.tldr}</p>` : ''}
|
||||||
|
${post.image ? `<img src="${post.image}" /><br><br>` : ''}
|
||||||
|
<a href="${post.url}">Post Link</a>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHtmlToFile(html) {
|
||||||
|
fs.writeFileSync('index.html', html);
|
||||||
|
log('HTML file saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateHtml(posts) {
|
||||||
|
const html = stylizeHTML(generatePostHtml(posts));
|
||||||
|
saveHtmlToFile(html);
|
||||||
|
}
|
||||||
19
Modules/logManager.js
Normal file
19
Modules/logManager.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
const logger = pino();
|
||||||
|
|
||||||
|
export function log(x) {
|
||||||
|
logger.info(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debug(x) {
|
||||||
|
logger.debug(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function warn(x) {
|
||||||
|
logger.warn(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function error(x) {
|
||||||
|
logger.error(x);
|
||||||
|
}
|
||||||
24
README.md
Normal file
24
README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Chromanews
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## What is Chromanews
|
||||||
|
|
||||||
|
Chromanews allows you to fetch all the top posts of news subs you follow from reddit and generate an HTML offline page for you to read. The file can also be pushed to a webhook such as discord.
|
||||||
|
|
||||||
|
You can edit the followed subs and the discord webhook in
|
||||||
|
```bash
|
||||||
|
./config.json
|
||||||
|
```
|
||||||
12
config.json.sample
Normal file
12
config.json.sample
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"subs": [
|
||||||
|
"Bitcoin",
|
||||||
|
"ethereum",
|
||||||
|
"CryptoCurrency",
|
||||||
|
"Crypto_Currency_News"
|
||||||
|
],
|
||||||
|
"discord": {
|
||||||
|
"avatar_url": "https://aostia.me/portfolio/assets/avatar.png",
|
||||||
|
"webhook": "https://discord.com/api/webhooks/id/token"
|
||||||
|
}
|
||||||
|
}
|
||||||
1713
index.html
Normal file
1713
index.html
Normal file
File diff suppressed because it is too large
Load Diff
42
index.js
Normal file
42
index.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
const { log } = require('./Modules/logManager');
|
||||||
|
const { get } = require('./Modules/fetchManager');
|
||||||
|
const { createCron } = require('./Modules/cronManager');
|
||||||
|
const { generateHtml } = require('./Modules/htmlManager');
|
||||||
|
|
||||||
|
const { subs, discord } = require('./config.json');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const posts = [];
|
||||||
|
for (const sub of subs) {
|
||||||
|
log(`Fetching data from ${sub}...`);
|
||||||
|
const res = await get(`https://www.reddit.com/r/${sub}/top/.json?t=week`);
|
||||||
|
log(`Fetched ${res.data.children.length} posts.`);
|
||||||
|
for (const post of res.data.children) {
|
||||||
|
let tldr = null;
|
||||||
|
if (sub == 'CryptoCurrency' && post.data.link_flair_text == 'GENERAL-NEWS') {
|
||||||
|
const comments = await get(`https://www.reddit.com${post.data.permalink}.json`);
|
||||||
|
for (const comment of comments[1].data.children) {
|
||||||
|
if (comment.data.author == 'coinfeeds-bot') tldr = comment.data.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
posts.push({
|
||||||
|
title: post.data.title,
|
||||||
|
url: `https://www.reddit.com${post.data.permalink}`,
|
||||||
|
linked: post.data.url,
|
||||||
|
description: `${post.data.selftext ? post.data.selftext : 'No text'}`,
|
||||||
|
image: post.data.thumbnail != 'self' ? post.data.thumbnail : null,
|
||||||
|
flair: post.data.link_flair_text ? post.data.link_flair_text : 'No flair',
|
||||||
|
date: new Date(post.data.created_utc * 1000),
|
||||||
|
tldr: tldr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('Generating HTML...');
|
||||||
|
generateHtml(posts);
|
||||||
|
log('Sending webhook...');
|
||||||
|
discord.webhook ? exec(`curl -X POST -F 'payload_json={"username":"ChromaNews","avatar_url":"${discord.avatar_url}","content":"New weekly news!"}' -F 'file1=@index.html' ${discord.webhook}`) : log('No webhook found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
createCron('monday 0 0', main);
|
||||||
27
jsconfig.json
Normal file
27
jsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Enable latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "chromanews",
|
||||||
|
"module": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run ./index.js",
|
||||||
|
"dev": "bun run ./index.js --debug"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"prettier": "^3.2.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"form-data": "^4.0.0",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"pino": "^8.19.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user