Feat plugin stock (#196)
This commit is contained in:
BIN
.github/readme/imgs/plugin_stock_token.png
vendored
Normal file
BIN
.github/readme/imgs/plugin_stock_token.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -28,10 +28,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**Formatter */
|
/**Formatter */
|
||||||
export function format(n, {sign = false} = {}) {
|
export function format(n, {sign = false, unit = true} = {}) {
|
||||||
for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}]) {
|
if (unit) {
|
||||||
if (n/v >= 1)
|
for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}]) {
|
||||||
return `${(sign)&&(n > 0) ? "+" : ""}${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}`
|
if (n/v >= 1)
|
||||||
|
return `${(sign)&&(n > 0) ? "+" : ""}${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return `${(sign)&&(n > 0) ? "+" : ""}${n}`
|
return `${(sign)&&(n > 0) ? "+" : ""}${n}`
|
||||||
}
|
}
|
||||||
|
|||||||
75
source/app/mocks/api/axios/get/yahoo.mjs
Normal file
75
source/app/mocks/api/axios/get/yahoo.mjs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**Mocked data */
|
||||||
|
export default function({faker, url, options, login = faker.internet.userName()}) {
|
||||||
|
//Wakatime api
|
||||||
|
if (/^https:..apidojo-yahoo-finance-v1.p.rapidapi.com.stock.v2.*$/.test(url)) {
|
||||||
|
//Get company profile
|
||||||
|
if (/get-profile/.test(url)) {
|
||||||
|
console.debug(`metrics/compute/mocks > mocking yahoo finance api result > ${url}`)
|
||||||
|
return ({
|
||||||
|
status:200,
|
||||||
|
data:{
|
||||||
|
price:{
|
||||||
|
marketCap:{
|
||||||
|
raw:faker.random.number(1000000000),
|
||||||
|
},
|
||||||
|
symbol:"OCTO",
|
||||||
|
},
|
||||||
|
quoteType:{
|
||||||
|
shortName:faker.company.companyName(),
|
||||||
|
longName:faker.company.companyName(),
|
||||||
|
exchangeTimezoneName:faker.address.timeZone(),
|
||||||
|
symbol:"OCTO",
|
||||||
|
},
|
||||||
|
calendarEvents:{},
|
||||||
|
summaryDetail:{},
|
||||||
|
symbol:"OCTO",
|
||||||
|
assetProfile:{
|
||||||
|
fullTimeEmployees:faker.random.number(10000),
|
||||||
|
city:faker.address.city(),
|
||||||
|
country:faker.address.country(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
//Get stock chart
|
||||||
|
if (/get-chart/.test(url)) {
|
||||||
|
console.debug(`metrics/compute/mocks > mocking yahoo finance api result > ${url}`)
|
||||||
|
return ({
|
||||||
|
status:200,
|
||||||
|
data:{
|
||||||
|
chart:{
|
||||||
|
result:[
|
||||||
|
{
|
||||||
|
meta:{
|
||||||
|
currency:"USD",
|
||||||
|
symbol:"OCTO",
|
||||||
|
regularMarketPrice:faker.random.number(10000)/100,
|
||||||
|
chartPreviousClose:faker.random.number(10000)/100,
|
||||||
|
previousClose:faker.random.number(10000)/100,
|
||||||
|
},
|
||||||
|
timestamp:new Array(1000).fill(Date.now()).map((x, i) => x+i*60000),
|
||||||
|
indicators:{
|
||||||
|
quote:[
|
||||||
|
{
|
||||||
|
close:new Array(1000).fill(null).map(_ => faker.random.number(10000)/100),
|
||||||
|
get low() {
|
||||||
|
return this.close
|
||||||
|
},
|
||||||
|
get high() {
|
||||||
|
return this.close
|
||||||
|
},
|
||||||
|
get open() {
|
||||||
|
return this.close
|
||||||
|
},
|
||||||
|
volume:[],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
favorites:distribution(7).map((value, index, array) => ({name:faker.lorem.word(), color:faker.internet.color(), value, size:faker.random.number(1000000), x:array.slice(0, index).reduce((a, b) => a + b, 0)}))
|
favorites:distribution(7).map((value, index, array) => ({name:faker.lorem.word(), color:faker.internet.color(), value, size:faker.random.number(1000000), x:array.slice(0, index).reduce((a, b) => a + b, 0)}))
|
||||||
}
|
}
|
||||||
}) : null),
|
}) : null),
|
||||||
//Languages
|
//RSS
|
||||||
...(set.plugins.enabled.rss ? ({
|
...(set.plugins.enabled.rss ? ({
|
||||||
rss:{
|
rss:{
|
||||||
source:faker.lorem.words(),
|
source:faker.lorem.words(),
|
||||||
@@ -242,6 +242,20 @@
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}) : null),
|
}) : null),
|
||||||
|
//Stock price
|
||||||
|
...(set.plugins.enabled.stock ? ({
|
||||||
|
stock:{
|
||||||
|
chart:"(stock chart is not displayed in placeholder)",
|
||||||
|
currency:"USD",
|
||||||
|
price:faker.random.number(10000)/100,
|
||||||
|
previous:faker.random.number(10000)/100,
|
||||||
|
get delta() { return this.price-this.previous },
|
||||||
|
symbol:options["stock.symbol"],
|
||||||
|
company:faker.company.companyName(),
|
||||||
|
interval:options["stock.interval"],
|
||||||
|
duration:options["stock.duration"],
|
||||||
|
}
|
||||||
|
}) : null),
|
||||||
//Habits
|
//Habits
|
||||||
...(set.plugins.enabled.habits ? ({
|
...(set.plugins.enabled.habits ? ({
|
||||||
habits:{
|
habits:{
|
||||||
|
|||||||
34
source/plugins/stock/README.md
Normal file
34
source/plugins/stock/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
### 💹 Stock prices
|
||||||
|
|
||||||
|
The *stock* plugin lets you display the stock market price of a given company.
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<td align="center">
|
||||||
|
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.stock.svg">
|
||||||
|
<img width="900" height="1" alt="">
|
||||||
|
</td>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>💬 Obtaining a RapidAPI Yahoo Finance token</summary>
|
||||||
|
|
||||||
|
Create a [RapidAPI account](https://rapidapi.com) and subscribe to [Yahoo Finance API](https://rapidapi.com/apidojo/api/yahoo-finance1) to get a token.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
#### ℹ️ Examples workflows
|
||||||
|
|
||||||
|
[➡️ Available options for this plugin](metadata.yml)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: lowlighter/metrics@latest
|
||||||
|
with:
|
||||||
|
# ... other options
|
||||||
|
plugin_stock: yes
|
||||||
|
plugin_stock_token: ${{ secrets.STOCK_TOKEN }} # RapidAPI Yahoo Finance token
|
||||||
|
plugin_stock_symbol: TSLA # Display Tesla stock price
|
||||||
|
plugin_stock_duration: 1d # Display last day of market
|
||||||
|
plugin_stock_interval: 5m # Use precision of 5 minutes for each record
|
||||||
|
```
|
||||||
60
source/plugins/stock/index.mjs
Normal file
60
source/plugins/stock/index.mjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
//Setup
|
||||||
|
export default async function({login, q, imports, data, account}, {enabled = false, token} = {}) {
|
||||||
|
//Plugin execution
|
||||||
|
try {
|
||||||
|
//Check if plugin is enabled and requirements are met
|
||||||
|
if ((!enabled)||(!q.stock))
|
||||||
|
return null
|
||||||
|
|
||||||
|
//Load inputs
|
||||||
|
let {symbol, interval, duration} = imports.metadata.plugins.stock.inputs({data, account, q})
|
||||||
|
if (!token)
|
||||||
|
throw {error:{message:"A token is required"}}
|
||||||
|
if (!symbol)
|
||||||
|
throw {error:{message:"A company stock symbol is required"}}
|
||||||
|
symbol = symbol.toLocaleUpperCase()
|
||||||
|
|
||||||
|
//Query API for company informations
|
||||||
|
console.debug(`metrics/compute/${login}/plugins > stock > querying api for company`)
|
||||||
|
const {data:{quoteType:{shortName:company}}} = await imports.axios.get("https://apidojo-yahoo-finance-v1.p.rapidapi.com/stock/v2/get-profile", {
|
||||||
|
params:{symbol, region:"US"},
|
||||||
|
headers:{"x-rapidapi-key":token},
|
||||||
|
})
|
||||||
|
|
||||||
|
//Query API for sotck charts
|
||||||
|
console.debug(`metrics/compute/${login}/plugins > stock > querying api for stock`)
|
||||||
|
const {data:{chart:{result:[{meta, timestamp, indicators:{quote:[{close}]}}]}}} = await imports.axios.get("https://apidojo-yahoo-finance-v1.p.rapidapi.com/stock/v2/get-chart", {
|
||||||
|
params:{interval, symbol, range:duration, region:"US"},
|
||||||
|
headers:{"x-rapidapi-key":token},
|
||||||
|
})
|
||||||
|
const {currency, regularMarketPrice:price, previousClose:previous} = meta
|
||||||
|
|
||||||
|
//Generating chart
|
||||||
|
console.debug(`metrics/compute/${login}/plugins > stock > generating chart`)
|
||||||
|
const chart = await imports.chartist("line", {
|
||||||
|
width:480,
|
||||||
|
height:160,
|
||||||
|
showPoint:false,
|
||||||
|
axisX:{showGrid:false, labelInterpolationFnc:(value, index) => index%Math.floor(close.length/4) === 0 ? value : null},
|
||||||
|
axisY:{scaleMinSpace:20},
|
||||||
|
showArea:true,
|
||||||
|
}, {
|
||||||
|
labels:timestamp.map(timestamp => new Intl.DateTimeFormat("en-GB", {month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit"}).format(new Date(timestamp*1000))),
|
||||||
|
series:[close],
|
||||||
|
})
|
||||||
|
|
||||||
|
//Results
|
||||||
|
return {chart, currency, price, previous, delta:price-previous, symbol, company, interval, duration}
|
||||||
|
}
|
||||||
|
//Handle errors
|
||||||
|
catch (error) {
|
||||||
|
let message = "An error occured"
|
||||||
|
if (error.isAxiosError) {
|
||||||
|
const status = error.response?.status
|
||||||
|
const description = error.response?.data?.message ?? null
|
||||||
|
message = `API returned ${status}${description ? ` (${description})` : ""}`
|
||||||
|
error = error.response?.data ?? null
|
||||||
|
}
|
||||||
|
throw {error:{message, instance:error}}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
source/plugins/stock/metadata.yml
Normal file
58
source/plugins/stock/metadata.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: "💹 Stock prices"
|
||||||
|
cost: N/A
|
||||||
|
categorie: other
|
||||||
|
index: 1
|
||||||
|
supports:
|
||||||
|
- user
|
||||||
|
- organization
|
||||||
|
inputs:
|
||||||
|
|
||||||
|
# Enable or disable plugin
|
||||||
|
plugin_stock:
|
||||||
|
description: Display stock prices of a given company
|
||||||
|
type: boolean
|
||||||
|
default: no
|
||||||
|
|
||||||
|
# RapidAPI Yahoo finance token
|
||||||
|
# Case insensitive
|
||||||
|
plugin_stock_token:
|
||||||
|
description: Yahoo Finance token
|
||||||
|
type: token
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
# Company stock symbol (required)
|
||||||
|
plugin_stock_symbol:
|
||||||
|
description: Company stock symbol
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
# Time range to display (relative to current date)
|
||||||
|
plugin_stock_duration:
|
||||||
|
description: Time range to display
|
||||||
|
type: string
|
||||||
|
default: 1d
|
||||||
|
values:
|
||||||
|
- 1d # Today
|
||||||
|
- 5d # 5 days
|
||||||
|
- 1mo # 1 month
|
||||||
|
- 3mo # 3 months
|
||||||
|
- 6mo # 6 months
|
||||||
|
- 1y # 1 year
|
||||||
|
- 2y # 2 years
|
||||||
|
- 5y # 5 years
|
||||||
|
- 10y # 10 years
|
||||||
|
- ytd # Year to date
|
||||||
|
- max # All time
|
||||||
|
|
||||||
|
# Time invervals between each records over the given time range
|
||||||
|
plugin_stock_interval:
|
||||||
|
description: Time intervals between records
|
||||||
|
type: string
|
||||||
|
default: 5m
|
||||||
|
values:
|
||||||
|
- 1m # 1 minute
|
||||||
|
- 2m # 2 minutes
|
||||||
|
- 5m # 5 minutes
|
||||||
|
- 15m # 15 minutes
|
||||||
|
- 60m # 60 minutes
|
||||||
|
- 1d # 1 day
|
||||||
17
source/plugins/stock/tests.yml
Normal file
17
source/plugins/stock/tests.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
- name: Stock plugin (default)
|
||||||
|
uses: lowlighter/metrics@latest
|
||||||
|
with:
|
||||||
|
token: NOT_NEEDED
|
||||||
|
plugin_stock: yes
|
||||||
|
plugin_stock_token: MOCKED_TOKEN
|
||||||
|
plugin_stock_symbol: OCTO
|
||||||
|
|
||||||
|
- name: Stock plugin (complete)
|
||||||
|
uses: lowlighter/metrics@latest
|
||||||
|
with:
|
||||||
|
token: NOT_NEEDED
|
||||||
|
plugin_stock: yes
|
||||||
|
plugin_stock_token: MOCKED_TOKEN
|
||||||
|
plugin_stock_symbol: OCTO
|
||||||
|
plugin_stock_duration: 5d
|
||||||
|
plugin_stock_interval: 5m
|
||||||
@@ -25,5 +25,6 @@
|
|||||||
"wakatime",
|
"wakatime",
|
||||||
"skyline",
|
"skyline",
|
||||||
"stackoverflow",
|
"stackoverflow",
|
||||||
|
"stock",
|
||||||
"achievements"
|
"achievements"
|
||||||
]
|
]
|
||||||
48
source/templates/classic/partials/stock.ejs
Normal file
48
source/templates/classic/partials/stock.ejs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<% if (plugins.stock) { %>
|
||||||
|
<section>
|
||||||
|
<h2 class="field">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M10.75 9a.75.75 0 000 1.5h1.5a.75.75 0 000-1.5h-1.5z"></path><path fill-rule="evenodd" d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0114.25 14H1.75A1.75 1.75 0 010 12.25v-8.5zm14.5 0V5h-13V3.75a.25.25 0 01.25-.25h12.5a.25.25 0 01.25.25zm0 2.75h-13v5.75c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V6.5z"></path></svg>
|
||||||
|
Stock prices <%= plugins.stock.symbol ? `for ${plugins.stock.symbol}` : "" %>
|
||||||
|
</h2>
|
||||||
|
<% if (plugins.stock.error) { %>
|
||||||
|
<div class="row fill-width">
|
||||||
|
<section>
|
||||||
|
<div class="field error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M2.343 13.657A8 8 0 1113.657 2.343 8 8 0 012.343 13.657zM6.03 4.97a.75.75 0 00-1.06 1.06L6.94 8 4.97 9.97a.75.75 0 101.06 1.06L8 9.06l1.97 1.97a.75.75 0 101.06-1.06L9.06 8l1.97-1.97a.75.75 0 10-1.06-1.06L8 6.94 6.03 4.97z"></path></svg>
|
||||||
|
<%= plugins.stock.error.message %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="row">
|
||||||
|
<section>
|
||||||
|
<div class="field">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 14.25c0 .138.112.25.25.25H4v-1.25a.75.75 0 01.75-.75h2.5a.75.75 0 01.75.75v1.25h2.25a.25.25 0 00.25-.25V1.75a.25.25 0 00-.25-.25h-8.5a.25.25 0 00-.25.25v12.5zM1.75 16A1.75 1.75 0 010 14.25V1.75C0 .784.784 0 1.75 0h8.5C11.216 0 12 .784 12 1.75v12.5c0 .085-.006.168-.018.25h2.268a.25.25 0 00.25-.25V8.285a.25.25 0 00-.111-.208l-1.055-.703a.75.75 0 11.832-1.248l1.055.703c.487.325.779.871.779 1.456v5.965A1.75 1.75 0 0114.25 16h-3.5a.75.75 0 01-.197-.026c-.099.017-.2.026-.303.026h-3a.75.75 0 01-.75-.75V14h-1v1.25a.75.75 0 01-.75.75h-3zM3 3.75A.75.75 0 013.75 3h.5a.75.75 0 010 1.5h-.5A.75.75 0 013 3.75zM3.75 6a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM3 9.75A.75.75 0 013.75 9h.5a.75.75 0 010 1.5h-.5A.75.75 0 013 9.75zM7.75 9a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM7 6.75A.75.75 0 017.75 6h.5a.75.75 0 010 1.5h-.5A.75.75 0 017 6.75zM7.75 3a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5z"></path></svg>
|
||||||
|
<%= plugins.stock.company %>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M6 2a.75.75 0 01.696.471L10 10.731l1.304-3.26A.75.75 0 0112 7h3.25a.75.75 0 010 1.5h-2.742l-1.812 4.528a.75.75 0 01-1.392 0L6 4.77 4.696 8.03A.75.75 0 014 8.5H.75a.75.75 0 010-1.5h2.742l1.812-4.529A.75.75 0 016 2z"></path></svg>
|
||||||
|
Valued at <%= plugins.stock.price.toFixed(2) %> <%= plugins.stock.currency %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="field">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4.75 0a.75.75 0 01.75.75V2h5V.75a.75.75 0 011.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0113.25 16H2.75A1.75 1.75 0 011 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 014.75 0zm0 3.5h8.5a.25.25 0 01.25.25V6h-11V3.75a.25.25 0 01.25-.25h2zm-2.25 4v6.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V7.5h-11z"></path></svg>
|
||||||
|
<%= {"1d":"Today", "5d":"Last five days", "1mo":"Last month", "3mo":"Last trimester", "6mo":"Last semester", "1y":"Last year", "2y":"Last two years", "5y":"Last five years", "10y":"Last ten years", ytd:"Year to date", max:"All-time"}[plugins.stock.duration] %>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<% if (plugins.stock.delta > 0) { %>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.47 7.78a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 0l4.25 4.25a.75.75 0 01-1.06 1.06L9 4.81v7.44a.75.75 0 01-1.5 0V4.81L4.53 7.78a.75.75 0 01-1.06 0z"></path></svg>
|
||||||
|
<% } else { %>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M13.03 8.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.47 9.28a.75.75 0 011.06-1.06l2.97 2.97V3.75a.75.75 0 011.5 0v7.44l2.97-2.97a.75.75 0 011.06 0z"></path></svg>
|
||||||
|
<% } %>
|
||||||
|
<%= f(plugins.stock.delta.toFixed(2), {sign:true}) %> (<%= f((100*plugins.stock.delta/plugins.stock.price).toFixed(2), {sign:true}) %>%)
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="stock-chart">
|
||||||
|
<%- plugins.stock.chart %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
@@ -916,6 +916,22 @@
|
|||||||
color: #666666;
|
color: #666666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
.ct-line {
|
||||||
|
stroke-width: 2px !important;
|
||||||
|
stroke: #58A6FF !important;
|
||||||
|
}
|
||||||
|
.ct-area {
|
||||||
|
fill: #58A6FF !important;
|
||||||
|
}
|
||||||
|
.ct-label {
|
||||||
|
fill: rgba(127, 127, 127, 0.8) !important;
|
||||||
|
color: rgba(127, 127, 127, 0.8) !important;
|
||||||
|
}
|
||||||
|
.ct-grid {
|
||||||
|
stroke: rgba(127, 127, 127, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fade animation */
|
/* Fade animation */
|
||||||
.af {
|
.af {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user